feat(cli): standardize tree picker UX after clack autocomplete-multiselect

Search is always on (no '/' to enter): typed printable chars feed the
query, Tab toggles selection on the focused node without leaving the
search bar, and Space toggles only after arrow-key navigation
(isNavigating); otherwise it is appended to the query. Esc clears a
non-empty query before quitting, Ctrl+A and Ctrl+N replace bare-letter
bulk bindings, and the cursor refocuses on the first match when the
query change would hide it.
This commit is contained in:
Andrey Avtomonov 2026-05-24 19:11:21 +02:00
parent 556d9eff5a
commit 200041b178
4 changed files with 155 additions and 79 deletions

View file

@ -191,7 +191,7 @@ describe('search and cursor movement', () => {
}); });
const searching = { const searching = {
...state, ...state,
search: { editing: false, query: 'architecture' }, search: { query: 'architecture' },
}; };
expect(filterTree(searching)).toEqual({ expect(filterTree(searching)).toEqual({
@ -229,7 +229,7 @@ describe('bulk actions and reducer effects', () => {
}); });
const searching = { const searching = {
...state, ...state,
search: { editing: false, query: 'architecture' }, search: { query: 'architecture' },
}; };
const selected = selectAllVisible(searching); const selected = selectAllVisible(searching);
@ -306,12 +306,11 @@ describe('bulk actions and reducer effects', () => {
next: { ...state, pendingConfirm: null }, next: { ...state, pendingConfirm: null },
effect: null, effect: null,
}); });
expect(reducer(state, { type: 'search-input', value: 'a' }).next.search).toEqual({ query: 'a' });
expect(reducer({ ...state, isNavigating: true }, { type: 'search-input', value: 'b' }).next.isNavigating).toBe(false);
expect( expect(
reducer( reducer({ ...state, search: { query: 'foo' }, isNavigating: true }, 'search-clear').next,
reducer(reducer(state, 'search-start').next, { type: 'search-input', value: 'a' }).next, ).toEqual({ ...state, search: { query: '' }, isNavigating: false });
'search-submit',
).next.search,
).toEqual({ editing: false, query: 'a' });
expect(reducer(state, 'quit')).toEqual({ expect(reducer(state, 'quit')).toEqual({
next: state, next: state,
effect: 'quit-without-save', effect: 'quit-without-save',
@ -336,6 +335,34 @@ describe('bulk actions and reducer effects', () => {
}); });
}); });
it('navigates cursor commands set isNavigating, typed input clears it, and search refocuses cursor', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingSelectedIds: [],
});
expect(state.isNavigating).toBe(false);
const afterDown = reducer(state, 'cursor-down').next;
expect(afterDown.isNavigating).toBe(true);
const afterType = reducer(afterDown, { type: 'search-input', value: 'a' }).next;
expect(afterType.isNavigating).toBe(false);
expect(afterType.search.query).toBe('a');
const afterBackspace = reducer({ ...afterDown, search: { query: 'foo' } }, 'search-backspace').next;
expect(afterBackspace.search.query).toBe('fo');
expect(afterBackspace.isNavigating).toBe(false);
const withCursorOnHidden = {
...state,
cursorId: IDS.journal,
search: { query: 'arch' },
};
const refocused = reducer(withCursorOnHidden, { type: 'search-input', value: 'i' }).next;
expect(refocused.search.query).toBe('archi');
expect(visibleNodeIds(refocused)).toContain(refocused.cursorId);
});
it('clears transient hints only when their expiry time has passed', () => { it('clears transient hints only when their expiry time has passed', () => {
const state = buildInitialState({ const state = buildInitialState({
tree: buildPickerTree(pages()), tree: buildPickerTree(pages()),

View file

@ -25,7 +25,8 @@ export interface PickerState {
expanded: Set<string>; expanded: Set<string>;
checked: Set<string>; checked: Set<string>;
cursorId: string; cursorId: string;
search: { editing: boolean; query: string }; search: { query: string };
isNavigating: boolean;
pendingConfirm: PendingConfirmKind | null; pendingConfirm: PendingConfirmKind | null;
preLoadWarnings: string[]; preLoadWarnings: string[];
transientHint: { text: string; expiresAt: number } | null; transientHint: { text: string; expiresAt: number } | null;
@ -47,9 +48,7 @@ export type PickerCommand =
| 'toggle-select-all-visible' | 'toggle-select-all-visible'
| 'select-none' | 'select-none'
| 'clear-transient-hint' | 'clear-transient-hint'
| 'search-start' | 'search-clear'
| 'search-cancel'
| 'search-submit'
| 'search-backspace' | 'search-backspace'
| { type: 'search-input'; value: string } | { type: 'search-input'; value: string }
| 'save-request' | 'save-request'
@ -464,7 +463,8 @@ export function buildInitialState(args: {
expanded, expanded,
checked: minimalChecked, checked: minimalChecked,
cursorId: args.tree[0]?.id ?? '', cursorId: args.tree[0]?.id ?? '',
search: { editing: false, query: '' }, search: { query: '' },
isNavigating: false,
pendingConfirm: null, pendingConfirm: null,
preLoadWarnings, preLoadWarnings,
transientHint: null, transientHint: null,
@ -473,6 +473,14 @@ export function buildInitialState(args: {
}; };
} }
function refocusVisibleCursor(state: PickerState): PickerState {
const ids = visibleNodeIds(state);
if (ids.length === 0 || ids.includes(state.cursorId)) {
return state;
}
return cloneState(state, { cursorId: ids[0]! });
}
export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()): { next: PickerState; effect: PickerEffect } { export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()): { next: PickerState; effect: PickerEffect } {
if (state.pendingConfirm) { if (state.pendingConfirm) {
if (cmd === 'save-confirm') { if (cmd === 'save-confirm') {
@ -491,13 +499,13 @@ export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()
switch (cmd) { switch (cmd) {
case 'cursor-up': case 'cursor-up':
return { next: moveCursor(state, 'up'), effect: null }; return { next: cloneState(moveCursor(state, 'up'), { isNavigating: true }), effect: null };
case 'cursor-down': case 'cursor-down':
return { next: moveCursor(state, 'down'), effect: null }; return { next: cloneState(moveCursor(state, 'down'), { isNavigating: true }), effect: null };
case 'cursor-left': case 'cursor-left':
return { next: moveCursor(state, 'left'), effect: null }; return { next: cloneState(moveCursor(state, 'left'), { isNavigating: true }), effect: null };
case 'cursor-right': case 'cursor-right':
return { next: moveCursor(state, 'right'), effect: null }; return { next: cloneState(moveCursor(state, 'right'), { isNavigating: true }), effect: null };
case 'expand': case 'expand':
return { next: setExpanded(state, state.cursorId, 'toggle'), effect: null }; return { next: setExpanded(state, state.cursorId, 'toggle'), effect: null };
case 'collapse': case 'collapse':
@ -521,15 +529,19 @@ export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()
return { next: selectNone(state), effect: null }; return { next: selectNone(state), effect: null };
case 'clear-transient-hint': case 'clear-transient-hint':
return { next: clearExpiredTransientHint(state, now), effect: null }; return { next: clearExpiredTransientHint(state, now), effect: null };
case 'search-start': case 'search-clear':
return { next: cloneState(state, { search: { ...state.search, editing: true } }), effect: null }; return {
case 'search-cancel': next: cloneState(state, { search: { query: '' }, isNavigating: false }),
return { next: cloneState(state, { search: { editing: false, query: '' } }), effect: null }; effect: null,
case 'search-submit': };
return { next: cloneState(state, { search: { ...state.search, editing: false } }), effect: null };
case 'search-backspace': case 'search-backspace':
return { return {
next: cloneState(state, { search: { ...state.search, query: state.search.query.slice(0, -1) } }), next: refocusVisibleCursor(
cloneState(state, {
search: { query: state.search.query.slice(0, -1) },
isNavigating: false,
}),
),
effect: null, effect: null,
}; };
case 'save-request': case 'save-request':
@ -546,6 +558,14 @@ export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()
case 'quit': case 'quit':
return { next: state, effect: 'quit-without-save' }; return { next: state, effect: 'quit-without-save' };
default: default:
return { next: cloneState(state, { search: { ...state.search, query: state.search.query + cmd.value } }), effect: null }; return {
next: refocusVisibleCursor(
cloneState(state, {
search: { query: state.search.query + cmd.value },
isNavigating: false,
}),
),
effect: null,
};
} }
} }

View file

@ -83,38 +83,71 @@ function normalizeFrameWrap(frame: string | undefined): string {
} }
describe('treePickerCommandForInkInput', () => { describe('treePickerCommandForInkInput', () => {
it('maps browse, search, and confirm input to reducer commands', () => { const browse = (overrides: Partial<{ search: { query: string }; isNavigating: boolean; pendingConfirm: null }> = {}) => ({
expect(treePickerCommandForInkInput('', { downArrow: true }, state().search, null)).toBe('cursor-down'); search: { query: '' },
expect(treePickerCommandForInkInput('', { upArrow: true }, state().search, null)).toBe('cursor-up'); isNavigating: false,
expect(treePickerCommandForInkInput('', { rightArrow: true }, state().search, null)).toBe('cursor-right'); pendingConfirm: null,
expect(treePickerCommandForInkInput('', { leftArrow: true }, state().search, null)).toBe('cursor-left'); ...overrides,
expect(treePickerCommandForInkInput(' ', {}, state().search, null)).toBe('toggle-check'); });
expect(treePickerCommandForInkInput('/', {}, state().search, null)).toBe('search-start'); const confirming = { ...browse(), pendingConfirm: 'save-confirm' as const };
expect(treePickerCommandForInkInput('a', {}, state().search, null)).toBe('toggle-select-all-visible');
expect(treePickerCommandForInkInput('n', {}, state().search, null)).toBe('select-none');
expect(treePickerCommandForInkInput('', { return: true }, state().search, null)).toBe('save-request');
expect(treePickerCommandForInkInput('', { escape: true }, state().search, null)).toBe('quit');
expect(treePickerCommandForInkInput('c', { ctrl: true }, state().search, null)).toBe('quit');
expect(treePickerCommandForInkInput('s', {}, state().search, null)).toBeNull();
expect(treePickerCommandForInkInput('q', {}, state().search, null)).toBeNull();
expect(treePickerCommandForInkInput('x', {}, { editing: true, query: '' }, null)).toEqual({ it('routes cursor and confirm keys when no query is typed', () => {
expect(treePickerCommandForInkInput('', { downArrow: true }, browse())).toBe('cursor-down');
expect(treePickerCommandForInkInput('', { upArrow: true }, browse())).toBe('cursor-up');
expect(treePickerCommandForInkInput('', { rightArrow: true }, browse())).toBe('cursor-right');
expect(treePickerCommandForInkInput('', { leftArrow: true }, browse())).toBe('cursor-left');
expect(treePickerCommandForInkInput('', { return: true }, browse())).toBe('save-request');
expect(treePickerCommandForInkInput('', { escape: true }, browse())).toBe('quit');
expect(treePickerCommandForInkInput('c', { ctrl: true }, browse())).toBe('quit');
});
it('Tab toggles selection regardless of search/navigation state', () => {
expect(treePickerCommandForInkInput('', { tab: true }, browse())).toBe('toggle-check');
expect(treePickerCommandForInkInput('', { tab: true }, browse({ search: { query: 'foo' }, isNavigating: false }))).toBe(
'toggle-check',
);
expect(treePickerCommandForInkInput('', { tab: true }, browse({ isNavigating: true }))).toBe('toggle-check');
});
it('Space toggles only when navigating; otherwise typed into the search query', () => {
expect(treePickerCommandForInkInput(' ', {}, browse({ isNavigating: true }))).toBe('toggle-check');
expect(treePickerCommandForInkInput(' ', {}, browse({ isNavigating: false }))).toEqual({
type: 'search-input',
value: ' ',
});
});
it('typed printable chars feed the search query — including a, n, and slash', () => {
expect(treePickerCommandForInkInput('a', {}, browse())).toEqual({ type: 'search-input', value: 'a' });
expect(treePickerCommandForInkInput('n', {}, browse())).toEqual({ type: 'search-input', value: 'n' });
expect(treePickerCommandForInkInput('/', {}, browse())).toEqual({ type: 'search-input', value: '/' });
expect(treePickerCommandForInkInput('x', {}, browse({ search: { query: 'foo' } }))).toEqual({
type: 'search-input', type: 'search-input',
value: 'x', value: 'x',
}); });
expect(treePickerCommandForInkInput('', { backspace: true }, { editing: true, query: 'x' }, null)).toBe( });
it('Ctrl+A and Ctrl+N drive the bulk toggle helpers', () => {
expect(treePickerCommandForInkInput('a', { ctrl: true }, browse())).toBe('toggle-select-all-visible');
expect(treePickerCommandForInkInput('n', { ctrl: true }, browse())).toBe('select-none');
});
it('Backspace deletes from the query at any time; Esc clears query first then quits', () => {
expect(treePickerCommandForInkInput('', { backspace: true }, browse({ search: { query: 'x' } }))).toBe(
'search-backspace', 'search-backspace',
); );
expect(treePickerCommandForInkInput('', { return: true }, { editing: true, query: 'x' }, null)).toBe( expect(treePickerCommandForInkInput('', { delete: true }, browse({ search: { query: 'x' } }))).toBe(
'search-submit', 'search-backspace',
);
expect(treePickerCommandForInkInput('', { escape: true }, { editing: true, query: 'x' }, null)).toBe(
'search-cancel',
); );
expect(treePickerCommandForInkInput('', { escape: true }, browse({ search: { query: 'x' } }))).toBe('search-clear');
expect(treePickerCommandForInkInput('', { escape: true }, browse())).toBe('quit');
});
expect(treePickerCommandForInkInput('y', {}, state().search, 'save-confirm')).toBe('save-confirm'); it('confirm prompts intercept y/n/Enter/Esc before search routing', () => {
expect(treePickerCommandForInkInput('', { return: true }, state().search, 'save-confirm')).toBe('save-confirm'); expect(treePickerCommandForInkInput('y', {}, confirming)).toBe('save-confirm');
expect(treePickerCommandForInkInput('n', {}, state().search, 'save-confirm')).toBe('save-cancel'); expect(treePickerCommandForInkInput('', { return: true }, confirming)).toBe('save-confirm');
expect(treePickerCommandForInkInput('n', {}, confirming)).toBe('save-cancel');
expect(treePickerCommandForInkInput('', { escape: true }, confirming)).toBe('save-cancel');
}); });
}); });
@ -160,8 +193,9 @@ describe('TreePickerApp', () => {
expect(frame).toContain('◻ Engineering Docs ▸ (1)'); expect(frame).toContain('◻ Engineering Docs ▸ (1)');
expect(frame).toContain('◻ Marketing'); expect(frame).toContain('◻ Marketing');
expect(normalizeFrameWrap(frame)).toContain( expect(normalizeFrameWrap(frame)).toContain(
'Right Arrow to expand, Up/Down to move, Space to select or unselect, Slash to filter, Enter to confirm, Escape to go back, or Ctrl+C to exit.', 'Up/Down to move, Right/Left to expand or collapse, Tab to select, Type to search, Enter to confirm, Escape to clear search or go back, Ctrl+C to exit.',
); );
expect(frame).toContain('Search:');
}); });
it('renders custom help text when supplied', () => { it('renders custom help text when supplied', () => {
@ -238,7 +272,7 @@ describe('TreePickerApp', () => {
/>, />,
); );
stdin.write(' '); stdin.write('\t');
await waitForInkInput(); await waitForInkInput();
expect(lastFrame()).toContain('◼ Engineering Docs'); expect(lastFrame()).toContain('◼ Engineering Docs');

View file

@ -32,7 +32,7 @@ const NO_COLOR_THEME = {
type TreePickerTheme = Record<keyof typeof COLOR_THEME, string>; type TreePickerTheme = Record<keyof typeof COLOR_THEME, string>;
const DEFAULT_TREE_PICKER_HELP_TEXT = const DEFAULT_TREE_PICKER_HELP_TEXT =
'Right Arrow to expand, Up/Down to move, Space to select or unselect, Slash to filter, Enter to confirm, Escape to go back, or Ctrl+C to exit.'; 'Up/Down to move, Right/Left to expand or collapse, Tab to select, Type to search, Enter to confirm, Escape to clear search or go back, Ctrl+C to exit.';
const DEFAULT_SKIP_EMPTY_MESSAGE = const DEFAULT_SKIP_EMPTY_MESSAGE =
'Nothing selected. Skip this step? Press Enter to skip or Escape to go back.'; 'Nothing selected. Skip this step? Press Enter to skip or Escape to go back.';
@ -50,6 +50,8 @@ interface InkKey {
return?: boolean; return?: boolean;
escape?: boolean; escape?: boolean;
ctrl?: boolean; ctrl?: boolean;
tab?: boolean;
shift?: boolean;
backspace?: boolean; backspace?: boolean;
delete?: boolean; delete?: boolean;
} }
@ -147,35 +149,27 @@ function truncateText(value: string, width: number): string {
export function treePickerCommandForInkInput( export function treePickerCommandForInkInput(
input: string, input: string,
key: InkKey, key: InkKey,
search: PickerState['search'], state: Pick<PickerState, 'search' | 'isNavigating' | 'pendingConfirm'>,
pendingConfirm: PickerState['pendingConfirm'],
): PickerCommand | null { ): PickerCommand | null {
if (pendingConfirm) { if (state.pendingConfirm) {
if (input === 'y' || key.return) return 'save-confirm'; if (input === 'y' || key.return) return 'save-confirm';
if (input === 'n' || key.escape) return 'save-cancel'; if (input === 'n' || key.escape) return 'save-cancel';
if (key.ctrl === true && input === 'c') return 'quit'; if (key.ctrl === true && input === 'c') return 'quit';
return null; return null;
} }
if (search.editing) {
if (key.escape) return 'search-cancel';
if (key.return) return 'search-submit';
if (key.backspace || key.delete) return 'search-backspace';
if (key.downArrow) return 'cursor-down';
if (key.upArrow) return 'cursor-up';
if (input.length === 1 && input >= ' ' && input !== '') return { type: 'search-input', value: input };
return null;
}
if (key.ctrl === true && input === 'c') return 'quit'; if (key.ctrl === true && input === 'c') return 'quit';
if (key.ctrl === true && input === 'a') return 'toggle-select-all-visible';
if (key.ctrl === true && input === 'n') return 'select-none';
if (key.return) return 'save-request';
if (key.upArrow) return 'cursor-up'; if (key.upArrow) return 'cursor-up';
if (key.downArrow) return 'cursor-down'; if (key.downArrow) return 'cursor-down';
if (key.leftArrow) return 'cursor-left'; if (key.leftArrow) return 'cursor-left';
if (key.rightArrow) return 'cursor-right'; if (key.rightArrow) return 'cursor-right';
if (key.return) return 'save-request'; if (key.tab) return 'toggle-check';
if (input === ' ') return 'toggle-check'; if (input === ' ' && state.isNavigating) return 'toggle-check';
if (input === '/') return 'search-start'; if (key.backspace || key.delete) return 'search-backspace';
if (input === 'a') return 'toggle-select-all-visible'; if (key.escape) return state.search.query.length > 0 ? 'search-clear' : 'quit';
if (input === 'n') return 'select-none'; if (input.length === 1 && input >= ' ' && input !== '') return { type: 'search-input', value: input };
if (key.escape) return 'quit';
return null; return null;
} }
@ -220,14 +214,13 @@ export function TreePickerApp(props: TreePickerAppProps): ReactNode {
const theme = useMemo(() => resolveTheme(props.env), [props.env]); const theme = useMemo(() => resolveTheme(props.env), [props.env]);
const visibleIds = visibleNodeIds(state); const visibleIds = visibleNodeIds(state);
const selectedIndex = Math.max(0, visibleIds.indexOf(state.cursorId)); const selectedIndex = Math.max(0, visibleIds.indexOf(state.cursorId));
const reservedRows = state.pendingConfirm === 'save-confirm' ? 10 : 9; const reservedRows = state.pendingConfirm === 'save-confirm' ? 11 : 10;
const visibleRows = Math.max(5, Math.min(12, (props.terminalRows ?? 24) - reservedRows)); const visibleRows = Math.max(5, Math.min(12, (props.terminalRows ?? 24) - reservedRows));
const rows = windowItems(visibleIds, selectedIndex, visibleRows); const rows = windowItems(visibleIds, selectedIndex, visibleRows);
const hiddenAbove = rows.offset; const hiddenAbove = rows.offset;
const hiddenBelow = Math.max(0, visibleIds.length - rows.offset - rows.items.length); const hiddenBelow = Math.max(0, visibleIds.length - rows.offset - rows.items.length);
const searchMatchCount = filterTree(state).visibleIds.size; const searchMatchCount = filterTree(state).visibleIds.size;
const width = resolveTreePickerWidth(props.terminalWidth); const width = resolveTreePickerWidth(props.terminalWidth);
const showSearch = state.search.editing || state.search.query.trim().length > 0;
const helpText = props.chrome.helpText ?? DEFAULT_TREE_PICKER_HELP_TEXT; const helpText = props.chrome.helpText ?? DEFAULT_TREE_PICKER_HELP_TEXT;
const skipEmptyMessage = props.chrome.skipEmptyMessage ?? DEFAULT_SKIP_EMPTY_MESSAGE; const skipEmptyMessage = props.chrome.skipEmptyMessage ?? DEFAULT_SKIP_EMPTY_MESSAGE;
@ -258,7 +251,7 @@ export function TreePickerApp(props: TreePickerAppProps): ReactNode {
}, [state.transientHint?.expiresAt]); }, [state.transientHint?.expiresAt]);
useInput((input, key) => { useInput((input, key) => {
const command = treePickerCommandForInkInput(input, key, stateRef.current.search, stateRef.current.pendingConfirm); const command = treePickerCommandForInkInput(input, key, stateRef.current);
if (!command) { if (!command) {
return; return;
} }
@ -308,16 +301,18 @@ export function TreePickerApp(props: TreePickerAppProps): ReactNode {
{warning} {warning}
</Text> </Text>
))} ))}
{showSearch ? ( <Text>
<Text> <Text color={theme.muted}>Search: </Text>
<Text color={theme.muted}>/ </Text> {state.isNavigating ? (
<Text color={theme.muted}>{state.search.query || '(type to filter)'}</Text>
) : (
<Text> <Text>
{state.search.query} {state.search.query}
{state.search.editing ? '█' : ''} <Text inverse> </Text>
</Text> </Text>
<Text color={theme.muted}> ({searchMatchCount} matches)</Text> )}
</Text> <Text color={theme.muted}> ({searchMatchCount} match{searchMatchCount === 1 ? '' : 'es'})</Text>
) : null} </Text>
<Text> </Text> <Text> </Text>
{hiddenAbove > 0 ? <Text color={theme.muted}> {hiddenAbove} more</Text> : null} {hiddenAbove > 0 ? <Text color={theme.muted}> {hiddenAbove} more</Text> : null}
{rows.items.map((nodeId) => ( {rows.items.map((nodeId) => (