Skip to content
Merged
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
9 changes: 8 additions & 1 deletion shared/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,14 @@ export const CLAUDE_PERMISSION_MODE_VALUES = ['default', 'plan', 'acceptEdits',
const EXTERNAL_EDITOR_VALUES = ['auto', 'cursor', 'code', 'custom'] as const
const NETWORK_HOST_VALUES = ['127.0.0.1', '0.0.0.0'] as const
const UI_SCALE_MIN = 0.75
const UI_SCALE_MAX = 1.5
const UI_SCALE_MAX = 4
// Slider stops for the UI scale control, in integer percent (avoids float drift).
// Fine 5% steps up to 200%, coarse 25% steps to 400%.
export const UI_SCALE_PERCENT_OPTIONS: readonly number[] = [
75, 80, 85, 90, 95, 100, 105, 110, 115, 120, 125, 130, 135, 140, 145, 150,
155, 160, 165, 170, 175, 180, 185, 190, 195, 200,
225, 250, 275, 300, 325, 350, 375, 400,
]
const TERMINAL_FONT_SIZE_MIN = 12
const TERMINAL_FONT_SIZE_MAX = 32
const TERMINAL_LINE_HEIGHT_MIN = 1
Expand Down
18 changes: 9 additions & 9 deletions src/components/settings/AppearanceSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { terminalThemes, darkThemes, lightThemes, getTerminalTheme } from '@/lib
import { resolveTerminalFontFamily } from '@/lib/terminal-fonts'
import type { AppSettings, TerminalTheme } from '@/store/types'
import type { SettingsSectionProps } from './settings-types'
import { UI_SCALE_PERCENT_OPTIONS } from '@shared/settings'
import {
SettingsSection,
SettingsRow,
SegmentedControl,
Toggle,
RangeSlider,
SteppedRangeInput,
} from './settings-controls'

type PreviewTokenKind =
Expand Down Expand Up @@ -307,15 +309,13 @@ export default function AppearanceSettings({
</SettingsRow>

<SettingsRow label="UI scale">
<RangeSlider
value={settings.uiScale ?? 1.0}
min={0.75}
max={1.5}
step={0.05}
labelWidth="w-12"
format={(v) => `${Math.round(v * 100)}%`}
onChange={(v) => {
applyLocalSetting({ uiScale: v })
<SteppedRangeInput
value={Math.round((settings.uiScale ?? 1.0) * 100)}
values={UI_SCALE_PERCENT_OPTIONS}
unit="%"
aria-label="UI scale"
Comment on lines +312 to +316

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Update the docs mock for the new UI scale control

This replaces the UI-scale setting with the new stepped 75–400% control, but docs/index.html still renders the old mock slider with max="1.5" and no percent input around its UI scale row. AGENTS.md says, “When adding new user-facing features or making significant UI changes, update docs/index.html,” so the default-experience mock will now document the wrong settings UI unless it is updated with this change.

Useful? React with 👍 / 👎.

onChange={(pct) => {
applyLocalSetting({ uiScale: pct / 100 })
}}
/>
</SettingsRow>
Expand Down
113 changes: 112 additions & 1 deletion src/components/settings/settings-controls.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Reusable form controls for settings pages — sections, rows, toggles, sliders, etc.

import { useState } from 'react'
import { useRef, useState } from 'react'
import { cn } from '@/lib/utils'

export function SettingsSection({
Expand Down Expand Up @@ -210,3 +210,114 @@ export function RangeSlider({
</div>
)
}

function nearestIndex(values: readonly number[], value: number): number {
let best = 0
for (let i = 1; i < values.length; i++) {
if (Math.abs(values[i] - value) < Math.abs(values[best] - value)) best = i
}
return best
}

// Discrete slider over an ascending allowed-values list plus a numeric input.
// The slider steps by index (one stop per arrow press, aria-valuetext announces
// the real value); the numeric input accepts any integer in range (no snapping).
// Pointer drags defer commit until release; keyboard changes commit immediately.
export function SteppedRangeInput({
value,
values,
onChange,
'aria-label': ariaLabel,
unit = '',
width = 'w-full md:w-32',
}: {
value: number
values: readonly number[]
onChange: (value: number) => void
'aria-label': string
unit?: string
width?: string
}) {
const [pendingIndex, setPendingIndex] = useState<number | null>(null)
const [draft, setDraft] = useState<string | null>(null)
const pointerActive = useRef(false)

const min = values[0]
const max = values[values.length - 1]
const displayValue = pendingIndex !== null ? values[pendingIndex] : value

const commitPending = () => {
pointerActive.current = false
if (pendingIndex !== null) {
onChange(values[pendingIndex])
setPendingIndex(null)
}
}

const commitDraft = () => {
if (draft === null) return
setDraft(null)
const trimmed = draft.trim()
if (trimmed === '') return
const parsed = Number(trimmed)
if (!Number.isFinite(parsed)) return
const result = Math.min(max, Math.max(min, Math.round(parsed)))
if (result !== value) onChange(result)
}

return (
<div className="flex w-full items-center gap-3 md:w-auto">
<input
type="range"
min={0}
max={values.length - 1}
step={1}
value={pendingIndex ?? nearestIndex(values, value)}
aria-label={ariaLabel}
aria-valuetext={`${displayValue}${unit}`}
onChange={(e) => {
const idx = Number(e.target.value)
if (pointerActive.current) {
setPendingIndex(idx)
} else {
onChange(values[idx])
}
}}
onPointerDown={() => {
pointerActive.current = true
}}
onPointerUp={commitPending}
onPointerLeave={commitPending}
className={cn(
width,
'h-1.5 bg-muted rounded-full appearance-none cursor-pointer',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground'
)}
/>
<input
type="number"
inputMode="numeric"
min={min}
max={max}
step={1}
aria-label={ariaLabel}
value={draft ?? String(displayValue)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitDraft()
} else if (e.key === 'Escape') {
setDraft(null)
}
}}
className="h-10 w-16 px-2 text-right text-sm tabular-nums bg-muted border-0 rounded-md focus:outline-none focus:ring-1 focus:ring-border md:h-8"
/>
{unit && (
<span aria-hidden="true" className="text-sm text-muted-foreground">
{unit}
</span>
)}
</div>
)
}
71 changes: 63 additions & 8 deletions test/unit/client/components/SettingsView.behavior.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,72 @@ describe('SettingsView behavior sections', () => {
const store = createSettingsViewStore()
renderSettingsView(store)

const uiScaleSlider = getSlider((slider) => {
const min = slider.getAttribute('min')
const step = slider.getAttribute('step')
return min === '0.75' && step === '0.05'
})
const uiScaleSlider = screen.getByRole('slider', { name: 'UI scale' })

fireEvent.change(uiScaleSlider, { target: { value: '1.5' } })
fireEvent.change(uiScaleSlider, { target: { value: '33' } })
fireEvent.pointerUp(uiScaleSlider)

expect(store.getState().settings.settings.uiScale).toBe(1.5)
expect(screen.getByText('150%')).toBeInTheDocument()
expect(store.getState().settings.settings.uiScale).toBe(4)
const spinbutton = screen.getByRole('spinbutton', { name: 'UI scale' }) as HTMLInputElement
expect(spinbutton.value).toBe('400')
})

it('commits keyboard-only slider changes across the 200% boundary', () => {
const store = createSettingsViewStore({ settings: { uiScale: 2.0 } })
renderSettingsView(store)

const uiScaleSlider = screen.getByRole('slider', { name: 'UI scale' })

// No pointer events: keyboard changes must commit immediately.
fireEvent.change(uiScaleSlider, { target: { value: '26' } })

expect(store.getState().settings.settings.uiScale).toBe(2.25)
})

it('clamps numeric UI scale input to the supported range without calling /api/settings', async () => {
const store = createSettingsViewStore()
renderSettingsView(store)

const uiScaleInput = screen.getByRole('spinbutton', { name: 'UI scale' })

fireEvent.change(uiScaleInput, { target: { value: '999' } })
fireEvent.blur(uiScaleInput)
expect(store.getState().settings.settings.uiScale).toBe(4)

fireEvent.change(uiScaleInput, { target: { value: '50' } })
fireEvent.keyDown(uiScaleInput, { key: 'Enter' })
expect(store.getState().settings.settings.uiScale).toBe(0.75)

await act(async () => {
vi.advanceTimersByTime(500)
})

expect(api.patch).not.toHaveBeenCalled()
})

it('commits typed off-list UI scale percentages without snapping', () => {
const store = createSettingsViewStore()
renderSettingsView(store)

const uiScaleInput = screen.getByRole('spinbutton', { name: 'UI scale' })

fireEvent.change(uiScaleInput, { target: { value: '137' } })
fireEvent.blur(uiScaleInput)

expect(store.getState().settings.settings.uiScale).toBeCloseTo(1.37)
})

it('ignores invalid numeric UI scale input', () => {
const store = createSettingsViewStore()
renderSettingsView(store)
const initialUiScale = store.getState().settings.settings.uiScale

const uiScaleInput = screen.getByRole('spinbutton', { name: 'UI scale' })

fireEvent.change(uiScaleInput, { target: { value: 'abc' } })
fireEvent.blur(uiScaleInput)

expect(store.getState().settings.settings.uiScale).toBe(initialUiScale)
})

it('updates sidebar sort mode locally without calling /api/settings', async () => {
Expand Down
4 changes: 3 additions & 1 deletion test/unit/client/components/SettingsView.core.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,9 @@ describe('SettingsView core sections', () => {
const store = createSettingsViewStore({ settings: { uiScale: 1.5 } })
renderSettingsView(store)

expect(screen.getByText('150%')).toBeInTheDocument()
const uiScaleInput = screen.getByRole('spinbutton', { name: 'UI scale' }) as HTMLInputElement
expect(uiScaleInput.value).toBe('150')
expect(screen.getByRole('slider', { name: 'UI scale' }).getAttribute('aria-valuetext')).toBe('150%')
})

it('displays current line height value', () => {
Expand Down
Loading
Loading