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
4 changes: 2 additions & 2 deletions src/input/SyntaxPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
Expand Down
4 changes: 2 additions & 2 deletions src/output/TranscriptPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 13 additions & 3 deletions src/ui/CommandPalette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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));
}
4 changes: 2 additions & 2 deletions src/ui/LayoutPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}`] : [];
Expand Down
45 changes: 45 additions & 0 deletions src/ui/actions.ts
Original file line number Diff line number Diff line change
@@ -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']},
];
56 changes: 56 additions & 0 deletions tests/uiActions.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
Loading