diff --git a/src/main/persistence/applying-settings/persistence-native-chat-appearance.test.ts b/src/main/persistence/applying-settings/persistence-native-chat-appearance.test.ts index 9d26ca9e2a0..defa4a87657 100644 --- a/src/main/persistence/applying-settings/persistence-native-chat-appearance.test.ts +++ b/src/main/persistence/applying-settings/persistence-native-chat-appearance.test.ts @@ -20,9 +20,12 @@ afterEach(async () => { it('normalizes, saves, reloads, and removes chat appearance overrides', async () => { const store = await createStore() - store.updateSettings({ nativeChatAppearance: { fontSize: 25, codeFontSize: 15, width: 'wide' } }) + store.updateSettings({ + nativeChatAppearance: { fontFamily: ' Georgia ', fontSize: 25, codeFontSize: 15, width: 'wide' } + }) store.flush() expect(readDataFile()).toHaveProperty('settings.nativeChatAppearance', { + fontFamily: 'Georgia', fontSize: 20, codeFontSize: 15, width: 'wide' @@ -30,6 +33,7 @@ it('normalizes, saves, reloads, and removes chat appearance overrides', async () await closeTestStores() const reopened = await createStore() expect(reopened.getSettings().nativeChatAppearance).toEqual({ + fontFamily: 'Georgia', fontSize: 20, codeFontSize: 15, width: 'wide' diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.font-windowing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.font-windowing.test.tsx new file mode 100644 index 00000000000..5806f66cc62 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.font-windowing.test.tsx @@ -0,0 +1,251 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' +import { useAppStore } from '../../store' +import { NATIVE_CHAT_BOTTOM_THRESHOLD_PX } from './native-chat-autoscroll' +import { transcriptRowOffset } from './native-chat-window-test-layout' +import { + deliverResizes, + layout, + list, + marker, + scrollTranscript, + stubLayout, + stubResizeObserver, + windowState +} from './native-chat-windowing-test-harness' + +vi.mock('./NativeChatInlineVisual', () => ({ NativeChatInlineVisual: () => null })) +vi.mock('@/lib/open-agent-message-sender', () => ({ openAgentMessageSender: vi.fn() })) +vi.mock('./use-native-chat-paced-text', async (importOriginal) => + (await import('./native-chat-unpaced-text-fixture')).unpacedTextModule(importOriginal) +) + +const messages = Array.from({ length: 100 }, (_, index) => ({ + ...marker(index), + blocks: [{ type: 'text' as const, text: `marker-${index} ${'history '.repeat(65)}` }] +})) +const settings = createGlobalSettingsFixture({ appFontFamily: 'Geist' }) + +function scrollRoot(container: HTMLElement): HTMLElement { + const scroller = container.querySelector('[data-native-chat-scroll]') + if (!scroller) { + throw new Error('no transcript scroll root') + } + return scroller +} + +function paint(container: HTMLElement): void { + const scroller = scrollRoot(container) + // Commit-time scroll writes dispatch before the next ResizeObserver delivery. + fireEvent.scroll(scroller) + for (let pass = 0; pass < 30; pass += 1) { + const previous = scroller.scrollTop + let resized = false + act(() => { + resized = deliverResizes() + vi.advanceTimersByTime(16) + }) + const moved = scroller.scrollTop !== previous + fireEvent.scroll(scroller) + if (!resized && !moved && pass >= 2) { + return + } + } + throw new Error('transcript did not settle') +} + +function readerAnchor(container: HTMLElement): { index: number; withinRow: number } { + const top = scrollRoot(container).scrollTop + const row = Array.from(container.querySelectorAll('[data-index]')).find( + (node) => transcriptRowOffset(node) + node.offsetHeight > top + ) + if (!row) { + throw new Error('no row at the reader offset') + } + return { index: Number(row.dataset.index), withinRow: top - transcriptRowOffset(row) } +} + +function measureHistory(container: HTMLElement): void { + for (let top = 0; top <= 10_000; top += 400) { + scrollTranscript(container, top) + paint(container) + } +} + +function changeFont(): void { + act(() => { + useAppStore.setState({ + settings: { ...settings, nativeChatAppearance: { fontFamily: 'Georgia' } } + }) + }) +} + +describe('chat font changes in a windowed transcript', () => { + let visible = true + let restoreLayout = (): void => {} + let restoreResizeObserver = (): void => {} + const originalSettings = useAppStore.getState().settings + + beforeEach(() => { + vi.useFakeTimers() + visible = true + layout.measuredRowHeights = Array(100).fill(80) + restoreLayout = stubLayout({ + scrollGeometry: true, + offsetChain: true, + isVisible: () => visible + }) + restoreResizeObserver = stubResizeObserver() + useAppStore.setState({ settings }) + }) + afterEach(() => { + cleanup() + restoreResizeObserver() + restoreLayout() + layout.measuredRowHeights = [] + useAppStore.setState({ settings: originalSettings }) + vi.useRealTimers() + }) + + it.each([false, true])('retains the row and within-row offset (hidden: %s)', (hidden) => { + const { container, rerender } = render(list(messages)) + paint(container) + measureHistory(container) + scrollTranscript(container, hidden ? 3_410 : 9_010) + paint(container) + const before = readerAnchor(container) + expect(before).toEqual({ index: hidden ? 34 : 90, withinRow: 10 }) + expect(windowState(container).indexes.length).toBeLessThan(30) + + if (hidden) { + visible = false + rerender(list(messages, visible)) + paint(container) + expect(scrollRoot(container).scrollTop).toBe(0) + } + changeFont() + if (hidden) { + visible = true + rerender(list(messages, visible)) + } + paint(container) + + expect(readerAnchor(container)).toEqual(before) + expect(screen.queryByRole('button', { name: /jump to latest/i })).not.toBeNull() + rerender(list([...messages, marker(100)])) + paint(container) + expect(readerAnchor(container)).toEqual(before) + }) + + it.each([false, true])( + 'keeps the row and offset with an unchanged family (hidden: %s)', + (hidden) => { + const { container, rerender } = render(list(messages)) + paint(container) + measureHistory(container) + scrollTranscript(container, 3_410) + paint(container) + const before = readerAnchor(container) + if (hidden) { + visible = false + rerender(list(messages, visible)) + paint(container) + visible = true + } + rerender(list(messages, visible)) + paint(container) + expect(readerAnchor(container)).toEqual(before) + } + ) + + it.each([ + { hidden: false, height: 40 }, + { hidden: false, height: 120 }, + { hidden: true, height: 40 }, + { hidden: true, height: 120 } + ])( + 'remeasures mounted rows without moving a parked reader ($hidden, $height px)', + ({ hidden, height }) => { + const { container, rerender } = render(list(messages)) + paint(container) + measureHistory(container) + scrollTranscript(container, 3_410) + paint(container) + act(() => vi.advanceTimersByTime(200)) + const before = readerAnchor(container) + const heightBefore = windowState(container).totalSize + if (hidden) { + visible = false + rerender(list(messages, visible)) + paint(container) + } + layout.measuredRowHeights = Array(100).fill(height) + changeFont() + if (hidden) { + visible = true + rerender(list(messages, visible)) + } + paint(container) + + expect(Math.sign(windowState(container).totalSize - heightBefore)).toBe( + Math.sign(height - 80) + ) + expect(readerAnchor(container)).toEqual(before) + } + ) + + it('replaces offscreen cached heights when those rows remount after a font change', () => { + const { container } = render(list(messages)) + paint(container) + measureHistory(container) + scrollTranscript(container, 3_410) + paint(container) + act(() => vi.advanceTimersByTime(200)) + layout.measuredRowHeights = Array(100).fill(120) + changeFont() + paint(container) + const before = windowState(container) + expect(before.indexes).not.toContain(5) + + scrollTranscript(container, 500) + paint(container) + const after = windowState(container) + expect(after.indexes).toContain(5) + expect(after.totalSize).toBeGreaterThan(before.totalSize) + const row = container.querySelector('[data-index="5"]') + const nextRow = container.querySelector('[data-index="6"]') + if (!row || !nextRow) { + throw new Error('remeasured rows are missing') + } + expect(transcriptRowOffset(nextRow) - transcriptRowOffset(row)).toBe(140) + }) + + it.each([false, true])( + 'remeasures font reflow and keeps following the end (hidden: %s)', + (hidden) => { + const { container, rerender } = render(list(messages)) + paint(container) + if (hidden) { + visible = false + rerender(list(messages, visible)) + paint(container) + } + layout.measuredRowHeights = Array(100).fill(120) + changeFont() + if (hidden) { + visible = true + rerender(list(messages, visible)) + } + paint(container) + + const scroller = scrollRoot(container) + expect( + scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop + ).toBeLessThanOrEqual(NATIVE_CHAT_BOTTOM_THRESHOLD_PX) + expect(screen.queryByRole('button', { name: /jump to latest/i })).toBeNull() + } + ) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts index d97212709b2..191739c81ff 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' import { buildFontFamily } from '@/lib/monospace-font-family' +import { buildAppFontFamily } from '@/lib/app-font-family' import * as terminalThemeSelection from '../../../../shared/terminal-theme-selection' import { resolveConfiguredTerminalColors } from '../../../../shared/terminal-theme-selection' import { nativeChatAppearanceStyle, nativeChatContrastMix } from './native-chat-appearance-style' @@ -25,6 +26,28 @@ describe('nativeChatContrastMix', () => { }) describe('nativeChatAppearanceStyle terminal interface', () => { + it('uses a custom chat family while preserving code fonts and terminal matching precedence', () => { + const settings = makeSettings({ + appFontFamily: 'Arial', + terminalFontFamily: 'Menlo', + nativeChatAppearance: { fontFamily: 'Georgia' } + }) + const style = nativeChatAppearanceStyle(settings) + expect(style['--chat-font-family']).toBe(buildAppFontFamily('Georgia')) + expect(style['--chat-code-font-family']).toBe(buildFontFamily('Menlo')) + expect( + nativeChatAppearanceStyle({ + ...settings, + nativeChatAppearance: { ...settings.nativeChatAppearance, matchTerminalInterface: true } + })['--chat-font-family'] + ).toBe(buildFontFamily('Menlo')) + expect( + nativeChatAppearanceStyle({ + ...settings, + nativeChatAppearance: { fontFamily: '' } + })['--chat-font-family'] + ).toBeUndefined() + }) it('follows the terminal code font while keeping default body font and colors', () => { const style = nativeChatAppearanceStyle(makeSettings({ terminalFontFamily: 'Menlo' })) expect(style['--chat-code-font-family']).toBe(buildFontFamily('Menlo')) diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts index a235bc1aa21..1560a29a6b7 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts @@ -5,6 +5,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import { resolveNativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings' import { resolveConfiguredTerminalColors } from '../../../../shared/terminal-theme-selection' import { buildFontFamily } from '@/lib/monospace-font-family' +import { buildAppFontFamily } from '@/lib/app-font-family' import { getSystemPrefersDark, isTerminalBackgroundLight } from '@/lib/terminal-theme' import { useSystemPrefersDark } from '../terminal-pane/use-system-prefers-dark' @@ -92,7 +93,10 @@ export function nativeChatAppearanceStyle( Math.floor((((96 * columnWidthPx) / 736) * 14) / fontSize) ), '--chat-estimated-spacing-scale': uiFontSize / 14, - '--chat-code-font-family': font + '--chat-code-font-family': font, + ...(appearance.fontFamily + ? { '--chat-font-family': buildAppFontFamily(appearance.fontFamily) } + : {}) } let light = settings?.theme === 'light' || (settings?.theme === 'system' && !systemPrefersDark) if (matching) { diff --git a/src/renderer/src/components/native-chat/use-native-chat-row-typography.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-row-typography.test.tsx index 28800ca8c0f..9b8a875a34b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-row-typography.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-row-typography.test.tsx @@ -18,6 +18,19 @@ afterEach(() => { }) describe('memoized transcript row typography', () => { + it('keeps row estimates stable across custom and inherited font changes', () => { + const settings = createGlobalSettingsFixture({ appFontFamily: 'Geist' }) + useAppStore.setState({ settings }) + const { result } = renderHook(() => useNativeChatRowTypography(useRef(null))) + const inherited = result.current.typography + const custom = { ...settings, nativeChatAppearance: { fontFamily: 'Georgia' } } + act(() => useAppStore.setState({ settings: custom })) + expect(result.current.typography).toBe(inherited) + act(() => useAppStore.setState({ settings: { ...custom, appFontFamily: 'Arial' } })) + expect(result.current.typography).toBe(inherited) + act(() => useAppStore.setState({ settings: { ...settings, appFontFamily: 'Arial' } })) + expect(result.current.typography).toBe(inherited) + }) it('does no theme work on message renders or unrelated settings writes and follows text size and measured width', () => { const settings = createGlobalSettingsFixture({ theme: 'dark', diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts index 9d6849c0abf..ca6d97bcf8f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts @@ -90,6 +90,7 @@ export function useNativeChatTranscriptWindow({ ) const scaledGapPx = NATIVE_CHAT_ROW_GAP_PX * typography.spacingScale + // Keep offscreen sizes as estimates through reflows; ResizeObserver refreshes mounted rows. const virtualizer = useVirtualizer({ count: slots.length, getScrollElement: () => scrollRef.current, diff --git a/src/renderer/src/components/settings/AppearanceChatSection.test.tsx b/src/renderer/src/components/settings/AppearanceChatSection.test.tsx index 18b0dc2d25f..5a7142b4346 100644 --- a/src/renderer/src/components/settings/AppearanceChatSection.test.tsx +++ b/src/renderer/src/components/settings/AppearanceChatSection.test.tsx @@ -113,23 +113,50 @@ describe('chat appearance settings controls', () => { ) const text = screen.getByRole('spinbutton', { name: 'Text size' }) expect(text.getAttribute('value')).toBe('14') + const font = screen.getByRole('combobox', { name: 'Font family' }) + fireEvent.focus(font) + fireEvent.change(font, { target: { value: 'Georgia' } }) + expect(updateSettings).not.toHaveBeenCalled() + fireEvent.keyDown(font, { key: 'Enter' }) fireEvent.change(text, { target: { value: '30' } }) fireEvent.blur(text) const code = screen.getByRole('spinbutton', { name: 'Code text size' }) fireEvent.change(code, { target: { value: '16' } }) fireEvent.keyDown(code, { key: 'Enter' }) fireEvent.click(screen.getByRole('radio', { name: 'Full' })) - await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(3)) + await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(4)) expect(mocks.state.settings?.nativeChatAppearance).toEqual({ + fontFamily: 'Georgia', fontSize: 20, codeFontSize: 16, width: 'full' }) }) + it('previews highlighted fonts locally and clears the preview when the picker closes', () => { + const settings = getDefaultSettings('/tmp') + const updateSettings = persistInMock(settings) + render( + + ) + const font = screen.getByRole('combobox', { name: 'Font family' }) + fireEvent.focus(font) + fireEvent.mouseEnter(screen.getByRole('option', { name: 'Georgia' })) + const preview = screen.getByLabelText('Chat font preview') + expect(preview.style.getPropertyValue('--chat-font-family')).toContain('Georgia') + expect(updateSettings).not.toHaveBeenCalled() + fireEvent.keyDown(font, { key: 'Escape' }) + expect(preview.style.getPropertyValue('--chat-font-family')).toBe('') + expect(updateSettings).not.toHaveBeenCalled() + }) it('resets only owned fields and preserves future settings', async () => { const settings = { ...getDefaultSettings('/tmp'), nativeChatAppearance: { + fontFamily: 'Georgia', fontSize: 18, codeFontSize: 16, width: 'wide' as const, @@ -153,6 +180,8 @@ describe('chat appearance settings controls', () => { 'Chat', 'Match terminal interface', 'Contrast', + 'Font family', + 'typeface', 'brighter look', 'Code text size', 'tool output', @@ -169,6 +198,7 @@ describe('chat appearance settings controls', () => { const settings = { ...getDefaultSettings('/tmp'), nativeChatAppearance: { + fontFamily: 'Georgia', fontSize: 18, codeFontSize: 16, contrast: 125, @@ -180,7 +210,10 @@ describe('chat appearance settings controls', () => { , { wrapper: TooltipProvider } ) - expect(screen.getAllByText('Set by terminal interface.')).toHaveLength(3) + expect(screen.getAllByText('Set by terminal interface.')).toHaveLength(4) + expect(screen.getByRole('combobox', { name: 'Font family' }).hasAttribute('disabled')).toBe( + true + ) expect(screen.getByRole('spinbutton', { name: 'Text size' }).hasAttribute('disabled')).toBe( true ) @@ -196,6 +229,7 @@ describe('chat appearance settings controls', () => { fireEvent.click(screen.getByRole('radio', { name: 'Wide' })) await waitFor(() => expect(mocks.state.settings?.nativeChatAppearance?.width).toBe('wide')) expect(mocks.state.settings?.nativeChatAppearance).toMatchObject({ + fontFamily: 'Georgia', fontSize: 18, codeFontSize: 16, contrast: 125 @@ -211,6 +245,9 @@ describe('chat appearance settings controls', () => { ) expect(screen.queryByText('Set by terminal interface.')).toBeNull() + expect(screen.getByRole('combobox', { name: 'Font family' }).getAttribute('value')).toBe( + 'Georgia' + ) expect(screen.getByRole('spinbutton', { name: 'Text size' }).getAttribute('value')).toBe('18') expect(screen.getByRole('spinbutton', { name: 'Code text size' }).getAttribute('value')).toBe( '16' @@ -224,6 +261,7 @@ describe('chat appearance settings controls', () => { const terminalTooltip = 'Matching your terminal interface. Turn off Match terminal interface to change this.' const controlledControls = [ + ['combobox', 'Font family'], ['spinbutton', 'Text size'], ['spinbutton', 'Code text size'], ['slider', 'Contrast'] diff --git a/src/renderer/src/components/settings/AppearanceChatSection.tsx b/src/renderer/src/components/settings/AppearanceChatSection.tsx index 94e72ad91fd..afc221116be 100644 --- a/src/renderer/src/components/settings/AppearanceChatSection.tsx +++ b/src/renderer/src/components/settings/AppearanceChatSection.tsx @@ -8,6 +8,7 @@ import { useAppStore } from '../../store' import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' import { AppearanceChatContrastControls } from './AppearanceChatContrastControls' +import { ChatFontFamilySetting } from './ChatFontFamilySetting' import { Button } from '../ui/button' import { SearchableSetting } from './SearchableSetting' import { NumberField, SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' @@ -24,11 +25,15 @@ export type AppearanceChatSectionProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void forceVisiblePrimary?: boolean + fontSuggestions?: string[] + onRequestFontSuggestions?: () => void } export function AppearanceChatSection({ settings, updateSettings, + fontSuggestions, + onRequestFontSuggestions, forceVisiblePrimary = false }: AppearanceChatSectionProps): React.JSX.Element { const appearance = resolveNativeChatAppearanceSettings(settings.nativeChatAppearance) @@ -50,6 +55,13 @@ export function AppearanceChatSection({ onChange={update} forceVisiblePrimary={forceVisiblePrimary} /> + update({ fontFamily })} + fontSuggestions={fontSuggestions} + onRequestFontSuggestions={onRequestFontSuggestions} + forceVisible={forceVisiblePrimary} + /> void + fontSuggestions?: string[] + onRequestFontSuggestions?: () => void + forceVisible?: boolean +}): React.JSX.Element { + const appearance = resolveNativeChatAppearanceSettings(settings.nativeChatAppearance) + const matching = appearance.matchTerminalInterface + const [previewFamily, setPreviewFamily] = useState(null) + const entry = getChatAppearanceEntriesByKey().fontFamily + const style = useNativeChatAppearanceStyle({ + ...settings, + nativeChatAppearance: { + ...settings.nativeChatAppearance, + fontFamily: previewFamily ?? appearance.fontFamily + } + }) + const defaultLabel = translate('settings.appearance.chat.inheritFont', 'Same as IDE font') + + return ( + + + + + } + /> +

+ {translate( + 'settings.appearance.chat.fontPreview', + 'The quick brown fox jumps over the lazy dog.' + )}{' '} + fontFamily +

+
+ ) +} diff --git a/src/renderer/src/components/settings/ChatSettingsSection.tsx b/src/renderer/src/components/settings/ChatSettingsSection.tsx index ecb9854f710..4e4dcdbaa38 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.tsx @@ -18,6 +18,8 @@ import { getChatBehaviorTitle } from './chat-behavior-search' export function ChatSettingsSection({ settings, updateSettings, + fontSuggestions, + onRequestFontSuggestions, writeSourceControlAiSettings, onChatPromptDirtyChange, chatPromptDiscardSignal, @@ -28,6 +30,8 @@ export function ChatSettingsSection({ }: { settings: GlobalSettings updateSettings: (updates: Partial) => void | Promise + fontSuggestions?: string[] + onRequestFontSuggestions?: () => void writeSourceControlAiSettings: (patch: SourceControlAiSettingsPatch) => Promise onChatPromptDirtyChange?: (dirty: boolean) => void chatPromptDiscardSignal?: number @@ -65,6 +69,8 @@ export function ChatSettingsSection({ void placeholder?: string + ariaLabel?: string + disabled?: boolean + commitMode?: 'change' | 'blur' + defaultOptionLabel?: string onRequestSuggestions?: () => void /** Preview the highlighted font without committing; null clears the preview when nothing is highlighted or the menu closes. */ onPreviewFontFamily?: (font: string | null) => void @@ -22,6 +26,10 @@ export function FontAutocomplete({ suggestions, onChange, placeholder = 'SF Mono', + ariaLabel, + disabled = false, + commitMode = 'change', + defaultOptionLabel, onRequestSuggestions, onPreviewFontFamily }: FontAutocompleteProps): React.JSX.Element { @@ -32,6 +40,7 @@ export function FontAutocomplete({ const [isFilteringQuery, setIsFilteringQuery] = useState(false) const inputRef = useRef(null) const rootRef = useRef(null) + const lastCommittedValueRef = useRef(value) const previewFontFamilyRef = useRef(onPreviewFontFamily) const listboxId = useId() @@ -49,11 +58,17 @@ export function FontAutocomplete({ if (value !== prevValue) { setPrevValue(value) + lastCommittedValueRef.current = value setQuery(value) if (value !== query) { setIsFilteringQuery(false) } } + if (disabled && open) { + setOpen(false) + setQuery(value) + setIsFilteringQuery(false) + } const requestSuggestions = useCallback((): void => { onRequestSuggestions?.() @@ -71,13 +86,23 @@ export function FontAutocomplete({ const normalizedQuery = query.trim().toLowerCase() const normalizedValue = value.trim().toLowerCase() + const allSuggestions = useMemo(() => { + if (!defaultOptionLabel) { + return suggestions + } + const fonts = suggestions.filter(Boolean) + if (value && !fonts.includes(value)) { + fonts.push(value) + } + return ['', ...fonts] + }, [defaultOptionLabel, suggestions, value]) const filteredSuggestions = useMemo( - () => filterFontSuggestions(suggestions, query), - [suggestions, query] + () => filterFontSuggestions(allSuggestions, query, defaultOptionLabel), + [allSuggestions, query, defaultOptionLabel] ) // Opening with the committed font must still show all fonts rather than filter by its name. const visibleSuggestions = - !isFilteringQuery && normalizedQuery === normalizedValue ? suggestions : filteredSuggestions + !isFilteringQuery && normalizedQuery === normalizedValue ? allSuggestions : filteredSuggestions const renderedSuggestions = useMemo( () => getRenderedFontSuggestions(visibleSuggestions, highlightedIndex), [visibleSuggestions, highlightedIndex] @@ -108,17 +133,31 @@ export function FontAutocomplete({ if (!onPreviewFontFamily) { return } - if (!open || highlightedIndex < 0) { + if (!open || disabled) { onPreviewFontFamily(null) return } - onPreviewFontFamily(visibleSuggestions[highlightedIndex] ?? null) - }, [visibleSuggestions, highlightedIndex, onPreviewFontFamily, open]) + onPreviewFontFamily( + highlightedIndex >= 0 + ? (visibleSuggestions[highlightedIndex] ?? null) + : commitMode === 'blur' + ? query.trim() + : null + ) + }, [visibleSuggestions, highlightedIndex, onPreviewFontFamily, open, disabled, commitMode, query]) const commitValue = (nextValue: string): void => { - setQuery(nextValue) + const trimmed = commitMode === 'blur' ? nextValue.trim() : nextValue + const committed = + defaultOptionLabel && trimmed.toLowerCase() === defaultOptionLabel.trim().toLowerCase() + ? '' + : trimmed + setQuery(committed) setIsFilteringQuery(false) - onChange(nextValue) + if (commitMode === 'change' || committed !== lastCommittedValueRef.current) { + lastCommittedValueRef.current = committed + onChange(committed) + } setOpen(false) } @@ -133,18 +172,22 @@ export function FontAutocomplete({ return (
- +
{ const next = e.target.value requestSuggestions() setQuery(next) setIsFilteringQuery(true) - onChange(next) + if (commitMode === 'change') { + onChange(next) + } setOpen(true) }} onFocus={() => { @@ -152,8 +195,16 @@ export function FontAutocomplete({ setIsFilteringQuery(false) setOpen(true) }} + onBlur={() => { + if (commitMode === 'blur' && !disabled) { + commitValue(query) + } + }} onKeyDown={(e) => { if (e.key === 'Escape') { + if (commitMode === 'blur') { + setQuery(lastCommittedValueRef.current) + } if (open) { e.preventDefault() setOpen(false) @@ -184,12 +235,19 @@ export function FontAutocomplete({ return } + if (e.key === 'Enter' && e.nativeEvent.isComposing) { + return + } + if (e.key === 'Enter' && open && highlightedIndex >= 0) { const highlightedFont = visibleSuggestions[highlightedIndex] - if (highlightedFont) { + if (highlightedFont !== undefined) { e.preventDefault() commitValue(highlightedFont) } + } else if (e.key === 'Enter' && commitMode === 'blur') { + e.preventDefault() + commitValue(query) } }} placeholder={placeholder} @@ -208,11 +266,10 @@ export function FontAutocomplete({ {query ? ( )) diff --git a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx index bcf7ba80712..7e45aaf7853 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx @@ -70,6 +70,20 @@ describe('FontAutocomplete', () => { }) } + async function pressInputKey( + input: HTMLInputElement, + key: string, + options?: { isComposing?: boolean } + ): Promise { + await act(async () => { + const event = new KeyboardEvent('keydown', { key, bubbles: true }) + if (options?.isComposing !== undefined) { + Object.defineProperty(event, 'isComposing', { value: options.isComposing }) + } + input.dispatchEvent(event) + }) + } + it('arrow keys move through the full list when the input shows the committed font', async () => { function Harness(): ReactNode { const [value, setValue] = useState('Geist') @@ -266,4 +280,191 @@ describe('FontAutocomplete', () => { expect(getOptionLabels()).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT) expect(getOptionLabels().at(-1)).toBe('Zed Mono') }) + + it('previews searches without saving, restores on Escape, and commits a manual name once', async () => { + const change = vi.fn() + const preview = vi.fn() + await act(async () => { + root.render( + + ) + }) + await act(async () => getInput().focus()) + await typeIntoInput(getInput(), 'Geor') + expect(change).not.toHaveBeenCalled() + expect(preview).toHaveBeenLastCalledWith('Georgia') + await act(async () => { + getInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + expect(getInput().value).toBe('Geist') + await act(async () => getInput().blur()) + expect(change).not.toHaveBeenCalled() + await act(async () => getInput().focus()) + await typeIntoInput(getInput(), ' My Custom Font ') + expect(preview).toHaveBeenLastCalledWith('My Custom Font') + await act(async () => { + getInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + getInput().blur() + }) + expect(change.mock.calls).toEqual([['My Custom Font']]) + }) + + it.each([ + { query: 'Geor', description: 'a highlighted suggestion' }, + { query: 'My Custom Font', description: 'a manual font name' } + ])('does not commit $description when Enter confirms IME composition', async ({ query }) => { + const change = vi.fn() + await act(async () => { + root.render( + + ) + }) + const input = getInput() + await act(async () => input.focus()) + await typeIntoInput(input, query) + + await pressInputKey(input, 'Enter', { isComposing: true }) + + expect(input.value).toBe(query) + expect(change).not.toHaveBeenCalled() + expect(input.getAttribute('aria-expanded')).toBe('true') + }) + + it('commits typed names on blur and offers an explicit inherited-font choice', async () => { + const change = vi.fn() + function Harness(): ReactNode { + const [value, setValue] = useState('Georgia') + return ( + { + change(font) + setValue(font) + }} + /> + ) + } + await act(async () => root.render()) + await act(async () => getInput().focus()) + expect(getOptionLabels()[0]).toBe('Same as IDE font') + await typeIntoInput(getInput(), 'My Custom Font') + await act(async () => getInput().blur()) + expect(change.mock.calls).toEqual([['My Custom Font']]) + await act(async () => getInput().focus()) + const inherited = document.querySelector('[role="option"]') + await act(async () => inherited?.click()) + expect(change.mock.calls).toEqual([['My Custom Font'], ['']]) + expect(getInput().value).toBe('') + }) + + it.each([{ suggestions: [] }, { suggestions: ['Geist', 'SF Mono'] }])( + 'preserves an unlisted saved font on focus and Enter with suggestions $suggestions', + async ({ suggestions }) => { + const change = vi.fn() + const preview = vi.fn() + await act(async () => { + root.render( + + ) + }) + await act(async () => getInput().focus()) + expect(preview).toHaveBeenLastCalledWith('Georgia') + await act(async () => { + getInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + expect(getInput().value).toBe('Georgia') + expect(change).not.toHaveBeenCalled() + await act(async () => getInput().blur()) + expect(change).not.toHaveBeenCalled() + } + ) + + it.each([ + { label: 'Same as IDE font', query: ' SAME AS IDE ' }, + { label: 'IDEと同じフォント', query: '同じ' } + ])('searches the inherited option by its label: $label', async ({ label, query }) => { + const change = vi.fn() + await act(async () => { + root.render( + + ) + }) + await act(async () => getInput().focus()) + await typeIntoInput(getInput(), query) + expect(getOptionLabels()).toEqual([label]) + await act(async () => { + getInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + expect(change.mock.calls).toEqual([['']]) + expect(getInput().value).toBe('') + }) + + it('commits the inherited value when its full label is typed and blurred', async () => { + const change = vi.fn() + await act(async () => { + root.render( + + ) + }) + await act(async () => getInput().focus()) + await typeIntoInput(getInput(), ' same as IDE font ') + await act(async () => getInput().blur()) + expect(change.mock.calls).toEqual([['']]) + expect(getInput().value).toBe('') + }) + + it('disables the whole picker and discards an open draft without saving', async () => { + const change = vi.fn() + const renderPicker = (disabled: boolean) => ( + + ) + await act(async () => root.render(renderPicker(false))) + await act(async () => getInput().focus()) + await typeIntoInput(getInput(), 'Geor') + await act(async () => root.render(renderPicker(true))) + expect(getInput().disabled).toBe(true) + expect(getInput().value).toBe('Geist') + expect(getOptionLabels()).toEqual([]) + expect(change).not.toHaveBeenCalled() + expect([...container.querySelectorAll('button')].every((button) => button.disabled)).toBe(true) + }) }) diff --git a/src/renderer/src/components/settings/chat-appearance-search.ts b/src/renderer/src/components/settings/chat-appearance-search.ts index 5ae313e2133..9a6c783056d 100644 --- a/src/renderer/src/components/settings/chat-appearance-search.ts +++ b/src/renderer/src/components/settings/chat-appearance-search.ts @@ -46,6 +46,15 @@ export function getChatContrastSearchEntries(): SettingsSearchEntry[] { const getChatAppearanceCatalog = createLocalizedCatalog( () => ({ + fontFamily: { + targetSectionId: 'chat-font-family', + title: translate('settings.appearance.chat.fontFamily', 'Font family'), + description: translate( + 'settings.appearance.chat.fontFamilyDescription', + 'Font used for messages, activity, and the message box.' + ), + keywords: ['chat', 'font', 'family', 'typeface', 'typography'] + }, textSize: { targetSectionId: 'chat-text-size', title: translate('settings.appearance.chat.textSize', 'Text size'), diff --git a/src/renderer/src/components/settings/chat-naming-persistence.test.tsx b/src/renderer/src/components/settings/chat-naming-persistence.test.tsx index 30c59dde8b2..918596d13fd 100644 --- a/src/renderer/src/components/settings/chat-naming-persistence.test.tsx +++ b/src/renderer/src/components/settings/chat-naming-persistence.test.tsx @@ -31,7 +31,7 @@ function ChatPersistenceHarness({ model }: { model: SettingsPersistenceModel }) throw new Error('Settings fixture is missing') } const context = { - model: { ...model, settings }, + model: { ...model, settings, fontSuggestions: [] }, interactions: controller, navigation: { getSectionSearchEntries: () => [getChatNamingSearchEntry()] }, view: { isSectionMounted: () => true } diff --git a/src/renderer/src/components/settings/settings-form-option-filter.ts b/src/renderer/src/components/settings/settings-form-option-filter.ts index c321739722e..2f5ceea8afb 100644 --- a/src/renderer/src/components/settings/settings-form-option-filter.ts +++ b/src/renderer/src/components/settings/settings-form-option-filter.ts @@ -40,7 +40,11 @@ export function filterTerminalThemeOptions( ) } -export function filterFontSuggestions(suggestions: readonly string[], query: string): string[] { +export function filterFontSuggestions( + suggestions: readonly string[], + query: string, + defaultOptionLabel = '' +): string[] { const normalizedQuery = normalizeSettingsFormOptionQuery(query) if (normalizedQuery === null) { return [] @@ -52,7 +56,7 @@ export function filterFontSuggestions(suggestions: readonly string[], query: str const startsWith: string[] = [] const includes: string[] = [] for (const font of suggestions) { - const normalizedFont = font.toLowerCase() + const normalizedFont = (font || defaultOptionLabel).toLowerCase() if (normalizedFont.startsWith(normalizedQuery)) { startsWith.push(font) } else if (normalizedFont.includes(normalizedQuery)) { diff --git a/src/renderer/src/components/settings/settings-interface-secondary-section-renderers.tsx b/src/renderer/src/components/settings/settings-interface-secondary-section-renderers.tsx index 9d5046861df..3af28250340 100644 --- a/src/renderer/src/components/settings/settings-interface-secondary-section-renderers.tsx +++ b/src/renderer/src/components/settings/settings-interface-secondary-section-renderers.tsx @@ -41,13 +41,17 @@ export type ChatSettingsRenderContext = { model: Pick< SettingsRenderContext['model'], | 'settings' + | 'fontSuggestions' | 'updateSettings' | 'setHasUnsavedChatPromptChanges' | 'sourceControlAiPromptDiscardSignal' | 'hasUnsavedChatPromptChanges' | 'showDesktopOnlySettings' > - interactions: Pick + interactions: Pick< + SettingsRenderContext['interactions'], + 'writeSourceControlAiSettingsOrThrow' | 'requestFontSuggestions' + > navigation: Pick view: Pick } @@ -57,6 +61,8 @@ export function renderChatSettingsSection(context: ChatSettingsRenderContext): R return ( { it('normalizes writes and old stored values, and preserves unrelated settings', () => { const base = getDefaultSettings('/tmp') const saved = mergeSettings(base, { - nativeChatAppearance: { fontSize: 99, codeFontSize: 1, width: 'wide' } + nativeChatAppearance: { + fontFamily: ' Georgia ', + fontSize: 99, + codeFontSize: 1, + width: 'wide' + } + }) + expect(saved.nativeChatAppearance).toEqual({ + fontFamily: 'Georgia', + fontSize: 20, + codeFontSize: 10, + width: 'wide' }) - expect(saved.nativeChatAppearance).toEqual({ fontSize: 20, codeFontSize: 10, width: 'wide' }) expect(mergeSettings(saved, { theme: 'light' }).nativeChatAppearance).toEqual( saved.nativeChatAppearance ) diff --git a/src/shared/native-chat-appearance-settings.test.ts b/src/shared/native-chat-appearance-settings.test.ts index 5e26ccc6e5b..3d62b8c3d1f 100644 --- a/src/shared/native-chat-appearance-settings.test.ts +++ b/src/shared/native-chat-appearance-settings.test.ts @@ -47,6 +47,7 @@ describe('native chat appearance normalization', () => { }) ).toBeUndefined() expect(resolveNativeChatAppearanceSettings(undefined)).toEqual({ + fontFamily: '', fontSize: 14, codeFontSize: 12, width: 'comfortable', @@ -54,8 +55,9 @@ describe('native chat appearance normalization', () => { matchTerminalInterface: false }) }) - it('resets all five known controls while keeping settings from a newer version', () => { + it('resets known controls while keeping settings from a newer version', () => { const fromNewerVersion = { + fontFamily: 'Georgia', fontSize: 18, codeFontSize: 16, width: 'wide' as const, @@ -72,6 +74,7 @@ describe('native chat appearance normalization', () => { expect( resolveNativeChatAppearanceSettings({ fontSize: 99, codeFontSize: 0, width: 'full' }) ).toEqual({ + fontFamily: '', fontSize: 20, codeFontSize: 10, width: 'full', @@ -94,6 +97,17 @@ describe('native chat appearance normalization', () => { expect(normalizeNativeChatAppearanceSettings(value)).toBeUndefined() } }) + it('trims family names, preserves explicit Geist, and omits malformed overrides', () => { + expect(normalizeNativeChatAppearanceSettings({ fontFamily: ' Georgia ' })).toEqual({ + fontFamily: 'Georgia' + }) + expect(normalizeNativeChatAppearanceSettings({ fontFamily: 'Geist' })).toEqual({ + fontFamily: 'Geist' + }) + for (const fontFamily of ['', ' ', null, 42, ['Georgia']]) { + expect(normalizeNativeChatAppearanceSettings({ fontFamily })).toBeUndefined() + } + }) }) describe('chat contrast and terminal interface normalization', () => { diff --git a/src/shared/native-chat-appearance-settings.ts b/src/shared/native-chat-appearance-settings.ts index f8459756ffe..fca1f00ca54 100644 --- a/src/shared/native-chat-appearance-settings.ts +++ b/src/shared/native-chat-appearance-settings.ts @@ -1,6 +1,8 @@ import type { TuiAgent } from './tui-agent' export type NativeChatAppearanceSettings = { + /** Absent follows the IDE font. */ + fontFamily?: string /** Chat text size in px, 12–20; absent = 14. */ fontSize?: number /** Code text size in px, 10–18; absent = 12. */ @@ -37,6 +39,8 @@ export function normalizeNativeChatAppearanceSettings( if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined } + const fontFamily = + 'fontFamily' in value && typeof value.fontFamily === 'string' ? value.fontFamily.trim() : '' const fontSize = normalizeSize('fontSize' in value ? value.fontSize : undefined, 12, 20, 14) const codeFontSize = normalizeSize( 'codeFontSize' in value ? value.codeFontSize : undefined, @@ -53,9 +57,17 @@ export function normalizeNativeChatAppearanceSettings( ...Object.fromEntries( Object.entries(value).filter( ([key]) => - !['fontSize', 'codeFontSize', 'width', 'contrast', 'matchTerminalInterface'].includes(key) + ![ + 'fontFamily', + 'fontSize', + 'codeFontSize', + 'width', + 'contrast', + 'matchTerminalInterface' + ].includes(key) ) ), + ...(fontFamily ? { fontFamily } : {}), ...(fontSize !== DEFAULT_NATIVE_CHAT_FONT_SIZE ? { fontSize } : {}), ...(codeFontSize !== DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE ? { codeFontSize } : {}), ...(width ? { width } : {}), @@ -70,6 +82,7 @@ export function resolveNativeChatAppearanceSettings( ): Required { const normalized = normalizeNativeChatAppearanceSettings(value) return { + fontFamily: normalized?.fontFamily ?? '', fontSize: normalized?.fontSize ?? DEFAULT_NATIVE_CHAT_FONT_SIZE, codeFontSize: normalized?.codeFontSize ?? DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE, width: normalized?.width ?? 'comfortable', @@ -85,7 +98,14 @@ export function resetNativeChatAppearanceSettings( Object.fromEntries( Object.entries(appearance ?? {}).filter( ([key]) => - !['fontSize', 'codeFontSize', 'width', 'contrast', 'matchTerminalInterface'].includes(key) + ![ + 'fontFamily', + 'fontSize', + 'codeFontSize', + 'width', + 'contrast', + 'matchTerminalInterface' + ].includes(key) ) ) )