diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index de3bd076616..eb1ec568b6f 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -207,6 +207,12 @@ export const globalSettingsSchema = z.object({ * @default "send" */ enterBehavior: z.enum(["send", "newline"]).optional(), + /** + * Multiplier for the chat view font size. + * - Min: 0.5, Max: 2.0 + * @default 1.0 + */ + chatFontSizeMultiplier: z.number().min(0.5).max(2).optional(), profileThresholds: z.record(z.string(), z.number()).optional(), hasOpenedModeSelector: z.boolean().optional(), lastModeExportPath: z.string().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 15edd13db45..d5f89cdbc9a 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -299,6 +299,7 @@ export type ExtensionState = Pick< | "includeTaskHistoryInEnhance" | "reasoningBlockCollapsed" | "enterBehavior" + | "chatFontSizeMultiplier" | "includeCurrentTime" | "includeCurrentCost" | "maxGitStatusFiles" diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index fd28f2e9945..641f0e2a48e 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -47,6 +47,10 @@ export const commandIds = [ "acceptInput", "focusPanel", "toggleAutoApprove", + + "increaseChatFontSize", + "decreaseChatFontSize", + "resetChatFontSize", ] as const export type CommandId = (typeof commandIds)[number] diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index f02ee8309a3..c4184ebfe7e 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -195,6 +195,40 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt action: "toggleAutoApprove", }) }, + increaseChatFontSize: async () => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + + if (!visibleProvider) { + return + } + + const currentMultiplier = visibleProvider.contextProxy.getValue("chatFontSizeMultiplier") ?? 1 + const newMultiplier = Math.min(2, Math.round((currentMultiplier + 0.1) * 10) / 10) + await visibleProvider.contextProxy.setValue("chatFontSizeMultiplier", newMultiplier) + await visibleProvider.postStateToWebview() + }, + decreaseChatFontSize: async () => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + + if (!visibleProvider) { + return + } + + const currentMultiplier = visibleProvider.contextProxy.getValue("chatFontSizeMultiplier") ?? 1 + const newMultiplier = Math.max(0.5, Math.round((currentMultiplier - 0.1) * 10) / 10) + await visibleProvider.contextProxy.setValue("chatFontSizeMultiplier", newMultiplier) + await visibleProvider.postStateToWebview() + }, + resetChatFontSize: async () => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + + if (!visibleProvider) { + return + } + + await visibleProvider.contextProxy.setValue("chatFontSizeMultiplier", 1) + await visibleProvider.postStateToWebview() + }, }) export const openClineInNewTab = async ({ context, outputChannel }: Omit) => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b9da4b4c607..c180923a415 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2174,6 +2174,7 @@ export class ClineProvider historyPreviewCollapsed, reasoningBlockCollapsed, enterBehavior, + chatFontSizeMultiplier, cloudUserInfo, cloudIsAuthenticated, sharingEnabled, @@ -2300,6 +2301,7 @@ export class ClineProvider historyPreviewCollapsed: historyPreviewCollapsed ?? false, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, enterBehavior: enterBehavior ?? "send", + chatFontSizeMultiplier: chatFontSizeMultiplier ?? 1, cloudUserInfo, cloudIsAuthenticated: cloudIsAuthenticated ?? false, cloudAuthSkipModel: this.context.globalState.get("roo-auth-skip-model") ?? false, @@ -2523,6 +2525,7 @@ export class ClineProvider historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, enterBehavior: stateValues.enterBehavior ?? "send", + chatFontSizeMultiplier: stateValues.chatFontSizeMultiplier ?? 1, cloudUserInfo, cloudIsAuthenticated, sharingEnabled, diff --git a/src/package.json b/src/package.json index 0a6b86833c0..2412f1bb19c 100644 --- a/src/package.json +++ b/src/package.json @@ -169,6 +169,21 @@ "command": "roo-cline.toggleAutoApprove", "title": "%command.toggleAutoApprove.title%", "category": "%configuration.title%" + }, + { + "command": "roo-cline.increaseChatFontSize", + "title": "%command.increaseChatFontSize.title%", + "category": "%configuration.title%" + }, + { + "command": "roo-cline.decreaseChatFontSize", + "title": "%command.decreaseChatFontSize.title%", + "category": "%configuration.title%" + }, + { + "command": "roo-cline.resetChatFontSize", + "title": "%command.resetChatFontSize.title%", + "category": "%configuration.title%" } ], "menus": { diff --git a/src/package.nls.json b/src/package.nls.json index 177b392f775..8a92e5714bb 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -25,6 +25,9 @@ "command.terminal.explainCommand.title": "Explain This Command", "command.acceptInput.title": "Accept Input/Suggestion", "command.toggleAutoApprove.title": "Toggle Auto-Approve", + "command.increaseChatFontSize.title": "Increase Chat Font Size", + "command.decreaseChatFontSize.title": "Decrease Chat Font Size", + "command.resetChatFontSize.title": "Reset Chat Font Size", "configuration.title": "Roo Code", "commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled", "commands.deniedCommands.description": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.", diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index fd0aca66cb7..986a863c105 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -93,6 +93,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction + className={isHidden ? "hidden" : "fixed top-0 left-0 right-0 bottom-0 flex flex-col overflow-hidden"} + style={{ fontSize: `calc(1em * ${chatFontSizeMultiplier ?? 1})` }}> {telemetrySetting === "unset" && } {(showAnnouncement || showAnnouncementModal) && ( (({ onDone, t openRouterImageGenerationSelectedModel, reasoningBlockCollapsed, enterBehavior, + chatFontSizeMultiplier, includeCurrentTime, includeCurrentCost, maxGitStatusFiles, @@ -413,6 +414,7 @@ const SettingsView = forwardRef(({ onDone, t includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, enterBehavior: enterBehavior ?? "send", + chatFontSizeMultiplier: chatFontSizeMultiplier ?? 1, includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, maxGitStatusFiles: maxGitStatusFiles ?? 0, @@ -892,6 +894,7 @@ const SettingsView = forwardRef(({ onDone, t )} diff --git a/webview-ui/src/components/settings/UISettings.tsx b/webview-ui/src/components/settings/UISettings.tsx index a3488dc59e1..61fcf018280 100644 --- a/webview-ui/src/components/settings/UISettings.tsx +++ b/webview-ui/src/components/settings/UISettings.tsx @@ -1,6 +1,7 @@ -import { HTMLAttributes, useMemo } from "react" +import { HTMLAttributes, useMemo, useState, useCallback, useEffect } from "react" import { useAppTranslation } from "@/i18n/TranslationContext" import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" +import { RotateCcw } from "lucide-react" import { telemetryClient } from "@/utils/TelemetryClient" import { SetCachedStateField } from "./types" @@ -12,17 +13,27 @@ import { ExtensionStateContextType } from "@/context/ExtensionStateContext" interface UISettingsProps extends HTMLAttributes { reasoningBlockCollapsed: boolean enterBehavior: "send" | "newline" + chatFontSizeMultiplier: number setCachedStateField: SetCachedStateField } export const UISettings = ({ reasoningBlockCollapsed, enterBehavior, + chatFontSizeMultiplier, setCachedStateField, ...props }: UISettingsProps) => { const { t } = useAppTranslation() + // Local state for the input value to allow typing freely + const [localMultiplier, setLocalMultiplier] = useState(chatFontSizeMultiplier.toString()) + + // Sync local state when prop changes (e.g., from commands) + useEffect(() => { + setLocalMultiplier(chatFontSizeMultiplier.toString()) + }, [chatFontSizeMultiplier]) + // Detect platform for dynamic modifier key display const primaryMod = useMemo(() => { const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0 @@ -48,6 +59,45 @@ export const UISettings = ({ }) } + const handleFontSizeMultiplierChange = useCallback( + (value: string) => { + setLocalMultiplier(value) + + // Parse and validate the value + const numValue = parseFloat(value) + if (!isNaN(numValue)) { + // Clamp the value between 0.5 and 2 + const clampedValue = Math.max(0.5, Math.min(2, numValue)) + setCachedStateField("chatFontSizeMultiplier", clampedValue) + } + }, + [setCachedStateField], + ) + + const handleFontSizeMultiplierBlur = useCallback(() => { + // On blur, ensure the display value matches the clamped value + const numValue = parseFloat(localMultiplier) + if (isNaN(numValue)) { + setLocalMultiplier(chatFontSizeMultiplier.toString()) + } else { + const clampedValue = Math.max(0.5, Math.min(2, numValue)) + setLocalMultiplier(clampedValue.toString()) + + // Track telemetry event on blur to capture only the user's final value + telemetryClient.capture("ui_settings_chat_font_size_changed", { + multiplier: clampedValue, + }) + } + }, [localMultiplier, chatFontSizeMultiplier]) + + const handleResetFontSize = useCallback(() => { + setCachedStateField("chatFontSizeMultiplier", 1) + setLocalMultiplier("1") + + // Track telemetry event + telemetryClient.capture("ui_settings_chat_font_size_reset", {}) + }, [setCachedStateField]) + return (
{t("settings:sections.ui")} @@ -91,6 +141,43 @@ export const UISettings = ({
+ + {/* Chat Font Size Multiplier Setting */} + +
+ +
+ handleFontSizeMultiplierChange(e.target.value)} + onBlur={handleFontSizeMultiplierBlur} + className="w-20 px-2 py-1 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded" + data-testid="chat-font-size-input" + /> + +
+
+ {t("settings:ui.chatFontSizeMultiplier.description")} +
+
+
diff --git a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx index 2a21a410b38..f739b3f34a8 100644 --- a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx @@ -2,13 +2,26 @@ import { render, fireEvent, waitFor } from "@testing-library/react" import { describe, it, expect, vi } from "vitest" import { UISettings } from "../UISettings" +// Mock telemetryClient +const mockCapture = vi.fn() +vi.mock("@/utils/TelemetryClient", () => ({ + telemetryClient: { + capture: (eventName: string, properties?: Record) => mockCapture(eventName, properties), + }, +})) + describe("UISettings", () => { const defaultProps = { reasoningBlockCollapsed: false, enterBehavior: "send" as const, + chatFontSizeMultiplier: 1, setCachedStateField: vi.fn(), } + beforeEach(() => { + vi.clearAllMocks() + }) + it("renders the collapse thinking checkbox", () => { const { getByTestId } = render() const checkbox = getByTestId("collapse-thinking-checkbox") @@ -41,4 +54,146 @@ describe("UISettings", () => { rerender() expect(checkbox.checked).toBe(true) }) + + describe("Chat Font Size Multiplier", () => { + it("renders the font size input with the correct default value", () => { + const { getByTestId } = render() + const input = getByTestId("chat-font-size-input") as HTMLInputElement + expect(input).toBeTruthy() + expect(input.value).toBe("1") + }) + + it("renders the reset button", () => { + const { getByTestId } = render() + const resetButton = getByTestId("chat-font-size-reset-button") + expect(resetButton).toBeTruthy() + }) + + it("displays custom multiplier value from props", () => { + const { getByTestId } = render() + const input = getByTestId("chat-font-size-input") as HTMLInputElement + expect(input.value).toBe("1.5") + }) + + it("calls setCachedStateField on change with a valid value", () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render() + const input = getByTestId("chat-font-size-input") + + fireEvent.change(input, { target: { value: "1.5" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("chatFontSizeMultiplier", 1.5) + }) + + it("does not call setCachedStateField on change with NaN input", () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render() + const input = getByTestId("chat-font-size-input") + + fireEvent.change(input, { target: { value: "abc" } }) + + expect(setCachedStateField).not.toHaveBeenCalledWith("chatFontSizeMultiplier", expect.anything()) + }) + + it("clamps values below 0.5 to 0.5 on change", () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render() + const input = getByTestId("chat-font-size-input") + + fireEvent.change(input, { target: { value: "0.1" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("chatFontSizeMultiplier", 0.5) + }) + + it("clamps values above 2 to 2 on change", () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render() + const input = getByTestId("chat-font-size-input") + + fireEvent.change(input, { target: { value: "5" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("chatFontSizeMultiplier", 2) + }) + + it("normalizes the display value on blur for a valid value", () => { + const { getByTestId } = render() + const input = getByTestId("chat-font-size-input") as HTMLInputElement + + fireEvent.change(input, { target: { value: "0.3" } }) + fireEvent.blur(input) + + // Should be clamped to 0.5 in the display + expect(input.value).toBe("0.5") + }) + + it("resets display value to prop on blur with NaN input", () => { + const { getByTestId } = render() + const input = getByTestId("chat-font-size-input") as HTMLInputElement + + fireEvent.change(input, { target: { value: "abc" } }) + fireEvent.blur(input) + + // Should reset to the prop value + expect(input.value).toBe("1.2") + }) + + it("fires telemetry on blur, not on change", () => { + const { getByTestId } = render() + const input = getByTestId("chat-font-size-input") + + fireEvent.change(input, { target: { value: "1.5" } }) + + // Telemetry should NOT have fired on change + expect(mockCapture).not.toHaveBeenCalledWith("ui_settings_chat_font_size_changed", expect.anything()) + + fireEvent.blur(input) + + // Telemetry should fire on blur with the clamped value + expect(mockCapture).toHaveBeenCalledWith("ui_settings_chat_font_size_changed", { + multiplier: 1.5, + }) + }) + + it("does not fire telemetry on blur with NaN input", () => { + const { getByTestId } = render() + const input = getByTestId("chat-font-size-input") + + fireEvent.change(input, { target: { value: "abc" } }) + fireEvent.blur(input) + + expect(mockCapture).not.toHaveBeenCalledWith("ui_settings_chat_font_size_changed", expect.anything()) + }) + + it("resets font size to 1 when reset button is clicked", () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render( + , + ) + const input = getByTestId("chat-font-size-input") as HTMLInputElement + const resetButton = getByTestId("chat-font-size-reset-button") + + fireEvent.click(resetButton) + + expect(setCachedStateField).toHaveBeenCalledWith("chatFontSizeMultiplier", 1) + expect(input.value).toBe("1") + }) + + it("fires reset telemetry when reset button is clicked", () => { + const { getByTestId } = render() + const resetButton = getByTestId("chat-font-size-reset-button") + + fireEvent.click(resetButton) + + expect(mockCapture).toHaveBeenCalledWith("ui_settings_chat_font_size_reset", {}) + }) + + it("syncs local state when chatFontSizeMultiplier prop changes", () => { + const { getByTestId, rerender } = render() + const input = getByTestId("chat-font-size-input") as HTMLInputElement + expect(input.value).toBe("1") + + rerender() + expect(input.value).toBe("1.8") + }) + }) }) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ce7a607d9a8..66ea7afa46b 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -126,6 +126,8 @@ export interface ExtensionStateContextType extends ExtensionState { setReasoningBlockCollapsed: (value: boolean) => void enterBehavior?: "send" | "newline" setEnterBehavior: (value: "send" | "newline") => void + chatFontSizeMultiplier?: number + setChatFontSizeMultiplier: (value: number) => void autoCondenseContext: boolean setAutoCondenseContext: (value: boolean) => void autoCondenseContextPercent: number @@ -236,6 +238,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode historyPreviewCollapsed: false, // Initialize the new state (default to expanded) reasoningBlockCollapsed: true, // Default to collapsed enterBehavior: "send", // Default: Enter sends, Shift+Enter creates newline + chatFontSizeMultiplier: 1, // Default: 1x multiplier (no scaling) cloudUserInfo: null, cloudIsAuthenticated: false, cloudOrganizations: [], @@ -586,6 +589,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setState((prevState) => ({ ...prevState, reasoningBlockCollapsed: value })), enterBehavior: state.enterBehavior ?? "send", setEnterBehavior: (value) => setState((prevState) => ({ ...prevState, enterBehavior: value })), + chatFontSizeMultiplier: state.chatFontSizeMultiplier ?? 1, + setChatFontSizeMultiplier: (value) => setState((prevState) => ({ ...prevState, chatFontSizeMultiplier: value })), setHasOpenedModeSelector: (value) => setState((prevState) => ({ ...prevState, hasOpenedModeSelector: value })), setAutoCondenseContext: (value) => setState((prevState) => ({ ...prevState, autoCondenseContext: value })), setAutoCondenseContextPercent: (value) => diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index af825fafe80..c2467351bf3 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -163,6 +163,11 @@ "requireCtrlEnterToSend": { "label": "Require {{primaryMod}}+Enter to send messages", "description": "When enabled, you must press {{primaryMod}}+Enter to send messages instead of just Enter" + }, + "chatFontSizeMultiplier": { + "label": "Chat Font Size Multiplier", + "description": "Adjust the font size in the chat view. Values range from 0.5 (smaller) to 2 (larger). Default is 1.", + "reset": "Reset" } }, "prompts": {