mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Add custom font family setting for native chat (#33687)
* Add custom font family setting for native chat * Preserve custom chat fonts in autocomplete * Preserve IME composition in font autocomplete
This commit is contained in:
@@ -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'
|
||||
|
||||
+251
@@ -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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-index="5"]')
|
||||
const nextRow = container.querySelector<HTMLElement>('[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()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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'))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<HTMLDivElement>(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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
<AppearanceChatSection
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
fontSuggestions={['Geist', 'Georgia']}
|
||||
/>
|
||||
)
|
||||
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', () => {
|
||||
<AppearanceChatSection settings={settings} updateSettings={updateSettings} />,
|
||||
{ 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', () => {
|
||||
<AppearanceChatSection settings={mocks.state.settings!} updateSettings={updateSettings} />
|
||||
)
|
||||
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']
|
||||
|
||||
@@ -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<GlobalSettings>) => 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}
|
||||
/>
|
||||
<ChatFontFamilySetting
|
||||
settings={settings}
|
||||
onChange={(fontFamily) => update({ fontFamily })}
|
||||
fontSuggestions={fontSuggestions}
|
||||
onRequestFontSuggestions={onRequestFontSuggestions}
|
||||
forceVisible={forceVisiblePrimary}
|
||||
/>
|
||||
<SearchableSetting
|
||||
id={entries.textSize.targetSectionId}
|
||||
{...entries.textSize}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { resolveNativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
NATIVE_CHAT_APPEARANCE_ROOT_CLASS,
|
||||
useNativeChatAppearanceStyle
|
||||
} from '../native-chat/native-chat-appearance-style'
|
||||
import { FontAutocomplete, SettingsRow } from './SettingsFormControls'
|
||||
import { SettingsDisabledControlTooltip } from './SettingsDisabledControlTooltip'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import {
|
||||
chatTerminalControlledHint,
|
||||
chatTerminalControlledTooltip,
|
||||
getChatAppearanceEntriesByKey
|
||||
} from './chat-appearance-search'
|
||||
|
||||
const EMPTY_FONT_SUGGESTIONS: string[] = []
|
||||
|
||||
export function ChatFontFamilySetting({
|
||||
settings,
|
||||
onChange,
|
||||
fontSuggestions = EMPTY_FONT_SUGGESTIONS,
|
||||
onRequestFontSuggestions,
|
||||
forceVisible
|
||||
}: {
|
||||
settings: GlobalSettings
|
||||
onChange: (fontFamily: string) => void
|
||||
fontSuggestions?: string[]
|
||||
onRequestFontSuggestions?: () => void
|
||||
forceVisible?: boolean
|
||||
}): React.JSX.Element {
|
||||
const appearance = resolveNativeChatAppearanceSettings(settings.nativeChatAppearance)
|
||||
const matching = appearance.matchTerminalInterface
|
||||
const [previewFamily, setPreviewFamily] = useState<string | null>(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 (
|
||||
<SearchableSetting id={entry.targetSectionId} {...entry} forceVisible={forceVisible}>
|
||||
<SettingsRow
|
||||
label={entry.title}
|
||||
description={matching ? chatTerminalControlledHint() : entry.description}
|
||||
control={
|
||||
<SettingsDisabledControlTooltip
|
||||
reason={matching ? chatTerminalControlledTooltip() : undefined}
|
||||
>
|
||||
<FontAutocomplete
|
||||
value={appearance.fontFamily}
|
||||
ariaLabel={entry.title}
|
||||
suggestions={fontSuggestions}
|
||||
placeholder={defaultLabel}
|
||||
defaultOptionLabel={defaultLabel}
|
||||
disabled={matching}
|
||||
commitMode="blur"
|
||||
onChange={onChange}
|
||||
onRequestSuggestions={onRequestFontSuggestions}
|
||||
onPreviewFontFamily={setPreviewFamily}
|
||||
/>
|
||||
</SettingsDisabledControlTooltip>
|
||||
}
|
||||
/>
|
||||
<p
|
||||
aria-label={translate('settings.appearance.chat.fontPreviewLabel', 'Chat font preview')}
|
||||
className={`${NATIVE_CHAT_APPEARANCE_ROOT_CLASS} mb-3 rounded-md border border-border px-3 py-2 text-sm text-chat-foreground`}
|
||||
style={style}
|
||||
>
|
||||
{translate(
|
||||
'settings.appearance.chat.fontPreview',
|
||||
'The quick brown fox jumps over the lazy dog.'
|
||||
)}{' '}
|
||||
<code className="font-mono text-chat-code">fontFamily</code>
|
||||
</p>
|
||||
</SearchableSetting>
|
||||
)
|
||||
}
|
||||
@@ -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<GlobalSettings>) => void | Promise<void>
|
||||
fontSuggestions?: string[]
|
||||
onRequestFontSuggestions?: () => void
|
||||
writeSourceControlAiSettings: (patch: SourceControlAiSettingsPatch) => Promise<void>
|
||||
onChatPromptDirtyChange?: (dirty: boolean) => void
|
||||
chatPromptDiscardSignal?: number
|
||||
@@ -65,6 +69,8 @@ export function ChatSettingsSection({
|
||||
<AppearanceChatSection
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
fontSuggestions={fontSuggestions}
|
||||
onRequestFontSuggestions={onRequestFontSuggestions}
|
||||
forceVisiblePrimary={matchesSettingsSearch(query, [
|
||||
{ title },
|
||||
{ title: appearanceTitle }
|
||||
|
||||
@@ -12,6 +12,10 @@ type FontAutocompleteProps = {
|
||||
suggestions: string[]
|
||||
onChange: (value: string) => 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<HTMLInputElement | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement | null>(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 (
|
||||
<div ref={setRootNode} className="relative max-w-sm">
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover open={open && !disabled} onOpenChange={handleOpenChange}>
|
||||
<PopoverAnchor asChild>
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
onChange={(e) => {
|
||||
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 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setIsFilteringQuery(false)
|
||||
onChange('')
|
||||
commitValue('')
|
||||
setOpen(true)
|
||||
focusInput()
|
||||
}}
|
||||
@@ -231,6 +288,7 @@ export function FontAutocomplete({
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
const nextOpen = !open
|
||||
@@ -300,7 +358,7 @@ export function FontAutocomplete({
|
||||
: 'hover:bg-muted/60'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{font}</span>
|
||||
<span className="truncate">{font || defaultOptionLabel}</span>
|
||||
{font === value ? <Check className="ml-3 size-4 shrink-0" /> : null}
|
||||
</button>
|
||||
))
|
||||
|
||||
@@ -70,6 +70,20 @@ describe('FontAutocomplete', () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function pressInputKey(
|
||||
input: HTMLInputElement,
|
||||
key: string,
|
||||
options?: { isComposing?: boolean }
|
||||
): Promise<void> {
|
||||
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(
|
||||
<FontAutocomplete
|
||||
value="Geist"
|
||||
suggestions={['Geist', 'Georgia']}
|
||||
onChange={change}
|
||||
onPreviewFontFamily={preview}
|
||||
commitMode="blur"
|
||||
/>
|
||||
)
|
||||
})
|
||||
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(
|
||||
<FontAutocomplete
|
||||
value="Geist"
|
||||
suggestions={['Geist', 'Georgia']}
|
||||
onChange={change}
|
||||
commitMode="blur"
|
||||
/>
|
||||
)
|
||||
})
|
||||
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 (
|
||||
<FontAutocomplete
|
||||
value={value}
|
||||
suggestions={['Geist', 'Georgia']}
|
||||
defaultOptionLabel="Same as IDE font"
|
||||
commitMode="blur"
|
||||
onChange={(font) => {
|
||||
change(font)
|
||||
setValue(font)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
await act(async () => root.render(<Harness />))
|
||||
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<HTMLButtonElement>('[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(
|
||||
<FontAutocomplete
|
||||
value="Georgia"
|
||||
suggestions={suggestions}
|
||||
onChange={change}
|
||||
onPreviewFontFamily={preview}
|
||||
defaultOptionLabel="Same as IDE font"
|
||||
commitMode="blur"
|
||||
/>
|
||||
)
|
||||
})
|
||||
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(
|
||||
<FontAutocomplete
|
||||
value="Georgia"
|
||||
suggestions={['Geist', 'Georgia']}
|
||||
onChange={change}
|
||||
defaultOptionLabel={label}
|
||||
commitMode="blur"
|
||||
/>
|
||||
)
|
||||
})
|
||||
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(
|
||||
<FontAutocomplete
|
||||
value="Georgia"
|
||||
suggestions={['Geist', 'Georgia']}
|
||||
onChange={change}
|
||||
defaultOptionLabel="Same as IDE font"
|
||||
commitMode="blur"
|
||||
/>
|
||||
)
|
||||
})
|
||||
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) => (
|
||||
<FontAutocomplete
|
||||
value="Geist"
|
||||
suggestions={['Geist', 'Georgia']}
|
||||
onChange={change}
|
||||
disabled={disabled}
|
||||
commitMode="blur"
|
||||
/>
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
+7
-1
@@ -41,13 +41,17 @@ export type ChatSettingsRenderContext = {
|
||||
model: Pick<
|
||||
SettingsRenderContext['model'],
|
||||
| 'settings'
|
||||
| 'fontSuggestions'
|
||||
| 'updateSettings'
|
||||
| 'setHasUnsavedChatPromptChanges'
|
||||
| 'sourceControlAiPromptDiscardSignal'
|
||||
| 'hasUnsavedChatPromptChanges'
|
||||
| 'showDesktopOnlySettings'
|
||||
>
|
||||
interactions: Pick<SettingsRenderContext['interactions'], 'writeSourceControlAiSettingsOrThrow'>
|
||||
interactions: Pick<
|
||||
SettingsRenderContext['interactions'],
|
||||
'writeSourceControlAiSettingsOrThrow' | 'requestFontSuggestions'
|
||||
>
|
||||
navigation: Pick<SettingsRenderContext['navigation'], 'getSectionSearchEntries'>
|
||||
view: Pick<SettingsRenderContext['view'], 'isSectionMounted'>
|
||||
}
|
||||
@@ -57,6 +61,8 @@ export function renderChatSettingsSection(context: ChatSettingsRenderContext): R
|
||||
return (
|
||||
<ChatSettingsSection
|
||||
settings={model.settings}
|
||||
fontSuggestions={model.fontSuggestions}
|
||||
onRequestFontSuggestions={interactions.requestFontSuggestions}
|
||||
updateSettings={model.updateSettings}
|
||||
writeSourceControlAiSettings={interactions.writeSourceControlAiSettingsOrThrow}
|
||||
onChatPromptDirtyChange={model.setHasUnsavedChatPromptChanges}
|
||||
|
||||
@@ -360,6 +360,11 @@
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fontFamily": "Font family",
|
||||
"fontFamilyDescription": "Font used for messages, activity, and the message box.",
|
||||
"inheritFont": "Same as IDE font",
|
||||
"fontPreviewLabel": "Chat font preview",
|
||||
"fontPreview": "The quick brown fox jumps over the lazy dog.",
|
||||
"title": "Chat",
|
||||
"textSize": "Text size",
|
||||
"textSizeDescription": "Messages, tool activity and the message box. {{increase}} / {{decrease}} in a chat change this too.",
|
||||
|
||||
@@ -133,6 +133,11 @@
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fontFamily": "Fuente",
|
||||
"fontFamilyDescription": "Fuente de los mensajes, la actividad y el cuadro de mensaje.",
|
||||
"inheritFont": "Misma fuente que el IDE",
|
||||
"fontPreviewLabel": "Vista previa de la fuente del chat",
|
||||
"fontPreview": "El veloz zorro marrón salta sobre el perro perezoso.",
|
||||
"title": "Chat",
|
||||
"textSize": "Tamaño del texto",
|
||||
"textSizeDescription": "Mensajes, actividad de herramientas y cuadro de mensaje. {{increase}} / {{decrease}} en un chat también cambian este valor.",
|
||||
|
||||
@@ -273,6 +273,11 @@
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fontFamily": "Police",
|
||||
"fontFamilyDescription": "Police des messages, de l’activité et de la zone de saisie.",
|
||||
"inheritFont": "Même police que l’IDE",
|
||||
"fontPreviewLabel": "Aperçu de la police du chat",
|
||||
"fontPreview": "Le vif renard brun saute par-dessus le chien paresseux.",
|
||||
"title": "Chat",
|
||||
"textSize": "Taille du texte",
|
||||
"textSizeDescription": "Messages, activité des outils et zone de message. {{increase}} / {{decrease}} dans un chat modifient aussi ce réglage.",
|
||||
|
||||
@@ -133,6 +133,11 @@
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fontFamily": "フォント",
|
||||
"fontFamilyDescription": "メッセージ、アクティビティ、入力欄に使用するフォント。",
|
||||
"inheritFont": "IDE と同じフォント",
|
||||
"fontPreviewLabel": "チャットフォントのプレビュー",
|
||||
"fontPreview": "この文章でチャットのフォントを確認できます。",
|
||||
"title": "チャット",
|
||||
"textSize": "テキストサイズ",
|
||||
"textSizeDescription": "メッセージ、ツールの動作、メッセージ入力欄。チャット内の {{increase}} / {{decrease}} でも変更できます。",
|
||||
|
||||
@@ -133,6 +133,11 @@
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fontFamily": "글꼴",
|
||||
"fontFamilyDescription": "메시지, 활동 및 메시지 입력란에 사용하는 글꼴입니다.",
|
||||
"inheritFont": "IDE와 같은 글꼴",
|
||||
"fontPreviewLabel": "채팅 글꼴 미리보기",
|
||||
"fontPreview": "이 문장에서 채팅 글꼴을 미리 볼 수 있습니다.",
|
||||
"title": "채팅",
|
||||
"textSize": "텍스트 크기",
|
||||
"textSizeDescription": "메시지, 도구 활동 및 메시지 입력란. 채팅에서 {{increase}} / {{decrease}} 키로도 변경할 수 있습니다.",
|
||||
|
||||
@@ -672,6 +672,11 @@
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fontFamily": "Fonte",
|
||||
"fontFamilyDescription": "Fonte usada nas mensagens, na atividade e na caixa de mensagem.",
|
||||
"inheritFont": "Mesma fonte do IDE",
|
||||
"fontPreviewLabel": "Prévia da fonte do chat",
|
||||
"fontPreview": "A rápida raposa marrom salta sobre o cão preguiçoso.",
|
||||
"title": "Chat",
|
||||
"textSize": "Tamanho do texto",
|
||||
"textSizeDescription": "Mensagens, atividade das ferramentas e caixa de mensagem. {{increase}} / {{decrease}} em um chat também alteram este tamanho.",
|
||||
|
||||
@@ -133,6 +133,11 @@
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"fontFamily": "字体",
|
||||
"fontFamilyDescription": "用于消息、活动和消息输入框的字体。",
|
||||
"inheritFont": "与 IDE 字体相同",
|
||||
"fontPreviewLabel": "聊天字体预览",
|
||||
"fontPreview": "这段文字用于预览聊天字体。",
|
||||
"title": "聊天",
|
||||
"textSize": "文字大小",
|
||||
"textSizeDescription": "消息、工具活动和消息输入框。在聊天中按 {{increase}} / {{decrease}} 也会更改此设置。",
|
||||
|
||||
@@ -6,9 +6,19 @@ describe('browser-local chat appearance settings', () => {
|
||||
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
|
||||
)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<NativeChatAppearanceSettings> {
|
||||
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)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user