Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ export const globalSettingsSchema = z.object({
includeTaskHistoryInEnhance: z.boolean().optional(),
historyPreviewCollapsed: z.boolean().optional(),
reasoningBlockCollapsed: z.boolean().optional(),
/**
* Font size (in pixels) for the Zoo Code chat/webview UI.
* When unset (or `null`), the webview inherits VS Code's `--vscode-font-size`.
*/
chatFontSize: z.number().int().min(8).max(32).nullish(),
/**
* Controls the keyboard behavior for sending messages in the chat input.
* - "send": Enter sends message, Shift+Enter creates newline (default)
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ export type ExtensionState = Pick<
| "openRouterImageGenerationSelectedModel"
| "includeTaskHistoryInEnhance"
| "reasoningBlockCollapsed"
| "chatFontSize"
| "enterBehavior"
| "includeCurrentTime"
| "includeCurrentCost"
Expand Down
3 changes: 3 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2071,6 +2071,7 @@ export class ClineProvider
maxTotalImageSize,
historyPreviewCollapsed,
reasoningBlockCollapsed,
chatFontSize,
enterBehavior,
cloudUserInfo,
cloudIsAuthenticated,
Expand Down Expand Up @@ -2229,6 +2230,7 @@ export class ClineProvider
settingsImportedAt: this.settingsImportedAt,
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
chatFontSize,
enterBehavior: enterBehavior ?? "send",
cloudUserInfo,
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
Expand Down Expand Up @@ -2428,6 +2430,7 @@ export class ClineProvider
maxTotalImageSize: stateValues.maxTotalImageSize ?? 20,
historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,
reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true,
chatFontSize: stateValues.chatFontSize,
enterBehavior: stateValues.enterBehavior ?? "send",
cloudUserInfo,
cloudIsAuthenticated,
Expand Down
2 changes: 1 addition & 1 deletion webview-ui/src/components/common/MarkdownBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const StyledMarkdown = styled.div`
"Helvetica Neue",
sans-serif;

font-size: var(--vscode-font-size, 13px);
font-size: var(--zoo-chat-font-size, var(--vscode-font-size, 13px));

p,
li,
Expand Down
3 changes: 3 additions & 0 deletions webview-ui/src/components/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
reasoningBlockCollapsed,
chatFontSize,
enterBehavior,
includeCurrentTime,
includeCurrentCost,
Expand Down Expand Up @@ -412,6 +413,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
followupAutoApproveTimeoutMs,
includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true,
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
chatFontSize: chatFontSize ?? null,
enterBehavior: enterBehavior ?? "send",
includeCurrentTime: includeCurrentTime ?? true,
includeCurrentCost: includeCurrentCost ?? true,
Expand Down Expand Up @@ -892,6 +894,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
<UISettings
reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
enterBehavior={enterBehavior ?? "send"}
chatFontSize={chatFontSize ?? undefined}
setCachedStateField={setCachedStateField}
/>
)}
Expand Down
55 changes: 55 additions & 0 deletions webview-ui/src/components/settings/UISettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,24 @@ import { SetCachedStateField } from "./types"
import { SectionHeader } from "./SectionHeader"
import { Section } from "./Section"
import { SearchableSetting } from "./SearchableSetting"
import { Slider, Button } from "../ui"
import { ExtensionStateContextType } from "@/context/ExtensionStateContext"

export const CHAT_FONT_SIZE_MIN = 8
export const CHAT_FONT_SIZE_MAX = 32
export const CHAT_FONT_SIZE_DEFAULT = 13

interface UISettingsProps extends HTMLAttributes<HTMLDivElement> {
reasoningBlockCollapsed: boolean
enterBehavior: "send" | "newline"
chatFontSize?: number
setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType>
}

export const UISettings = ({
reasoningBlockCollapsed,
enterBehavior,
chatFontSize,
setCachedStateField,
...props
}: UISettingsProps) => {
Expand Down Expand Up @@ -48,6 +55,22 @@ export const UISettings = ({
})
}

const handleChatFontSizeChange = (value: number) => {
setCachedStateField("chatFontSize", value)

// Track telemetry event
telemetryClient.capture("ui_settings_chat_font_size_changed", {
value,
})
}

const handleChatFontSizeReset = () => {
setCachedStateField("chatFontSize", undefined)

// Track telemetry event
telemetryClient.capture("ui_settings_chat_font_size_reset")
}
Comment on lines +58 to +72
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other two handlers in this file both emit a telemetry event — should these do the same? Something like ui_settings_chat_font_size_changed (with value) and ui_settings_chat_font_size_reset would keep the feature visible in analytics.


return (
<div {...props}>
<SectionHeader>{t("settings:sections.ui")}</SectionHeader>
Expand Down Expand Up @@ -91,6 +114,38 @@ export const UISettings = ({
</div>
</div>
</SearchableSetting>

{/* Chat Font Size Setting */}
<SearchableSetting
settingId="ui-chat-font-size"
section="ui"
label={t("settings:ui.chatFontSize.label")}>
<div className="flex flex-col gap-1">
<label className="block font-medium mb-1">{t("settings:ui.chatFontSize.label")}</label>
<div className="flex items-center gap-2">
<Slider
min={CHAT_FONT_SIZE_MIN}
max={CHAT_FONT_SIZE_MAX}
step={1}
value={[chatFontSize ?? CHAT_FONT_SIZE_DEFAULT]}
onValueChange={([value]) => handleChatFontSizeChange(value)}
data-testid="chat-font-size-slider"
/>
<span className="w-12 text-right">{chatFontSize ?? CHAT_FONT_SIZE_DEFAULT}px</span>
<Button
variant="secondary"
size="sm"
disabled={chatFontSize === undefined}
onClick={handleChatFontSizeReset}
data-testid="chat-font-size-reset">
{t("settings:ui.chatFontSize.reset")}
</Button>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:ui.chatFontSize.description")}
</div>
</div>
</SearchableSetting>
</div>
</Section>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,37 @@ describe("SettingsView - Sound Settings", () => {
)
})

