Skip to content

Commit 7de1361

Browse files
feat(settings): add configurable chat font size (#157) (#276)
* feat(settings): add configurable chat font size (#157) The Zoo Code chat font could not be sized independently of VS Code's UI zoom. Adds an optional chatFontSize setting (px, 8-32) surfaced as a slider with a 'Use VS Code default' reset in the UI settings section. When unset the appearance is unchanged: the --zoo-chat-font-size CSS var defaults to --vscode-font-size, and the webview text scale derives from it. When set, the value is applied to the document root and persisted via the generic updateSettings path (nullish + null-on-reset, matching allowedMaxRequests). Includes init-vs-user-edit webview tests and full i18n for all 18 locales. Closes #157 * fix(webview): normalize nullish chatFontSize in context value (#157) * fix(settings): scope chat font size to the chat markdown surface (#157) * feat(settings): emit telemetry for chat font size changes (#157) The chat font size change/reset handlers now emit telemetry like the other UI settings handlers in this file (ui_settings_chat_font_size_changed with the value, and ui_settings_chat_font_size_reset). Covered by UISettings spec. --------- Co-authored-by: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com>
1 parent 8b21009 commit 7de1361

29 files changed

Lines changed: 319 additions & 1 deletion

packages/types/src/global-settings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,11 @@ export const globalSettingsSchema = z.object({
201201
includeTaskHistoryInEnhance: z.boolean().optional(),
202202
historyPreviewCollapsed: z.boolean().optional(),
203203
reasoningBlockCollapsed: z.boolean().optional(),
204+
/**
205+
* Font size (in pixels) for the Zoo Code chat/webview UI.
206+
* When unset (or `null`), the webview inherits VS Code's `--vscode-font-size`.
207+
*/
208+
chatFontSize: z.number().int().min(8).max(32).nullish(),
204209
/**
205210
* Controls the keyboard behavior for sending messages in the chat input.
206211
* - "send": Enter sends message, Shift+Enter creates newline (default)

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ export type ExtensionState = Pick<
292292
| "openRouterImageGenerationSelectedModel"
293293
| "includeTaskHistoryInEnhance"
294294
| "reasoningBlockCollapsed"
295+
| "chatFontSize"
295296
| "enterBehavior"
296297
| "includeCurrentTime"
297298
| "includeCurrentCost"

src/core/webview/ClineProvider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2071,6 +2071,7 @@ export class ClineProvider
20712071
maxTotalImageSize,
20722072
historyPreviewCollapsed,
20732073
reasoningBlockCollapsed,
2074+
chatFontSize,
20742075
enterBehavior,
20752076
cloudUserInfo,
20762077
cloudIsAuthenticated,
@@ -2229,6 +2230,7 @@ export class ClineProvider
22292230
settingsImportedAt: this.settingsImportedAt,
22302231
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
22312232
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
2233+
chatFontSize,
22322234
enterBehavior: enterBehavior ?? "send",
22332235
cloudUserInfo,
22342236
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
@@ -2428,6 +2430,7 @@ export class ClineProvider
24282430
maxTotalImageSize: stateValues.maxTotalImageSize ?? 20,
24292431
historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,
24302432
reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true,
2433+
chatFontSize: stateValues.chatFontSize,
24312434
enterBehavior: stateValues.enterBehavior ?? "send",
24322435
cloudUserInfo,
24332436
cloudIsAuthenticated,

webview-ui/src/components/common/MarkdownBlock.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ const StyledMarkdown = styled.div`
102102
"Helvetica Neue",
103103
sans-serif;
104104
105-
font-size: var(--vscode-font-size, 13px);
105+
font-size: var(--zoo-chat-font-size, var(--vscode-font-size, 13px));
106106
107107
p,
108108
li,

webview-ui/src/components/settings/SettingsView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
199199
openRouterImageApiKey,
200200
openRouterImageGenerationSelectedModel,
201201
reasoningBlockCollapsed,
202+
chatFontSize,
202203
enterBehavior,
203204
includeCurrentTime,
204205
includeCurrentCost,
@@ -412,6 +413,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
412413
followupAutoApproveTimeoutMs,
413414
includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true,
414415
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
416+
chatFontSize: chatFontSize ?? null,
415417
enterBehavior: enterBehavior ?? "send",
416418
includeCurrentTime: includeCurrentTime ?? true,
417419
includeCurrentCost: includeCurrentCost ?? true,
@@ -892,6 +894,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
892894
<UISettings
893895
reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
894896
enterBehavior={enterBehavior ?? "send"}
897+
chatFontSize={chatFontSize ?? undefined}
895898
setCachedStateField={setCachedStateField}
896899
/>
897900
)}

webview-ui/src/components/settings/UISettings.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,24 @@ import { SetCachedStateField } from "./types"
77
import { SectionHeader } from "./SectionHeader"
88
import { Section } from "./Section"
99
import { SearchableSetting } from "./SearchableSetting"
10+
import { Slider, Button } from "../ui"
1011
import { ExtensionStateContextType } from "@/context/ExtensionStateContext"
1112

13+
export const CHAT_FONT_SIZE_MIN = 8
14+
export const CHAT_FONT_SIZE_MAX = 32
15+
export const CHAT_FONT_SIZE_DEFAULT = 13
16+
1217
interface UISettingsProps extends HTMLAttributes<HTMLDivElement> {
1318
reasoningBlockCollapsed: boolean
1419
enterBehavior: "send" | "newline"
20+
chatFontSize?: number
1521
setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType>
1622
}
1723

1824
export const UISettings = ({
1925
reasoningBlockCollapsed,
2026
enterBehavior,
27+
chatFontSize,
2128
setCachedStateField,
2229
...props
2330
}: UISettingsProps) => {
@@ -48,6 +55,22 @@ export const UISettings = ({
4855
})
4956
}
5057

58+
const handleChatFontSizeChange = (value: number) => {
59+
setCachedStateField("chatFontSize", value)
60+
61+
// Track telemetry event
62+
telemetryClient.capture("ui_settings_chat_font_size_changed", {
63+
value,
64+
})
65+
}
66+
67+
const handleChatFontSizeReset = () => {
68+
setCachedStateField("chatFontSize", undefined)
69+
70+
// Track telemetry event
71+
telemetryClient.capture("ui_settings_chat_font_size_reset")
72+
}
73+
5174
return (
5275
<div {...props}>
5376
<SectionHeader>{t("settings:sections.ui")}</SectionHeader>
@@ -91,6 +114,38 @@ export const UISettings = ({
91114
</div>
92115
</div>
93116
</SearchableSetting>
117+
118+
{/* Chat Font Size Setting */}
119+
<SearchableSetting
120+
settingId="ui-chat-font-size"
121+
section="ui"
122+
label={t("settings:ui.chatFontSize.label")}>
123+
<div className="flex flex-col gap-1">
124+
<label className="block font-medium mb-1">{t("settings:ui.chatFontSize.label")}</label>
125+
<div className="flex items-center gap-2">
126+
<Slider
127+
min={CHAT_FONT_SIZE_MIN}
128+
max={CHAT_FONT_SIZE_MAX}
129+
step={1}
130+
value={[chatFontSize ?? CHAT_FONT_SIZE_DEFAULT]}
131+
onValueChange={([value]) => handleChatFontSizeChange(value)}
132+
data-testid="chat-font-size-slider"
133+
/>
134+
<span className="w-12 text-right">{chatFontSize ?? CHAT_FONT_SIZE_DEFAULT}px</span>
135+
<Button
136+
variant="secondary"
137+
size="sm"
138+
disabled={chatFontSize === undefined}
139+
onClick={handleChatFontSizeReset}
140+
data-testid="chat-font-size-reset">
141+
{t("settings:ui.chatFontSize.reset")}
142+
</Button>
143+
</div>
144+
<div className="text-vscode-descriptionForeground text-sm mt-1">
145+
{t("settings:ui.chatFontSize.description")}
146+
</div>
147+
</div>
148+
</SearchableSetting>
94149
</div>
95150
</Section>
96151
</div>

webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,37 @@ describe("SettingsView - Sound Settings", () => {
412412
)
413413
})
414414

415+
it("saves the selected chat font size and persists null on reset", () => {
416+
const { activateTab, getSettingsContent } = renderSettingsView()
417+
418+
activateTab("ui")
419+
420+
const content = getSettingsContent()
421+
const slider = within(content).getByTestId("chat-font-size-slider")
422+
423+
// Pick a size, then Save — the boundary should forward it to the host.
424+
fireEvent.change(slider, { target: { value: "18" } })
425+
fireEvent.click(screen.getByTestId("save-button"))
426+
427+
expect(vscode.postMessage).toHaveBeenCalledWith(
428+
expect.objectContaining({
429+
type: "updateSettings",
430+
updatedSettings: expect.objectContaining({ chatFontSize: 18 }),
431+
}),
432+
)
433+
434+
// Reset clears the override; it is persisted as null (not undefined).
435+
fireEvent.click(within(getSettingsContent()).getByTestId("chat-font-size-reset"))
436+
fireEvent.click(screen.getByTestId("save-button"))
437+
438+
expect(vscode.postMessage).toHaveBeenCalledWith(
439+
expect.objectContaining({
440+
type: "updateSettings",
441+
updatedSettings: expect.objectContaining({ chatFontSize: null }),
442+
}),
443+
)
444+
})
445+
415446
it("shows tts slider when sound is enabled", () => {
416447
// Render once and get the activateTab helper
417448
const { activateTab, getSettingsContent } = renderSettingsView()

webview-ui/src/components/settings/__tests__/UISettings.spec.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { render, fireEvent, waitFor } from "@testing-library/react"
22
import { describe, it, expect, vi } from "vitest"
33
import { UISettings } from "../UISettings"
4+
import { telemetryClient } from "@/utils/TelemetryClient"
5+
6+
vi.mock("@/utils/TelemetryClient", () => ({
7+
telemetryClient: { capture: vi.fn() },
8+
}))
49

510
describe("UISettings", () => {
611
const defaultProps = {
@@ -41,4 +46,52 @@ describe("UISettings", () => {
4146
rerender(<UISettings {...defaultProps} reasoningBlockCollapsed={true} />)
4247
expect(checkbox.checked).toBe(true)
4348
})
49+
50+
describe("chat font size", () => {
51+
it("shows the default font size when unset (init)", () => {
52+
const { getByText, getByTestId } = render(<UISettings {...defaultProps} chatFontSize={undefined} />)
53+
expect(getByTestId("chat-font-size-slider")).toBeTruthy()
54+
// Default falls back to VS Code-equivalent default value.
55+
expect(getByText("13px")).toBeTruthy()
56+
})
57+
58+
it("shows the configured font size when set", () => {
59+
const { getByText } = render(<UISettings {...defaultProps} chatFontSize={20} />)
60+
expect(getByText("20px")).toBeTruthy()
61+
})
62+
63+
it("persists a user-edited font size via setCachedStateField", () => {
64+
const setCachedStateField = vi.fn()
65+
const { getByTestId } = render(
66+
<UISettings {...defaultProps} chatFontSize={14} setCachedStateField={setCachedStateField} />,
67+
)
68+
69+
const slider = getByTestId("chat-font-size-slider").querySelector('[role="slider"]') as HTMLElement
70+
slider.focus()
71+
fireEvent.keyDown(slider, { key: "ArrowRight" })
72+
73+
expect(setCachedStateField).toHaveBeenCalledWith("chatFontSize", 15)
74+
expect(telemetryClient.capture).toHaveBeenCalledWith("ui_settings_chat_font_size_changed", { value: 15 })
75+
})
76+
77+
it("disables reset when unset and clears the value on reset", () => {
78+
const setCachedStateField = vi.fn()
79+
const { getByTestId, rerender } = render(
80+
<UISettings {...defaultProps} chatFontSize={undefined} setCachedStateField={setCachedStateField} />,
81+
)
82+
83+
const resetUnset = getByTestId("chat-font-size-reset") as HTMLButtonElement
84+
expect(resetUnset.disabled).toBe(true)
85+
86+
rerender(
87+
<UISettings {...defaultProps} chatFontSize={18} setCachedStateField={setCachedStateField} />,
88+
)
89+
const resetSet = getByTestId("chat-font-size-reset") as HTMLButtonElement
90+
expect(resetSet.disabled).toBe(false)
91+
92+
fireEvent.click(resetSet)
93+
expect(setCachedStateField).toHaveBeenCalledWith("chatFontSize", undefined)
94+
expect(telemetryClient.capture).toHaveBeenCalledWith("ui_settings_chat_font_size_reset")
95+
})
96+
})
4497
})

webview-ui/src/context/ExtensionStateContext.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ export interface ExtensionStateContextType extends ExtensionState {
124124
togglePinnedApiConfig: (configName: string) => void
125125
setHistoryPreviewCollapsed: (value: boolean) => void
126126
setReasoningBlockCollapsed: (value: boolean) => void
127+
chatFontSize?: number
128+
setChatFontSize: (value: number | undefined) => void
127129
enterBehavior?: "send" | "newline"
128130
setEnterBehavior: (value: "send" | "newline") => void
129131
autoCondenseContext: boolean
@@ -473,8 +475,22 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
473475
vscode.postMessage({ type: "webviewDidLaunch" })
474476
}, [])
475477

478+
// Apply the configurable chat font size as a CSS variable. When unset, the
479+
// override is removed so the UI falls back to VS Code's `--vscode-font-size`.
480+
useEffect(() => {
481+
const root = document.documentElement
482+
if (typeof state.chatFontSize === "number") {
483+
root.style.setProperty("--zoo-chat-font-size", `${state.chatFontSize}px`)
484+
} else {
485+
root.style.removeProperty("--zoo-chat-font-size")
486+
}
487+
}, [state.chatFontSize])
488+
476489
const contextValue: ExtensionStateContextType = {
477490
...state,
491+
// `chatFontSize` is persisted as nullish (null on reset); normalize null to
492+
// undefined so it matches the context type and means "use VS Code default".
493+
chatFontSize: state.chatFontSize ?? undefined,
478494
reasoningBlockCollapsed: state.reasoningBlockCollapsed ?? true,
479495
didHydrateState,
480496
showWelcome,
@@ -572,6 +588,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
572588
setState((prevState) => ({ ...prevState, historyPreviewCollapsed: value })),
573589
setReasoningBlockCollapsed: (value) =>
574590
setState((prevState) => ({ ...prevState, reasoningBlockCollapsed: value })),
591+
setChatFontSize: (value) => setState((prevState) => ({ ...prevState, chatFontSize: value })),
575592
enterBehavior: state.enterBehavior ?? "send",
576593
setEnterBehavior: (value) => setState((prevState) => ({ ...prevState, enterBehavior: value })),
577594
setHasOpenedModeSelector: (value) => setState((prevState) => ({ ...prevState, hasOpenedModeSelector: value })),

webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@ const TestComponent = () => {
2929
)
3030
}
3131

32+
const ChatFontSizeTestComponent = () => {
33+
const { chatFontSize, setChatFontSize } = useExtensionState()
34+
35+
return (
36+
<div>
37+
<div data-testid="chat-font-size">{JSON.stringify(chatFontSize ?? null)}</div>
38+
<button data-testid="set-font-size-button" onClick={() => setChatFontSize(20)}>
39+
Set Font Size
40+
</button>
41+
<button data-testid="reset-font-size-button" onClick={() => setChatFontSize(undefined)}>
42+
Reset Font Size
43+
</button>
44+
</div>
45+
)
46+
}
47+
3248
const ApiConfigTestComponent = () => {
3349
const { apiConfiguration, setApiConfiguration } = useExtensionState()
3450

@@ -92,6 +108,43 @@ describe("ExtensionStateContext", () => {
92108
expect(JSON.parse(screen.getByTestId("show-rooignored-files").textContent!)).toBe(false)
93109
})
94110

111+
it("does not set the chat font-size CSS variable when unset (init)", () => {
112+
document.documentElement.style.removeProperty("--zoo-chat-font-size")
113+
114+
render(
115+
<ExtensionStateContextProvider>
116+
<ChatFontSizeTestComponent />
117+
</ExtensionStateContextProvider>,
118+
)
119+
120+
expect(JSON.parse(screen.getByTestId("chat-font-size").textContent!)).toBe(null)
121+
expect(document.documentElement.style.getPropertyValue("--zoo-chat-font-size")).toBe("")
122+
})
123+
124+
it("applies the chat font-size CSS variable when set, and clears it on reset", () => {
125+
document.documentElement.style.removeProperty("--zoo-chat-font-size")
126+
127+
render(
128+
<ExtensionStateContextProvider>
129+
<ChatFontSizeTestComponent />
130+
</ExtensionStateContextProvider>,
131+
)
132+
133+
act(() => {
134+
screen.getByTestId("set-font-size-button").click()
135+
})
136+
137+
expect(JSON.parse(screen.getByTestId("chat-font-size").textContent!)).toBe(20)
138+
expect(document.documentElement.style.getPropertyValue("--zoo-chat-font-size")).toBe("20px")
139+
140+
act(() => {
141+
screen.getByTestId("reset-font-size-button").click()
142+
})
143+
144+
expect(JSON.parse(screen.getByTestId("chat-font-size").textContent!)).toBe(null)
145+
expect(document.documentElement.style.getPropertyValue("--zoo-chat-font-size")).toBe("")
146+
})
147+
95148
it("updates allowedCommands through setAllowedCommands", () => {
96149
render(
97150
<ExtensionStateContextProvider>

0 commit comments

Comments
 (0)