Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c970745
feat(ui): add interface, terminal, editor font size and spacing settings
KarthickMahalingam Oct 28, 2026
5db31a8
test(visual): add appearance preview baselines and refresh UI settings
KarthickMahalingam Oct 28, 2026
dc8880c
fix(ui): keep a stepper value that equals the shown default; tighten …
KarthickMahalingam Oct 28, 2026
27982f9
fix(ui): make the settings save all-or-nothing and test appearance pe…
KarthickMahalingam Oct 28, 2026
18fddb7
chore(knip): treat webview test helpers under __tests__ as non-produc…
KarthickMahalingam Oct 28, 2026
41894ae
fix(ui): always report the save result, bind it to the submitted prof…
KarthickMahalingam Oct 28, 2026
adfc6ca
fix(ui): restore command lists from configuration on rollback, unique…
KarthickMahalingam Oct 28, 2026
7200ce1
refactor(ui): drop a redundant pending-save guard (the Save button is…
KarthickMahalingam Oct 28, 2026
5b90dc8
fix(ui): serialize settings saves across providers; keep the save-res…
KarthickMahalingam Oct 28, 2026
8b6a0a9
fix(ui): bound the DCG install and MCP toggle so a stuck stage cannot…
KarthickMahalingam Oct 28, 2026
f074050
test: avoid explicit any in the MCP timeout tests
KarthickMahalingam Oct 28, 2026
ef33c05
fix(ui): restore the running terminal values when a failed save is ro…
KarthickMahalingam Oct 28, 2026
3fe309b
Merge branch 'main' into feat/font-size-spacing
KarthickMahalingam Oct 29, 2026
8c30d02
fix(ui): serialize MCP changes so a rollback lands after a timed-out …
KarthickMahalingam Oct 29, 2026
0b4e2bc
refactor(ui): send the API configuration, telemetry and debug immedia…
KarthickMahalingam Oct 29, 2026
c496c64
test: cover a rejecting MCP hub change
KarthickMahalingam Oct 29, 2026
76941c2
fix(ui): put the running language and speech values back when a faile…
KarthickMahalingam Oct 29, 2026
681a721
fix(ui): roll back to the running value, not only when the stored one…
KarthickMahalingam Oct 29, 2026
283fe73
refactor(ui): apply the MCP setting to the hub after the save, not in…
KarthickMahalingam Oct 29, 2026
7574094
fix(ui): apply the MCP setting on every save that includes it; coales…
KarthickMahalingam Oct 29, 2026
394bb68
refactor(ui): narrow the save transaction to stored values; drop the …
KarthickMahalingam Oct 29, 2026
ae117d1
fix(ui): roll back only the appearance values, journal before the wri…
KarthickMahalingam Oct 29, 2026
ee7205a
test(ui): cover the reset telemetry value, and every consumer of the …
KarthickMahalingam Oct 29, 2026
fd9d98f
fix(ui): unwind overlapping failed saves in any order, keep imports, …
KarthickMahalingam Oct 29, 2026
dbf3d1c
test(ui): add screenshot and CodeBlock assertions to the appearance c…
KarthickMahalingam Oct 29, 2026
47ceffe
test(ui): add appearance consumers screenshot baselines
KarthickMahalingam Oct 29, 2026
d9688f4
fix(ui): validate the appearance values in the extension host before …
KarthickMahalingam Oct 29, 2026
76edd8c
test(ui): render ChatRow in the appearance consumers test
KarthickMahalingam Oct 29, 2026
539231b
test(ui): update the appearance consumers default-state baselines (wi…
KarthickMahalingam Oct 29, 2026
5dbcef4
test(ui): update the appearance consumers configured-state baselines …
KarthickMahalingam Oct 29, 2026
1969f4a
refactor(ui): only a save that changes an appearance value waits for …
KarthickMahalingam Oct 29, 2026
7af492f
fix(ui): order writes of one appearance value down to durable storage…
KarthickMahalingam Oct 29, 2026
215b098
fix(ui): save request ids unique across webview reloads
KarthickMahalingam Oct 29, 2026
a5706e7
fix(ui): read what a write replaces when it starts, after the writes …
KarthickMahalingam Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"src/**/*.{ts,tsx}!",
"../src/shared/*.ts!",
"!src/utils/test-utils.tsx!",
"!src/**/__tests__/**!",
"!src/vite-plugins/**!",
"!src/**/*.visual.tsx!",
"!src/**/*.visual.{fixture,i18n}.{ts,tsx}!"
Expand Down
38 changes: 38 additions & 0 deletions packages/types/src/__tests__/global-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,41 @@ describe("collapseCommandOutputByDefault global setting", () => {
expect(globalSettingsSchema.safeParse({ collapseCommandOutputByDefault: "true" }).success).toBe(false)
})
})

describe("appearance global settings", () => {
const bounds = {
interfaceFontScale: { min: 50, max: 200 },
terminalFontSize: { min: 8, max: 32 },
editorFontSize: { min: 8, max: 32 },
spacingScale: { min: 50, max: 200 },
} as const
const keys = Object.keys(bounds) as Array<keyof typeof bounds>

it.each(keys)("%s is a persisted global setting", (key) => {
expect(GLOBAL_SETTINGS_KEYS).toContain(key)
})

it.each(keys)("%s accepts the whole range, including both bounds", (key) => {
const { min, max } = bounds[key]
for (const value of [min, Math.round((min + max) / 2), max]) {
expect(globalSettingsSchema.parse({ [key]: value })).toEqual({ [key]: value })
}
})

it.each(keys)("%s accepts null (a saved reset) and an unset value", (key) => {
expect(globalSettingsSchema.parse({ [key]: null })).toEqual({ [key]: null })
expect(globalSettingsSchema.parse({})).not.toHaveProperty(key)
})

it.each(keys)("%s rejects out-of-range, fractional and non-numeric values", (key) => {
const { min, max } = bounds[key]
for (const value of [min - 1, max + 1, 0, -1, min + 0.5, Number.NaN, "100"]) {
expect(globalSettingsSchema.safeParse({ [key]: value }).success).toBe(false)
}
})

it("keeps all four values together", () => {
const all = { interfaceFontScale: 120, terminalFontSize: 14, editorFontSize: 15, spacingScale: 80 }
expect(globalSettingsSchema.parse(all)).toEqual(all)
})
})
16 changes: 16 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,22 @@ export const globalSettingsSchema = z.object({
* When unset (or `null`), the webview inherits VS Code's `--vscode-font-size`.
*/
chatFontSize: z.number().int().min(8).max(32).nullish(),
/**
* Scale (percent) applied to Zoo Code's interface text. `null`/unset = 100.
*/
interfaceFontScale: z.number().int().min(50).max(200).nullish(),
/**
* Font size (px) for command/terminal output shown in chat. `null`/unset inherits.
*/
terminalFontSize: z.number().int().min(8).max(32).nullish(),
/**
* Font size (px) for code blocks and diffs shown in chat. `null`/unset inherits.
*/
editorFontSize: z.number().int().min(8).max(32).nullish(),
/**
* Scale (percent) applied to chat row padding. `null`/unset = 100.
*/
spacingScale: z.number().int().min(50).max(200).nullish(),
/**
* Controls the keyboard behavior for sending messages in the chat input.
* - "send": Enter sends message, Shift+Enter creates newline (default)
Expand Down
5 changes: 5 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface ExtensionMessage {
| typeof VsCodeLmModelsMessageType.vsCodeLmModels
| typeof VsCodeLmModelsMessageType.githubCopilotModels
| typeof VsCodeLmModelsMessageType.githubCopilotSignInResult
| "settingsSaveResult"
| "vsCodeLmApiAvailable"
| "updatePrompt"
| "systemPrompt"
Expand Down Expand Up @@ -339,6 +340,10 @@ export type ExtensionState = Pick<
| "includeTaskHistoryInEnhance"
| "reasoningBlockCollapsed"
| "chatFontSize"
| "interfaceFontScale"
| "terminalFontSize"
| "editorFontSize"
| "spacingScale"
| "enterBehavior"
| "includeCurrentTime"
| "includeCurrentCost"
Expand Down
12 changes: 12 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2860,6 +2860,10 @@ export class ClineProvider
historyPreviewCollapsed,
reasoningBlockCollapsed,
chatFontSize,
interfaceFontScale,
terminalFontSize,
editorFontSize,
spacingScale,
enterBehavior,
cloudUserInfo,
cloudIsAuthenticated,
Expand Down Expand Up @@ -3041,6 +3045,10 @@ export class ClineProvider
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
chatFontSize,
interfaceFontScale,
terminalFontSize,
editorFontSize,
spacingScale,
enterBehavior: enterBehavior ?? "send",
cloudUserInfo,
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
Expand Down Expand Up @@ -3274,6 +3282,10 @@ export class ClineProvider
historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,
reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true,
chatFontSize: stateValues.chatFontSize,
interfaceFontScale: stateValues.interfaceFontScale,
terminalFontSize: stateValues.terminalFontSize,
editorFontSize: stateValues.editorFontSize,
spacingScale: stateValues.spacingScale,
enterBehavior: stateValues.enterBehavior ?? "send",
cloudUserInfo,
cloudIsAuthenticated,
Expand Down
82 changes: 82 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2119,6 +2119,88 @@ describe("ClineProvider", () => {
expect(state.allowedReadFiles).toEqual([])
})

describe("appearance settings", () => {
const keys = ["interfaceFontScale", "terminalFontSize", "editorFontSize", "spacingScale"] as const
const values = { interfaceFontScale: 120, terminalFontSize: 14, editorFontSize: 15, spacingScale: 80 }

/** Saves through the real `updateSettings` handler, as the settings view does. */
const save = async (updatedSettings: Record<string, unknown>) => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = vi.mocked(mockWebviewView.webview.onDidReceiveMessage).mock.calls[0][0]
await messageHandler({ type: "updateSettings", updatedSettings })
}

test.each(keys)("%s is unset when it was never saved, in both states", async (key) => {
await provider.resolveWebviewView(mockWebviewView)

expect((await provider.getState())[key]).toBeUndefined()
expect((await provider.getStateToPostToWebview())[key]).toBeUndefined()
})

test.each(keys)("%s that was stored is returned by both states", async (key) => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setValue(key, values[key])

expect((await provider.getState())[key]).toBe(values[key])
expect((await provider.getStateToPostToWebview())[key]).toBe(values[key])
})

