From 05dfb7b0374250faca4110f0678cb1d93de36475 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:18:06 +0530 Subject: [PATCH] Add shared form controls and use them in Settings --- src/app/TerminalApp.ts | 9 ++- src/ui/SettingsPanel.ts | 5 +- src/ui/formControls.ts | 144 +++++++++++++++++++++++++++++++++++++ tests/formControls.test.ts | 79 ++++++++++++++++++++ 4 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 src/ui/formControls.ts create mode 100644 tests/formControls.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index c2d73123..44e0bfff 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -52,6 +52,7 @@ import {parseSlashCommand, slashCommands, slashSuggestions, suggestionWindow} fr import {copyFeedback, copyStats, writeClipboard} from '../clipboard/clipboard.js'; import {shouldPassthrough} from '../passthrough/PassthroughPolicy.js'; import {layoutInput, graphemes} from '../input/inputLayout.js'; +import {editText} from '../ui/formControls.js'; import {shimmerText} from '../status/shimmer.js'; import {isDeterministicPresentation, presentationAnimationElapsed, presentationCompletionTime, presentationNow} from '../presentation/environment.js'; import {TaskProgress} from '../status/TaskProgress.js'; @@ -1984,11 +1985,9 @@ export class TerminalApp { } const view = settingsView(state); const row = selectedSettingsRow(state); - if (state.searchFocused && key.kind === 'text') { - state.searchQuery = (state.searchQuery ?? '') + key.value; - state.contentIndex = 0; - } else if (state.searchFocused && key.kind === 'backspace') { - state.searchQuery = (state.searchQuery ?? '').slice(0, -1); + const editedSearch = state.searchFocused ? editText(state.searchQuery ?? '', key) : undefined; + if (editedSearch !== undefined) { + state.searchQuery = editedSearch; state.contentIndex = 0; } else if (key.kind === 'text' && key.value === '/' && view === 'config') { state.searchFocused = true; diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 7013209c..41049518 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -17,6 +17,7 @@ import {SUGGESTION_PROVIDERS} from '../suggestions/types.js'; import {foreground, UI_COLORS} from './palette.js'; import {GLYPHS, getCurrentGlyphMode} from './glyphs.js'; import {framePanel, renderTabStrip} from './PanelShell.js'; +import {stepIndex, toggleValue} from './formControls.js'; import {displayWidth, highlightMatches, truncateAnsi} from '../util/text.js'; /** The three top-level views of the one shared panel behind /settings, /config, and /status. */ @@ -185,9 +186,9 @@ export function isInlineEditable(row: SettingsRow | undefined): boolean { /** ←/→ on an enum or boolean row; undefined when the row has nothing to change inline. */ export function adjustSettingsRow(row: SettingsRow, config: PromptConfiguration, delta: -1 | 1): PromptConfiguration | undefined { - if (row.control === 'boolean') return row.set(config, !row.get(config)); + if (row.control === 'boolean') return row.set(config, toggleValue(row.get(config))); if (row.control !== 'enum') return undefined; - return row.select(config, (row.index(config) + delta + row.options.length) % row.options.length); + return row.select(config, stepIndex(row.options.length, row.index(config), delta)); } /** Enter/Space: toggle a boolean or step an enum forward. */ diff --git a/src/ui/formControls.ts b/src/ui/formControls.ts new file mode 100644 index 00000000..794eeb45 --- /dev/null +++ b/src/ui/formControls.ts @@ -0,0 +1,144 @@ +import type {Key} from '../terminal/keys.js'; +import {foreground, UI_COLORS} from './palette.js'; +import {displayWidth, truncateAnsi} from '../util/text.js'; + +/** + * Terminal-native form controls. A control never stores or writes anything: + * key handlers return the value the user is proposing (or undefined when the + * key means nothing here) and the owning feature decides whether to persist it. + * Rendering has a plain path (`color: false`) where focus, changed state and + * errors are carried by text, not by color. + */ + +// ---- Behavior ------------------------------------------------------------- + +export function toggleValue(current: boolean): boolean { + return !current; +} + +/** Index after moving `delta` steps through `count` options, wrapping at both ends. */ +export function stepIndex(count: number, index: number, delta: number): number { + if (count <= 0) return 0; + return (((index + delta) % count) + count) % count; +} + +/** ←/→ (and Space/Enter as "next") on a select: the proposed option index. */ +export function handleSelectKey(key: Key, count: number, index: number): number | undefined { + if (key.kind === 'left') return stepIndex(count, index, -1); + if (key.kind === 'right' || key.kind === 'enter' || (key.kind === 'text' && key.value === ' ')) return stepIndex(count, index, 1); + return undefined; +} + +/** Multi-select proposal: `selected` with `index` flipped, kept in option order. */ +export function toggleMember(selected: readonly number[], index: number): number[] { + return selected.includes(index) ? selected.filter(value => value !== index) : [...selected, index].sort((a, b) => a - b); +} + +export interface MultiSelectState {cursor: number; selected: readonly number[]} + +/** ↑↓ move the cursor; Space flips the option under it. Returns the next state, or undefined for other keys. */ +export function handleMultiSelectKey(key: Key, count: number, state: MultiSelectState): MultiSelectState | undefined { + if (key.kind === 'up') return {...state, cursor: stepIndex(count, state.cursor, -1)}; + if (key.kind === 'down') return {...state, cursor: stepIndex(count, state.cursor, 1)}; + if (key.kind === 'text' && key.value === ' ') return {...state, selected: toggleMember(state.selected, state.cursor)}; + return undefined; +} + +/** Single-line text editing at the end of the value; the proposed text, or undefined for other keys. */ +export function editText(value: string, key: Key): string | undefined { + if (key.kind === 'text' || key.kind === 'paste') return value + key.value.replace(/[\r\n]/gu, ''); + if (key.kind === 'backspace') return [...value].slice(0, -1).join(''); + if (key.kind === 'deleteWord' || key.kind === 'deleteLineBefore') return ''; + return undefined; +} + +export interface ConfirmState {choice: 'yes' | 'no'} + +/** Confirmation starts on "no"; Enter accepts the highlighted choice, Esc always cancels. */ +export function createConfirm(): ConfirmState { + return {choice: 'no'}; +} + +export function handleConfirmKey(key: Key, state: ConfirmState): 'confirm' | 'cancel' | 'changed' | undefined { + if (key.kind === 'escape') return 'cancel'; + if (key.kind === 'enter') return state.choice === 'yes' ? 'confirm' : 'cancel'; + if (key.kind === 'left' || key.kind === 'right' || key.kind === 'up' || key.kind === 'down') { + state.choice = state.choice === 'yes' ? 'no' : 'yes'; + return 'changed'; + } + if (key.kind === 'text') { + const value = key.value.toLowerCase(); + if (value === 'y') { state.choice = 'yes'; return 'confirm'; } + if (value === 'n') { state.choice = 'no'; return 'cancel'; } + } + return undefined; +} + +// ---- Presentation --------------------------------------------------------- + +export interface ControlLook { + focused?: boolean; + /** False for a plain path with no escape sequences. */ + color?: boolean; +} + +const ACCENT = foreground(UI_COLORS.accent); +const SECONDARY = foreground(UI_COLORS.secondary); +const SUBTLE = foreground(UI_COLORS.subtle); +const ERROR = foreground(UI_COLORS.failure); +const INVERSE = '\u001B[7m'; +const RESET = '\u001B[0m'; + +function tint(text: string, look: ControlLook): string { + return look.color === false ? text : `${look.focused ? ACCENT : SECONDARY}${text}${RESET}`; +} + +export function renderToggle(value: boolean, look: ControlLook = {}): string { + return tint(`[${value ? 'x' : ' '}] ${value ? 'On' : 'Off'}`, look); +} + +export function renderSelect(optionLabel: string, look: ControlLook = {}): string { + return tint(`‹ ${optionLabel} ›`, look); +} + +export function renderMultiSelect(options: readonly string[], selected: readonly number[], cursor: number | undefined, look: ControlLook = {}): string { + return options.map((option, index) => { + const marked = `${cursor === index && look.focused ? '>' : ' '}[${selected.includes(index) ? 'x' : ' '}] ${option}`; + return tint(marked, {...look, focused: look.focused && cursor === index}); + }).join(' '); +} + +export function renderTextValue(value: string, placeholder: string, look: ControlLook = {}): string { + const cursor = look.focused ? (look.color === false ? '_' : `${INVERSE} ${RESET}`) : ''; + if (!value) return look.color === false ? `${cursor}${placeholder}` : `${cursor}${SUBTLE}${placeholder}${RESET}`; + return look.color === false ? `${value}${cursor}` : `${tint(value, look)}${cursor}`; +} + +export function renderConfirm(state: ConfirmState, look: ControlLook = {}): string { + const choice = (id: 'yes' | 'no', label: string) => (state.choice === id ? `[ ${label} ]` : ` ${label} `); + return tint(`${choice('yes', 'Yes')} ${choice('no', 'No')}`, look); +} + +export interface FieldFrame { + label: string; + /** Already rendered by one of the render* functions above. */ + control: string; + description?: string; + /** The proposal differs from the saved value. */ + changed?: boolean; + /** Set when the proposal is invalid; shown as text, not only color. */ + error?: string; + focused?: boolean; + color?: boolean; +} + +/** Label row, then description and error rows when present. Focus is a `>` marker; changed is `(changed)`. */ +export function renderField(field: FieldFrame, columns: number): string[] { + const plain = field.color === false; + const pointer = field.focused ? (plain ? '>' : `${ACCENT}›${RESET}`) : ' '; + const changed = field.changed ? (plain ? ' (changed)' : ` ${SUBTLE}(changed)${RESET}`) : ''; + const rows = [`${pointer} ${field.label} ${field.control}${changed}`]; + if (field.description) rows.push(plain ? ` ${field.description}` : ` ${SUBTLE}${field.description}${RESET}`); + if (field.error) rows.push(plain ? ` Error: ${field.error}` : ` ${ERROR}Error: ${field.error}${RESET}`); + return rows.map(row => (plain && displayWidth(row) > columns ? [...row].slice(0, columns).join('') : truncateAnsi(row, columns))); +} diff --git a/tests/formControls.test.ts b/tests/formControls.test.ts new file mode 100644 index 00000000..e2719e1e --- /dev/null +++ b/tests/formControls.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type {Key} from '../src/terminal/keys.js'; +import { + createConfirm, editText, handleConfirmKey, handleMultiSelectKey, handleSelectKey, renderConfirm, renderField, renderMultiSelect, + renderSelect, renderTextValue, renderToggle, stepIndex, toggleMember, toggleValue, +} from '../src/ui/formControls.js'; +import {adjustSettingsRow, SETTINGS_ROWS} from '../src/ui/SettingsPanel.js'; +import {DEFAULT_PROMPT_CONFIGURATION} from '../src/prompt/configuration.js'; + +const key = (kind: string, value?: string): Key => ({kind, value} as Key); +const text = (value: string) => key('text', value); +const hasEscape = (value: string) => value.includes('\u001B'); + +test('toggle and select report proposals without owning state', () => { + assert.equal(toggleValue(false), true); + assert.equal(stepIndex(3, 0, -1), 2); + assert.equal(stepIndex(3, 2, 1), 0); + assert.equal(stepIndex(0, 0, 1), 0); + assert.equal(handleSelectKey(key('left'), 3, 0), 2); + assert.equal(handleSelectKey(text(' '), 3, 0), 1); + assert.equal(handleSelectKey(key('up'), 3, 0), undefined); +}); + +test('multi-select keeps option order and moves with the keyboard only', () => { + assert.deepEqual(toggleMember([2], 0), [0, 2]); + assert.deepEqual(toggleMember([0, 2], 2), [0]); + let state = {cursor: 0, selected: [] as readonly number[]}; + state = handleMultiSelectKey(key('down'), 3, state)!; + state = handleMultiSelectKey(text(' '), 3, state)!; + assert.deepEqual(state, {cursor: 1, selected: [1]}); + assert.equal(handleMultiSelectKey(key('enter'), 3, state), undefined); +}); + +test('text field edits, pastes and clears', () => { + assert.equal(editText('ab', text('c')), 'abc'); + assert.equal(editText('ab', key('paste', 'x\ny')), 'abxy'); + assert.equal(editText('ab', key('backspace')), 'a'); + assert.equal(editText('ab', key('deleteWord')), ''); + assert.equal(editText('ab', key('up')), undefined); +}); + +test('confirmation defaults to no and Esc always cancels', () => { + const state = createConfirm(); + assert.equal(handleConfirmKey(key('enter'), state), 'cancel'); + assert.equal(handleConfirmKey(key('right'), state), 'changed'); + assert.equal(state.choice, 'yes'); + assert.equal(handleConfirmKey(key('enter'), state), 'confirm'); + assert.equal(handleConfirmKey(key('escape'), state), 'cancel'); + assert.equal(handleConfirmKey(text('y'), createConfirm()), 'confirm'); +}); + +test('plain rendering carries focus, changed state and errors in text', () => { + const plain = {color: false, focused: true}; + for (const rendered of [renderToggle(true, plain), renderSelect('Daily', plain), renderMultiSelect(['a', 'b'], [1], 0, plain), + renderTextValue('hi', 'Search', plain), renderTextValue('', 'Search', plain), renderConfirm(createConfirm(), plain)]) { + assert.equal(hasEscape(rendered), false, rendered); + } + assert.equal(renderToggle(false, plain), '[ ] Off'); + const rows = renderField({label: 'Name', control: renderTextValue('x', '', plain), description: 'Shown in the prompt', + changed: true, error: 'too short', focused: true, color: false}, 60); + assert.deepEqual(rows, ['> Name x_ (changed)', ' Shown in the prompt', ' Error: too short']); +}); + +test('styled rendering stays within narrow widths', () => { + const rows = renderField({label: 'A long setting label', control: renderSelect('A long option value'), description: 'x'.repeat(80), focused: true}, 20); + for (const row of rows) assert.ok([...row.replace(/\u001B\[[0-9;]*m/gu, '')].length <= 20); +}); + +test('settings rows use the shared toggle and select behavior', () => { + const toggle = SETTINGS_ROWS.find(row => row.control === 'boolean')!; + const before = toggle.control === 'boolean' && toggle.get(DEFAULT_PROMPT_CONFIGURATION); + const flipped = adjustSettingsRow(toggle, DEFAULT_PROMPT_CONFIGURATION, 1)!; + assert.equal(toggle.control === 'boolean' && toggle.get(flipped), !before); + const select = SETTINGS_ROWS.find(row => row.control === 'enum')!; + if (select.control !== 'enum') throw new Error('unreachable'); + const back = adjustSettingsRow(select, DEFAULT_PROMPT_CONFIGURATION, -1)!; + assert.equal(select.index(back), stepIndex(select.options.length, select.index(DEFAULT_PROMPT_CONFIGURATION), -1)); +});