it("saves the selected chat font size and persists null on reset", () => {
const { activateTab, getSettingsContent } = renderSettingsView()

activateTab("ui")

const content = getSettingsContent()
const slider = within(content).getByTestId("chat-font-size-slider")

// Pick a size, then Save — the boundary should forward it to the host.
fireEvent.change(slider, { target: { value: "18" } })
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does fireEvent.change on the Radix <Slider> root element actually reach onValueChange? The matching UISettings.spec.tsx test drives the slider via fireEvent.keyDown(slider, { key: "ArrowRight" }) on the [role="slider"] thumb — if this change event is silently ignored, the assertion below might be passing because chatFontSize was already 18 in the initial mock state rather than because the interaction worked.

fireEvent.click(screen.getByTestId("save-button"))

expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "updateSettings",
updatedSettings: expect.objectContaining({ chatFontSize: 18 }),
}),
)

// Reset clears the override; it is persisted as null (not undefined).
fireEvent.click(within(getSettingsContent()).getByTestId("chat-font-size-reset"))
fireEvent.click(screen.getByTestId("save-button"))

expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "updateSettings",
updatedSettings: expect.objectContaining({ chatFontSize: null }),
}),
)
})

it("shows tts slider when sound is enabled", () => {
// Render once and get the activateTab helper
const { activateTab, getSettingsContent } = renderSettingsView()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { render, fireEvent, waitFor } from "@testing-library/react"
import { describe, it, expect, vi } from "vitest"
import { UISettings } from "../UISettings"
import { telemetryClient } from "@/utils/TelemetryClient"

vi.mock("@/utils/TelemetryClient", () => ({
telemetryClient: { capture: vi.fn() },
}))

describe("UISettings", () => {
const defaultProps = {
Expand Down Expand Up @@ -41,4 +46,52 @@ describe("UISettings", () => {
rerender(<UISettings {...defaultProps} reasoningBlockCollapsed={true} />)
expect(checkbox.checked).toBe(true)
})

describe("chat font size", () => {
it("shows the default font size when unset (init)", () => {
const { getByText, getByTestId } = render(<UISettings {...defaultProps} chatFontSize={undefined} />)
expect(getByTestId("chat-font-size-slider")).toBeTruthy()
// Default falls back to VS Code-equivalent default value.
expect(getByText("13px")).toBeTruthy()
})

it("shows the configured font size when set", () => {
const { getByText } = render(<UISettings {...defaultProps} chatFontSize={20} />)
expect(getByText("20px")).toBeTruthy()
})

it("persists a user-edited font size via setCachedStateField", () => {
Comment thread
edelauna marked this conversation as resolved.
const setCachedStateField = vi.fn()
const { getByTestId } = render(
<UISettings {...defaultProps} chatFontSize={14} setCachedStateField={setCachedStateField} />,
)

const slider = getByTestId("chat-font-size-slider").querySelector('[role="slider"]') as HTMLElement
slider.focus()
fireEvent.keyDown(slider, { key: "ArrowRight" })

expect(setCachedStateField).toHaveBeenCalledWith("chatFontSize", 15)
expect(telemetryClient.capture).toHaveBeenCalledWith("ui_settings_chat_font_size_changed", { value: 15 })
})

it("disables reset when unset and clears the value on reset", () => {
const setCachedStateField = vi.fn()
const { getByTestId, rerender } = render(
<UISettings {...defaultProps} chatFontSize={undefined} setCachedStateField={setCachedStateField} />,
)

const resetUnset = getByTestId("chat-font-size-reset") as HTMLButtonElement
expect(resetUnset.disabled).toBe(true)

rerender(
<UISettings {...defaultProps} chatFontSize={18} setCachedStateField={setCachedStateField} />,
)
const resetSet = getByTestId("chat-font-size-reset") as HTMLButtonElement
expect(resetSet.disabled).toBe(false)

fireEvent.click(resetSet)
expect(setCachedStateField).toHaveBeenCalledWith("chatFontSize", undefined)
expect(telemetryClient.capture).toHaveBeenCalledWith("ui_settings_chat_font_size_reset")
})
})
})
17 changes: 17 additions & 0 deletions webview-ui/src/context/ExtensionStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ export interface ExtensionStateContextType extends ExtensionState {
togglePinnedApiConfig: (configName: string) => void
setHistoryPreviewCollapsed: (value: boolean) => void
setReasoningBlockCollapsed: (value: boolean) => void
chatFontSize?: number
setChatFontSize: (value: number | undefined) => void
enterBehavior?: "send" | "newline"
setEnterBehavior: (value: "send" | "newline") => void
autoCondenseContext: boolean
Expand Down Expand Up @@ -473,8 +475,22 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
vscode.postMessage({ type: "webviewDidLaunch" })
}, [])

// Apply the configurable chat font size as a CSS variable. When unset, the
// override is removed so the UI falls back to VS Code's `--vscode-font-size`.
useEffect(() => {
const root = document.documentElement
if (typeof state.chatFontSize === "number") {
root.style.setProperty("--zoo-chat-font-size", `${state.chatFontSize}px`)
} else {
root.style.removeProperty("--zoo-chat-font-size")
}
}, [state.chatFontSize])

const contextValue: ExtensionStateContextType = {
...state,
// `chatFontSize` is persisted as nullish (null on reset); normalize null to
// undefined so it matches the context type and means "use VS Code default".
chatFontSize: state.chatFontSize ?? undefined,
reasoningBlockCollapsed: state.reasoningBlockCollapsed ?? true,
didHydrateState,
showWelcome,
Expand Down Expand Up @@ -572,6 +588,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setState((prevState) => ({ ...prevState, historyPreviewCollapsed: value })),
setReasoningBlockCollapsed: (value) =>
setState((prevState) => ({ ...prevState, reasoningBlockCollapsed: value })),
setChatFontSize: (value) => setState((prevState) => ({ ...prevState, chatFontSize: value })),
enterBehavior: state.enterBehavior ?? "send",
setEnterBehavior: (value) => setState((prevState) => ({ ...prevState, enterBehavior: value })),
setHasOpenedModeSelector: (value) => setState((prevState) => ({ ...prevState, hasOpenedModeSelector: value })),
Expand Down
53 changes: 53 additions & 0 deletions webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ const TestComponent = () => {
)
}

const ChatFontSizeTestComponent = () => {
const { chatFontSize, setChatFontSize } = useExtensionState()

return (
<div>
<div data-testid="chat-font-size">{JSON.stringify(chatFontSize ?? null)}</div>
<button data-testid="set-font-size-button" onClick={() => setChatFontSize(20)}>
Set Font Size
</button>
<button data-testid="reset-font-size-button" onClick={() => setChatFontSize(undefined)}>
Reset Font Size
</button>
</div>
)
}

const ApiConfigTestComponent = () => {
const { apiConfiguration, setApiConfiguration } = useExtensionState()

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

it("does not set the chat font-size CSS variable when unset (init)", () => {
document.documentElement.style.removeProperty("--zoo-chat-font-size")

render(
<ExtensionStateContextProvider>
<ChatFontSizeTestComponent />
</ExtensionStateContextProvider>,
)

expect(JSON.parse(screen.getByTestId("chat-font-size").textContent!)).toBe(null)
expect(document.documentElement.style.getPropertyValue("--zoo-chat-font-size")).toBe("")
})

it("applies the chat font-size CSS variable when set, and clears it on reset", () => {
document.documentElement.style.removeProperty("--zoo-chat-font-size")

render(
<ExtensionStateContextProvider>
<ChatFontSizeTestComponent />
</ExtensionStateContextProvider>,
)

act(() => {
screen.getByTestId("set-font-size-button").click()
})

expect(JSON.parse(screen.getByTestId("chat-font-size").textContent!)).toBe(20)
expect(document.documentElement.style.getPropertyValue("--zoo-chat-font-size")).toBe("20px")

act(() => {
screen.getByTestId("reset-font-size-button").click()
})

expect(JSON.parse(screen.getByTestId("chat-font-size").textContent!)).toBe(null)
expect(document.documentElement.style.getPropertyValue("--zoo-chat-font-size")).toBe("")
})

it("updates allowedCommands through setAllowedCommands", () => {
render(
<ExtensionStateContextProvider>
Expand Down
5 changes: 5 additions & 0 deletions webview-ui/src/i18n/locales/ca/settings.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions webview-ui/src/i18n/locales/de/settings.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading