Skip to content

Commit 911199b

Browse files
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
1 parent b761a0a commit 911199b

27 files changed

Lines changed: 273 additions & 4 deletions

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
@@ -293,6 +293,7 @@ export type ExtensionState = Pick<
293293
| "openRouterImageGenerationSelectedModel"
294294
| "includeTaskHistoryInEnhance"
295295
| "reasoningBlockCollapsed"
296+
| "chatFontSize"
296297
| "enterBehavior"
297298
| "includeCurrentTime"
298299
| "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/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: 47 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,14 @@ export const UISettings = ({
4855
})
4956
}
5057

58+
const handleChatFontSizeChange = (value: number) => {
59+
setCachedStateField("chatFontSize", value)
60+
}
61+
62+
const handleChatFontSizeReset = () => {
63+
setCachedStateField("chatFontSize", undefined)
64+
}
65+
5166
return (
5267
<div {...props}>
5368
<SectionHeader>{t("settings:sections.ui")}</SectionHeader>
@@ -91,6 +106,38 @@ export const UISettings = ({
91106
</div>
92107
</div>
93108
</SearchableSetting>
109+
110+
{/* Chat Font Size Setting */}
111+
<SearchableSetting
112+
settingId="ui-chat-font-size"
113+
section="ui"
114+
label={t("settings:ui.chatFontSize.label")}>
115+
<div className="flex flex-col gap-1">
116+
<label className="block font-medium mb-1">{t("settings:ui.chatFontSize.label")}</label>
117+
<div className="flex items-center gap-2">
118+
<Slider
119+
min={CHAT_FONT_SIZE_MIN}
120+
max={CHAT_FONT_SIZE_MAX}
121+
step={1}
122+
value={[chatFontSize ?? CHAT_FONT_SIZE_DEFAULT]}
123+
onValueChange={([value]) => handleChatFontSizeChange(value)}
124+
data-testid="chat-font-size-slider"
125+
/>
126+
<span className="w-12 text-right">{chatFontSize ?? CHAT_FONT_SIZE_DEFAULT}px</span>
127+
<Button
128+
variant="secondary"
129+
size="sm"
130+
disabled={chatFontSize === undefined}
131+
onClick={handleChatFontSizeReset}
132+
data-testid="chat-font-size-reset">
133+
{t("settings:ui.chatFontSize.reset")}
134+
</Button>
135+
</div>
136+
<div className="text-vscode-descriptionForeground text-sm mt-1">
137+
{t("settings:ui.chatFontSize.description")}
138+
</div>
139+
</div>
140+
</SearchableSetting>
94141
</div>
95142
</Section>
96143
</div>

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,50 @@ describe("UISettings", () => {
4141
rerender(<UISettings {...defaultProps} reasoningBlockCollapsed={true} />)
4242
expect(checkbox.checked).toBe(true)
4343
})
44+
45+
describe("chat font size", () => {
46+
it("shows the default font size when unset (init)", () => {
47+
const { getByText, getByTestId } = render(<UISettings {...defaultProps} chatFontSize={undefined} />)
48+
expect(getByTestId("chat-font-size-slider")).toBeTruthy()
49+
// Default falls back to VS Code-equivalent default value.
50+
expect(getByText("13px")).toBeTruthy()
51+
})
52+
53+
it("shows the configured font size when set", () => {
54+
const { getByText } = render(<UISettings {...defaultProps} chatFontSize={20} />)
55+
expect(getByText("20px")).toBeTruthy()
56+
})
57+
58+
it("persists a user-edited font size via setCachedStateField", () => {
59+
const setCachedStateField = vi.fn()
60+
const { getByTestId } = render(
61+
<UISettings {...defaultProps} chatFontSize={14} setCachedStateField={setCachedStateField} />,
62+
)
63+
64+
const slider = getByTestId("chat-font-size-slider").querySelector('[role="slider"]') as HTMLElement
65+
slider.focus()
66+
fireEvent.keyDown(slider, { key: "ArrowRight" })
67+
68+
expect(setCachedStateField).toHaveBeenCalledWith("chatFontSize", 15)
69+
})
70+
71+
it("disables reset when unset and clears the value on reset", () => {
72+
const setCachedStateField = vi.fn()
73+
const { getByTestId, rerender } = render(
74+
<UISettings {...defaultProps} chatFontSize={undefined} setCachedStateField={setCachedStateField} />,
75+
)
76+
77+
const resetUnset = getByTestId("chat-font-size-reset") as HTMLButtonElement
78+
expect(resetUnset.disabled).toBe(true)
79+
80+
rerender(
81+
<UISettings {...defaultProps} chatFontSize={18} setCachedStateField={setCachedStateField} />,
82+
)
83+
const resetSet = getByTestId("chat-font-size-reset") as HTMLButtonElement
84+
expect(resetSet.disabled).toBe(false)
85+
86+
fireEvent.click(resetSet)
87+
expect(setCachedStateField).toHaveBeenCalledWith("chatFontSize", undefined)
88+
})
89+
})
4490
})

webview-ui/src/context/ExtensionStateContext.tsx

Lines changed: 14 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,6 +475,17 @@ 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,
478491
reasoningBlockCollapsed: state.reasoningBlockCollapsed ?? true,
@@ -572,6 +585,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
572585
setState((prevState) => ({ ...prevState, historyPreviewCollapsed: value })),
573586
setReasoningBlockCollapsed: (value) =>
574587
setState((prevState) => ({ ...prevState, reasoningBlockCollapsed: value })),
588+
setChatFontSize: (value) => setState((prevState) => ({ ...prevState, chatFontSize: value })),
575589
enterBehavior: state.enterBehavior ?? "send",
576590
setEnterBehavior: (value) => setState((prevState) => ({ ...prevState, enterBehavior: value })),
577591
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>

webview-ui/src/i18n/locales/ca/settings.json

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/de/settings.json

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)