From bb137e134db1f4ee5740b13f8f592f5ef2e4a607 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:15:58 +0530 Subject: [PATCH] Add shared UI action model and derive panel help from it --- src/input/SyntaxPanel.ts | 4 +-- src/output/TranscriptPanel.ts | 4 +-- src/ui/CommandPalette.ts | 16 ++++++++-- src/ui/LayoutPanel.ts | 4 +-- src/ui/actions.ts | 45 ++++++++++++++++++++++++++++ tests/uiActions.test.ts | 56 +++++++++++++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 src/ui/actions.ts create mode 100644 tests/uiActions.test.ts diff --git a/src/input/SyntaxPanel.ts b/src/input/SyntaxPanel.ts index e66f3cb4..39a7879b 100644 --- a/src/input/SyntaxPanel.ts +++ b/src/input/SyntaxPanel.ts @@ -8,7 +8,7 @@ import { import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; import type {CommandType} from '../shell/SemanticService.js'; import type {Key} from '../terminal/keys.js'; -import {renderControls} from '../ui/controls.js'; +import {DRAFT_PANEL_ACTIONS, renderActionHelp} from '../ui/actions.js'; import {GLYPHS} from '../ui/glyphs.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.js'; @@ -127,7 +127,7 @@ export function renderSyntaxPanel(state: SyntaxPanelState, columns: number, prom const preview = ['', `${PRIMARY}Preview${RESET} ${syntaxDraftChanged(state) ? `${ACCENT}unsaved preview` : `${SUBTLE}matches current`}${RESET}`, ...SYNTAX_PREVIEW_LINES.map(line => ` ${ACCENT}${GLYPHS.prompt}${RESET} ${renderSyntaxPreviewLine(line, draft, promptPalette)}`)]; if (state.message) preview.push(`${SECONDARY}${state.message}${RESET}`); - const controls = ['', renderControls([['↑↓', 'move'], ['←→', 'change'], ['Enter', 'save'], ['Esc', 'cancel']])]; + const controls = ['', renderActionHelp(DRAFT_PANEL_ACTIONS)]; const includeGallery = out.length + gallery.length + preview.length + controls.length <= rowsAvailable; return [...out, ...(includeGallery ? gallery : []), ...preview, ...controls].map(row => truncateAnsi(row, columns)); diff --git a/src/output/TranscriptPanel.ts b/src/output/TranscriptPanel.ts index a1154eba..92c4b7db 100644 --- a/src/output/TranscriptPanel.ts +++ b/src/output/TranscriptPanel.ts @@ -5,7 +5,7 @@ import { } from '../prompt/configuration.js'; import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; import type {Key} from '../terminal/keys.js'; -import {renderControls} from '../ui/controls.js'; +import {DRAFT_PANEL_ACTIONS, renderActionHelp} from '../ui/actions.js'; import {GLYPHS} from '../ui/glyphs.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.js'; @@ -116,7 +116,7 @@ export function renderTranscriptPanel(state: TranscriptPanelState, columns: numb sampleRows.push(` ${SECONDARY}${GLYPHS.prompt} ${command}${RESET}`, ` ${SUBTLE}${output}${RESET}`); } if (state.message) sampleRows.push(`${SECONDARY}${state.message}${RESET}`); - const controls = ['', renderControls([['↑↓', 'move'], ['←→', 'change'], ['Enter', 'save'], ['Esc', 'cancel']])]; + const controls = ['', renderActionHelp(DRAFT_PANEL_ACTIONS)]; // Short terminals keep the editable rows, preview, and controls; the gallery goes first. const includeGallery = out.length + gallery.length + sampleRows.length + controls.length <= rowsAvailable; diff --git a/src/ui/CommandPalette.ts b/src/ui/CommandPalette.ts index 1aaf23fe..1d64afa1 100644 --- a/src/ui/CommandPalette.ts +++ b/src/ui/CommandPalette.ts @@ -3,7 +3,7 @@ import {NATIVE_PALETTE_IDS} from '../prompt/configuration.js'; import {NATIVE_PROMPT_THEMES} from '../prompt/prompt.js'; import {fuzzyMatch} from '../suggestions/NativeSuggestions.js'; import type {Key} from '../terminal/keys.js'; -import {renderControls} from './controls.js'; +import {renderActionHelp, resolveAction, type UiAction} from './actions.js'; import {foreground, UI_COLORS} from './palette.js'; import {SEARCH_MATCH, SETTINGS_ENTRIES, SETTINGS_ROWS, type SettingsDestination} from './SettingsPanel.js'; import {highlightMatches, truncateAnsi} from '../util/text.js'; @@ -109,10 +109,20 @@ export function filterPalette(state: PaletteState, recent: readonly string[] = [ return scored.sort((a, b) => b.score - a.score || recency(a.item) - recency(b.item)).map(entry => entry.item); } +/** Run needs a result to run; move needs more than nothing to move over. */ +export function paletteActions(resultCount: number): UiAction[] { + return [ + {id: 'search', label: 'search', keyLabel: 'type', kinds: []}, + {id: 'move', label: 'move', keyLabel: '↑↓', kinds: ['up', 'down'], enabled: resultCount > 1}, + {id: 'run', label: 'run', keyLabel: 'Enter', kinds: ['enter'], enabled: resultCount > 0}, + {id: 'close', label: 'close', keyLabel: 'Esc', kinds: ['escape']}, + ]; +} + /** Returns the chosen item on Enter; edits the query and selection otherwise. */ export function handlePaletteKey(key: Key, state: PaletteState, recent: readonly string[] = []): PaletteItem | 'changed' | undefined { const visible = filterPalette(state, recent); - if (key.kind === 'enter') return visible[Math.min(state.selectedIndex, visible.length - 1)]; + if (resolveAction(paletteActions(visible.length), key)?.id === 'run') return visible[Math.min(state.selectedIndex, visible.length - 1)]; if (key.kind === 'up') state.selectedIndex = visible.length ? (state.selectedIndex - 1 + visible.length) % visible.length : 0; else if (key.kind === 'down') state.selectedIndex = visible.length ? (state.selectedIndex + 1) % visible.length : 0; else if (key.kind === 'text' || key.kind === 'paste') { state.query += key.value.replace(/[\r\n]/gu, ''); state.selectedIndex = 0; state.viewportStart = 0; } @@ -142,6 +152,6 @@ export function renderPalette(state: PaletteState, columns: number, rowsAvailabl const label = state.query ? highlightMatches(item.label, state.query, active ? ACCENT : SECONDARY, SEARCH_MATCH) : item.label; rows.push(`${active ? `${ACCENT}›` : ' '} ${active ? ACCENT : SECONDARY}${label}${RESET} ${SUBTLE}${item.category} · ${item.detail}${RESET}`); }); - rows.push('', renderControls([['type', 'search'], ['↑↓', 'move'], ['Enter', 'run'], ['Esc', 'close']])); + rows.push('', renderActionHelp(paletteActions(visible.length))); return rows.map(row => truncateAnsi(row, columns)); } diff --git a/src/ui/LayoutPanel.ts b/src/ui/LayoutPanel.ts index a30fcd3c..80149814 100644 --- a/src/ui/LayoutPanel.ts +++ b/src/ui/LayoutPanel.ts @@ -11,7 +11,7 @@ import { import {completedActivity, liveActivityParts} from '../status/activity.js'; import type {Key} from '../terminal/keys.js'; import {truncateAnsi, repeatToWidth} from '../util/text.js'; -import {renderControls} from './controls.js'; +import {DRAFT_PANEL_ACTIONS, renderActionHelp} from './actions.js'; import {GLYPHS} from './glyphs.js'; import {foreground, UI_COLORS} from './palette.js'; @@ -159,7 +159,7 @@ export function renderLayoutPanel(state: LayoutPanelState, columns: number, rows const selected = index === state.selectedIndex; out.push(`${selected ? `${ACCENT}›` : ' '} ${selected ? ACCENT : SECONDARY}${labels[row]}${RESET}`); }); - const controls = ['', renderControls([['↑↓', 'move'], ['←→', 'change'], ['Enter', 'save'], ['Esc', 'cancel']])]; + const controls = ['', renderActionHelp(DRAFT_PANEL_ACTIONS)]; const header = ['', `${PRIMARY}Preview${RESET} ${layoutDraftChanged(state) ? `${ACCENT}unsaved preview` : `${SUBTLE}matches current`}${RESET} ${SUBTLE}sample content; nothing runs${RESET}`, ` ${SUBTLE}${POSITION_CAPTIONS[draft.composerPosition]}${RESET}`]; const message = state.message ? [`${SECONDARY}${state.message}${RESET}`] : []; diff --git a/src/ui/actions.ts b/src/ui/actions.ts new file mode 100644 index 00000000..b63957dd --- /dev/null +++ b/src/ui/actions.ts @@ -0,0 +1,45 @@ +import type {Key} from '../terminal/keys.js'; +import {renderControls} from './controls.js'; + +/** + * A panel action: one place that says what the action is called, which keys + * trigger it and whether it currently applies. Panel footers are derived from + * the actions that are enabled, so help cannot drift from the real bindings. + * Every action is keyboard-reachable; pointer input may only add shortcuts. + */ +export interface UiAction { + readonly id: string; + readonly label: string; + /** Key as shown in help, e.g. `↑↓`. */ + readonly keyLabel: string; + /** Key kinds (from the input parser) that trigger the action. Empty for display-only entries such as free typing. */ + readonly kinds: readonly Key['kind'][]; + /** Defaults to enabled; disabled actions are neither dispatched nor shown in help. */ + readonly enabled?: boolean; +} + +export function enabledActions(actions: readonly UiAction[]): UiAction[] { + return actions.filter(action => action.enabled !== false); +} + +/** The enabled action bound to this key, if any. */ +export function resolveAction(actions: readonly UiAction[], key: Key): UiAction | undefined { + return enabledActions(actions).find(action => action.kinds.includes(key.kind)); +} + +export function actionControls(actions: readonly UiAction[]): Array<[string, string]> { + return enabledActions(actions).map(action => [action.keyLabel, action.label]); +} + +/** Footer row for the actions currently available in a surface. */ +export function renderActionHelp(actions: readonly UiAction[]): string { + return renderControls(actionControls(actions)); +} + +/** Shared by the draft-and-save panels (layout, syntax, transcript): move between rows, change a value, save or cancel. */ +export const DRAFT_PANEL_ACTIONS: readonly UiAction[] = [ + {id: 'move', label: 'move', keyLabel: '↑↓', kinds: ['up', 'down']}, + {id: 'change', label: 'change', keyLabel: '←→', kinds: ['left', 'right']}, + {id: 'save', label: 'save', keyLabel: 'Enter', kinds: ['enter']}, + {id: 'cancel', label: 'cancel', keyLabel: 'Esc', kinds: ['escape']}, +]; diff --git a/tests/uiActions.test.ts b/tests/uiActions.test.ts new file mode 100644 index 00000000..49e6e7a8 --- /dev/null +++ b/tests/uiActions.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type {Key} from '../src/terminal/keys.js'; +import {actionControls, DRAFT_PANEL_ACTIONS, enabledActions, renderActionHelp, resolveAction, type UiAction} from '../src/ui/actions.js'; +import {createPalette, handlePaletteKey, paletteActions, renderPalette} from '../src/ui/CommandPalette.js'; +import {createLayoutPanel, renderLayoutPanel} from '../src/ui/LayoutPanel.js'; + +const strip = (text: string) => text.replace(/\u001B\[[0-9;]*m/gu, ''); +const key = (kind: string): Key => ({kind} as Key); + +test('disabled actions are neither resolved nor shown in help', () => { + const actions: UiAction[] = [ + {id: 'a', label: 'alpha', keyLabel: 'A', kinds: ['enter']}, + {id: 'b', label: 'beta', keyLabel: 'B', kinds: ['escape'], enabled: false}, + ]; + assert.deepEqual(enabledActions(actions).map(action => action.id), ['a']); + assert.equal(resolveAction(actions, key('enter'))?.id, 'a'); + assert.equal(resolveAction(actions, key('escape')), undefined); + assert.deepEqual(actionControls(actions), [['A', 'alpha']]); + assert.equal(strip(renderActionHelp(actions)), 'A alpha'); +}); + +test('draft panels keep complete keyboard operation with derived help', () => { + for (const kind of ['up', 'down', 'left', 'right', 'enter', 'escape']) { + assert.ok(resolveAction(DRAFT_PANEL_ACTIONS, key(kind)), `${kind} is bound`); + } + assert.equal(strip(renderActionHelp(DRAFT_PANEL_ACTIONS)), '↑↓ move · ←→ change · Enter save · Esc cancel'); +}); + +test('layout panel footer is generated from its actions, including at narrow widths', () => { + const state = createLayoutPanel({composerPosition: 'bottom', transcriptPresentation: 'flow'} as never); + const wide = renderLayoutPanel(state, 80, 30).map(strip); + assert.ok(wide.some(row => row.includes('↑↓ move · ←→ change · Enter save · Esc cancel'))); + for (const row of renderLayoutPanel(state, 24, 30).map(strip)) assert.ok([...row].length <= 24); +}); + +test('palette help reflects what is available', () => { + const state = createPalette([ + {id: 'one', label: 'One', category: 'c', detail: 'd'}, + {id: 'two', label: 'Two', category: 'c', detail: 'd'}, + ] as never); + const help = (rows: string[]) => strip(rows[rows.length - 1]!); + assert.match(help(renderPalette(state, 80, 20)), /↑↓ move · Enter run/u); + state.query = 'zzz-nothing'; + const empty = help(renderPalette(state, 80, 20)); + assert.doesNotMatch(empty, /Enter/u); + assert.doesNotMatch(empty, /↑↓/u); + assert.match(empty, /Esc close/u); + assert.equal(handlePaletteKey(key('enter'), state), undefined); + assert.deepEqual(enabledActions(paletteActions(1)).map(action => action.id), ['search', 'run', 'close']); +}); + +test('palette Enter still chooses the selected item', () => { + const state = createPalette([{id: 'one', label: 'One', category: 'c', detail: 'd'}] as never); + assert.equal((handlePaletteKey(key('enter'), state) as {id: string}).id, 'one'); +});