test("all four are returned together, each with its own value", async () => {
await provider.resolveWebviewView(mockWebviewView)
for (const key of keys) await provider.contextProxy.setValue(key, values[key])

expect(await provider.getState()).toMatchObject(values)
expect(await provider.getStateToPostToWebview()).toMatchObject(values)
})

test.each(keys)("a reset of %s (null) is kept, not replaced by a default", async (key) => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setValue(key, values[key])
await provider.contextProxy.setValue(key, null)

expect((await provider.getState())[key]).toBeNull()
expect((await provider.getStateToPostToWebview())[key]).toBeNull()
})

test("saving through updateSettings stores the values and tells the open webview", async () => {
mockPostMessage.mockClear()

await save(values)

for (const key of keys) expect(mockContext.globalState.update).toHaveBeenCalledWith(key, values[key])
expect(await provider.getStateToPostToWebview()).toMatchObject(values)
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: "state", state: expect.objectContaining(values) }),
)
})

test("saving a reset stores null and tells the open webview", async () => {
await save(values)
mockPostMessage.mockClear()

await save({ interfaceFontScale: null, terminalFontSize: null, editorFontSize: null, spacingScale: null })

expect(await provider.getStateToPostToWebview()).toMatchObject({
interfaceFontScale: null,
terminalFontSize: null,
editorFontSize: null,
spacingScale: null,
})
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "state",
state: expect.objectContaining({ interfaceFontScale: null, spacingScale: null }),
}),
)
})

test("a value the webview should not be able to store is refused, and nothing changes", async () => {
await save({ interfaceFontScale: 999 })

expect((await provider.getState()).interfaceFontScale).toBeUndefined()
})
})

test("getState returns the saved allowed write files", async () => {
await provider.contextProxy.setValue("allowedWriteFiles", ["notes.md"])

Expand Down
Loading
Loading