Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/ui/SettingsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down
144 changes: 144 additions & 0 deletions src/ui/formControls.ts
Original file line number Diff line number Diff line change
@@ -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)));
}
79 changes: 79 additions & 0 deletions tests/formControls.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
Loading