Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 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
7783aae
refactor(ui): move the appearance write chain into its own module, wi…
KarthickMahalingam Oct 29, 2026
ea620f4
Merge branch 'main' into feat/font-size-spacing
KarthickMahalingam Oct 29, 2026
65951f5
Merge branch 'main' into feat/font-size-spacing
KarthickMahalingam Oct 30, 2026
afd8df8
Merge branch 'main' into feat/font-size-spacing
KarthickMahalingam Oct 30, 2026
49ae2f8
Merge branch 'main' into feat/font-size-spacing
KarthickMahalingam Oct 31, 2026
d3f7937
fix(ui): a finished write is the base of a later queued one; an untou…
KarthickMahalingam Oct 31, 2026
08a8c09
Merge branch 'main' into feat/font-size-spacing
KarthickMahalingam Oct 31, 2026
434d7b1
fix(ui): import appearance values through the write chain; link the s…
KarthickMahalingam Oct 31, 2026
f8cbb2b
Merge branch 'main' into feat/font-size-spacing
KarthickMahalingam Oct 31, 2026
cee9023
fix(ui): undo an import write that failed instead of finishing it
KarthickMahalingam Oct 31, 2026
f82060b
test: other settings failing to store in an import
KarthickMahalingam Oct 31, 2026
aaf5db7
Merge branch 'main' into feat/font-size-spacing
KarthickMahalingam Oct 31, 2026
fcf747a
test: assert which setting and error the restore log names
KarthickMahalingam Oct 31, 2026
e18dcb1
Merge branch 'main' into feat/font-size-spacing
KarthickMahalingam Oct 31, 2026
9ecfd00
test: a rolled-back import write puts an unset appearance value back …
KarthickMahalingam Oct 31, 2026
d390d61
fix(ui): send only the appearance values the form changed; validate c…
KarthickMahalingam Oct 31, 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
180 changes: 180 additions & 0 deletions src/core/config/__tests__/appearanceSettingWrites.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import {
APPEARANCE_SETTING_KEYS,
beginSettingWrite,
finishSettingWrites,
isAppearanceSetting,
performSettingWrite,
undoSettingWrite,
} from "../appearanceSettingWrites"
import type { ContextProxy } from "../ContextProxy"

describe("appearanceSettingWrites", () => {
let stored: Map<string, unknown>
// Only the two methods the module uses. The cast says so: the real ContextProxy has far more.
let contextProxy: ContextProxy

beforeEach(() => {
stored = new Map<string, unknown>([["interfaceFontScale", 100]])
contextProxy = {
getValue: (key: string) => stored.get(key),
setValue: async (key: string, value: unknown) => {
stored.set(key, value)
},
} as unknown as ContextProxy
})

/** Stores the value, as the write that was just recorded would have. */
const store = (key: string, value: unknown) => stored.set(key, value)

describe("which settings are tracked", () => {
it.each(APPEARANCE_SETTING_KEYS)("tracks %s", (key) => {
expect(isAppearanceSetting(key)).toBe(true)
})

it.each(["soundEnabled", "language", "", "TimeFormat"])("does not track %j", (key) => {
expect(isAppearanceSetting(key)).toBe(false)
})
})

describe("two saves writing the same setting", () => {
it("a failed save does not undo a later save that wrote the same value afterwards", async () => {
const save = beginSettingWrite("interfaceFontScale", 100, 120)
store("interfaceFontScale", 120)
const other = beginSettingWrite("interfaceFontScale", 120, 120)
store("interfaceFontScale", 120)
finishSettingWrites([other])

await undoSettingWrite(contextProxy, save)

expect(stored.get("interfaceFontScale")).toBe(120)
})

it("a failed earlier save does not undo a later save that wrote the same value afterwards", async () => {
const other = beginSettingWrite("interfaceFontScale", 100, 150)
store("interfaceFontScale", 150)
const save = beginSettingWrite("interfaceFontScale", 150, 150)
store("interfaceFontScale", 150)
finishSettingWrites([save])

await undoSettingWrite(contextProxy, other)

expect(stored.get("interfaceFontScale")).toBe(150)
})

it("when both fail, each restores what was stored before either of them", async () => {
const save = beginSettingWrite("interfaceFontScale", 100, 120)
store("interfaceFontScale", 120)
const other = beginSettingWrite("interfaceFontScale", 120, 150)
store("interfaceFontScale", 150)

await undoSettingWrite(contextProxy, save) // superseded by the other save: nothing changes yet
expect(stored.get("interfaceFontScale")).toBe(150)

await undoSettingWrite(contextProxy, other)
expect(stored.get("interfaceFontScale")).toBe(100)
})
})

describe("performSettingWrite", () => {
it("reads what the write replaces when it starts", async () => {
const write = beginSettingWrite("interfaceFontScale", "stale", 120)
store("interfaceFontScale", 150) // an earlier write reached storage after this one registered

await performSettingWrite(contextProxy, write)

expect(write.previous).toBe(150)
expect(stored.get("interfaceFontScale")).toBe(120)
})

it("keeps the value handed over by a write that was rolled back before this one started", async () => {
const first = beginSettingWrite("interfaceFontScale", 100, 150)
store("interfaceFontScale", 150)
const second = beginSettingWrite("interfaceFontScale", 150, 120)

await undoSettingWrite(contextProxy, first) // superseded: hands 100 to the second write
await performSettingWrite(contextProxy, second)
expect(second.previous).toBe(100)

await undoSettingWrite(contextProxy, second)
expect(stored.get("interfaceFontScale")).toBe(100)
})
})

describe("a write that finished between two that fail", () => {
it("keeps its value when the earlier one fails and then the later one", async () => {
const first = beginSettingWrite("interfaceFontScale", 100, 150)
await performSettingWrite(contextProxy, first)
const second = beginSettingWrite("interfaceFontScale", 150, 120)
await performSettingWrite(contextProxy, second)
const third = beginSettingWrite("interfaceFontScale", 120, 80) // registered while the second is pending
finishSettingWrites([second]) // committed, between the other two
await performSettingWrite(contextProxy, third)

await undoSettingWrite(contextProxy, first) // superseded: the committed value is not its to restore
await undoSettingWrite(contextProxy, third)

expect(stored.get("interfaceFontScale")).toBe(120)
})

it("keeps its value for a later write that has not started when the earlier one fails", async () => {
const first = beginSettingWrite("interfaceFontScale", 100, 150)
await performSettingWrite(contextProxy, first)
const second = beginSettingWrite("interfaceFontScale", 150, 120)
await performSettingWrite(contextProxy, second)
const third = beginSettingWrite("interfaceFontScale", 120, 80) // queued, not started

await undoSettingWrite(contextProxy, first)
finishSettingWrites([second])
await performSettingWrite(contextProxy, third)
await undoSettingWrite(contextProxy, third)

expect(stored.get("interfaceFontScale")).toBe(120)
})

it("leaves what a write read when it started, if it had started when the earlier one finished", async () => {
const first = beginSettingWrite("interfaceFontScale", 100, 150)
await performSettingWrite(contextProxy, first)
const second = beginSettingWrite("interfaceFontScale", 150, 120)
await performSettingWrite(contextProxy, second)
const third = beginSettingWrite("interfaceFontScale", 120, 80)
await performSettingWrite(contextProxy, third) // started: it read 120

finishSettingWrites([second])
await undoSettingWrite(contextProxy, third)

expect(stored.get("interfaceFontScale")).toBe(120)
})
})

it("puts a failed write back to what was stored before it", async () => {
const write = beginSettingWrite("interfaceFontScale", 100, 120)
store("interfaceFontScale", 120)

await undoSettingWrite(contextProxy, write)

expect(stored.get("interfaceFontScale")).toBe(100)
})

it("leaves a value alone that was changed by something else after the write", async () => {
const write = beginSettingWrite("interfaceFontScale", 100, 120)
store("interfaceFontScale", 150)

await undoSettingWrite(contextProxy, write)

expect(stored.get("interfaceFontScale")).toBe(150)
})

it("does not link a new write to one that has finished", async () => {
const first = beginSettingWrite("interfaceFontScale", 100, 120)
store("interfaceFontScale", 120)
finishSettingWrites([first])
const second = beginSettingWrite("interfaceFontScale", 120, 150)
store("interfaceFontScale", 150)

await undoSettingWrite(contextProxy, second)

// Back to the finished write's value, not past it.
expect(stored.get("interfaceFontScale")).toBe(120)
expect(second.prev).toBeUndefined()
})
})
Loading
Loading