ktx/packages/cli/src/tree-picker-tui.tsx
Andrey Avtomonov 200041b178 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.
2026-05-24 19:11:21 +02:00

390 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* @jsxImportSource react */
import { Box, Text, render as renderInkRuntime, useApp, useInput } from 'ink';
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import {
filterTree,
flattenSelection,
hasPartialChildren,
isAncestorChecked,
reducer,
visibleNodeIds,
type PickerCommand,
type PickerState,
} from './tree-picker-state.js';
import type { KtxCliIo } from './cli-runtime.js';
const COLOR_THEME = {
text: 'white',
muted: 'gray',
active: 'cyan',
selected: 'green',
warning: 'yellow',
} as const;
const NO_COLOR_THEME = {
text: 'white',
muted: 'white',
active: 'white',
selected: 'white',
warning: 'white',
} as const;
type TreePickerTheme = Record<keyof typeof COLOR_THEME, string>;
const DEFAULT_TREE_PICKER_HELP_TEXT =
'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 =
'Nothing selected. Skip this step? Press Enter to skip or Escape to go back.';
export interface TreePickerTuiIo extends KtxCliIo {
stdin?: { isTTY?: boolean; setRawMode?(value: boolean): void };
stdout: KtxCliIo['stdout'] & { isTTY?: boolean; columns?: number; rows?: number };
}
interface InkKey {
leftArrow?: boolean;
rightArrow?: boolean;
upArrow?: boolean;
downArrow?: boolean;
return?: boolean;
escape?: boolean;
ctrl?: boolean;
tab?: boolean;
shift?: boolean;
backspace?: boolean;
delete?: boolean;
}
export type TreePickerResult = { kind: 'save'; selectedIds: string[] } | { kind: 'quit' };
export interface TreePickerChrome {
title: string;
helpText?: string;
subtitleLines?: readonly string[];
warningLines?: readonly string[];
confirmSaveMessage?: (state: PickerState) => string;
skipEmptyMessage?: string;
}
export interface TreePickerRenderInput {
initialState: PickerState;
chrome: TreePickerChrome;
}
interface TreePickerAppProps extends TreePickerRenderInput {
terminalRows?: number;
terminalWidth?: number;
env?: NodeJS.ProcessEnv;
onExit(result: TreePickerResult): void;
}
/** @internal */
export interface TreePickerInkInstance {
rerender(tree: ReactNode): void;
unmount(): void;
waitUntilExit(): Promise<void>;
}
/** @internal */
export interface TreePickerInkRenderOptions {
stdin?: TreePickerTuiIo['stdin'];
stdout: TreePickerTuiIo['stdout'];
stderr: TreePickerTuiIo['stderr'];
exitOnCtrlC: boolean;
patchConsole: boolean;
maxFps: number;
alternateScreen: boolean;
}
function resolveTheme(env: NodeJS.ProcessEnv = process.env): TreePickerTheme {
return env.NO_COLOR || env.TERM === 'dumb' ? NO_COLOR_THEME : COLOR_THEME;
}
/** @internal */
export function resolveTreePickerWidth(columns: number | undefined): number {
const resolvedColumns = columns ?? 100;
return Math.max(60, Math.min(120, resolvedColumns - 4));
}
function rowMatchesSearch(state: PickerState, nodeId: string): boolean {
const query = state.search.query.trim().toLocaleLowerCase();
if (!query) {
return false;
}
const node = state.byId.get(nodeId);
if (!node) {
return false;
}
return node.title.toLocaleLowerCase().includes(query) || node.path.toLocaleLowerCase().includes(query);
}
/** @internal */
export function sanitizeTreePickerTuiError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message
.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi, '[redacted-url]')
.replace(/\b(api[_-]?key|password|token|secret)=\S+/gi, '[redacted]');
}
/** @internal */
export function windowOffset(count: number, selected: number, visible: number): number {
if (count <= visible) return 0;
return Math.max(0, Math.min(count - visible, selected - Math.floor(visible / 2)));
}
/** @internal */
export function windowItems<T>(items: T[], selected: number, visible: number): { items: T[]; offset: number } {
const offset = windowOffset(items.length, selected, visible);
return { items: items.slice(offset, offset + visible), offset };
}
function truncateText(value: string, width: number): string {
if (value.length <= width) return value;
if (width <= 3) return value.slice(0, width);
return `${value.slice(0, width - 3)}...`;
}
/** @internal */
export function treePickerCommandForInkInput(
input: string,
key: InkKey,
state: Pick<PickerState, 'search' | 'isNavigating' | 'pendingConfirm'>,
): PickerCommand | null {
if (state.pendingConfirm) {
if (input === 'y' || key.return) return 'save-confirm';
if (input === 'n' || key.escape) return 'save-cancel';
if (key.ctrl === true && input === 'c') return 'quit';
return null;
}
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.downArrow) return 'cursor-down';
if (key.leftArrow) return 'cursor-left';
if (key.rightArrow) return 'cursor-right';
if (key.tab) return 'toggle-check';
if (input === ' ' && state.isNavigating) return 'toggle-check';
if (key.backspace || key.delete) return 'search-backspace';
if (key.escape) return state.search.query.length > 0 ? 'search-clear' : 'quit';
if (input.length === 1 && input >= ' ' && input !== '') return { type: 'search-input', value: input };
return null;
}
function PickerRow(props: { state: PickerState; nodeId: string; width: number; theme: TreePickerTheme }): ReactNode {
const node = props.state.byId.get(props.nodeId);
if (!node) return null;
const focused = props.state.cursorId === node.id;
const locked = isAncestorChecked(node.id, props.state.checked, props.state.byId);
const checked = props.state.checked.has(node.id);
const isSelected = checked || locked;
const partial = !isSelected && hasPartialChildren(node.id, props.state.checked, props.state.byId);
const glyph = isSelected ? '◼' : partial ? '◧' : '◻';
const glyphColor = isSelected || partial ? props.theme.selected : props.theme.muted;
const childAffordance =
node.childIds.length > 0 ? (props.state.expanded.has(node.id) ? ' ▾' : ` ▸ (${node.childIds.length})`) : '';
const indent = ' '.repeat(node.depth * 2);
const titleColor = focused ? props.theme.active : props.theme.text;
const inverse = rowMatchesSearch(props.state, node.id);
const prefixWidth = indent.length + 2 + childAffordance.length;
const title = truncateText(node.title, Math.max(10, props.width - prefixWidth));
return (
<Text>
<Text color={glyphColor}>
{indent}
{glyph}
</Text>
<Text color={titleColor} strikethrough={node.archived} bold={focused}>
{' '}
<Text inverse={inverse}>{title}</Text>
</Text>
{childAffordance.length > 0 ? <Text color={props.theme.muted}>{childAffordance}</Text> : null}
</Text>
);
}
/** @internal */
export function TreePickerApp(props: TreePickerAppProps): ReactNode {
const app = useApp();
const [state, setState] = useState(props.initialState);
const stateRef = useRef(state);
const theme = useMemo(() => resolveTheme(props.env), [props.env]);
const visibleIds = visibleNodeIds(state);
const selectedIndex = Math.max(0, visibleIds.indexOf(state.cursorId));
const reservedRows = state.pendingConfirm === 'save-confirm' ? 11 : 10;
const visibleRows = Math.max(5, Math.min(12, (props.terminalRows ?? 24) - reservedRows));
const rows = windowItems(visibleIds, selectedIndex, visibleRows);
const hiddenAbove = rows.offset;
const hiddenBelow = Math.max(0, visibleIds.length - rows.offset - rows.items.length);
const searchMatchCount = filterTree(state).visibleIds.size;
const width = resolveTreePickerWidth(props.terminalWidth);
const helpText = props.chrome.helpText ?? DEFAULT_TREE_PICKER_HELP_TEXT;
const skipEmptyMessage = props.chrome.skipEmptyMessage ?? DEFAULT_SKIP_EMPTY_MESSAGE;
stateRef.current = state;
useEffect(() => {
const hint = state.transientHint;
if (!hint) {
return;
}
const clearHint = () => {
setState((current) => {
const { next } = reducer(current, 'clear-transient-hint');
stateRef.current = next;
return next;
});
};
const delay = hint.expiresAt - Date.now();
if (delay <= 0) {
clearHint();
return;
}
const timeout = setTimeout(clearHint, delay);
return () => clearTimeout(timeout);
}, [state.transientHint?.expiresAt]);
useInput((input, key) => {
const command = treePickerCommandForInkInput(input, key, stateRef.current);
if (!command) {
return;
}
const { next, effect } = reducer(stateRef.current, command);
stateRef.current = next;
setState(next);
if (effect === 'save') {
props.onExit({ kind: 'save', selectedIds: flattenSelection(next.checked, next.byId) });
app.exit();
return;
}
if (effect === 'quit-without-save') {
props.onExit({ kind: 'quit' });
app.exit();
}
});
return (
<Box flexDirection="column">
<Text>
<Text color={theme.active}></Text>
<Text bold> {props.chrome.title}</Text>
</Text>
<Box
flexDirection="column"
borderStyle="single"
borderTop={false}
borderRight={false}
borderBottom={false}
borderColor={theme.active}
paddingLeft={1}
>
<Text color={theme.muted}>{helpText}</Text>
<Text> </Text>
{(props.chrome.subtitleLines ?? []).map((line, idx) => (
<Text key={`subtitle-${idx}`} color={theme.muted}>
{line}
</Text>
))}
{(props.chrome.warningLines ?? []).map((line, idx) => (
<Text key={`chromewarn-${idx}`} color={theme.warning}>
{line}
</Text>
))}
{state.preLoadWarnings.map((warning) => (
<Text key={warning} color={theme.warning}>
{warning}
</Text>
))}
<Text>
<Text color={theme.muted}>Search: </Text>
{state.isNavigating ? (
<Text color={theme.muted}>{state.search.query || '(type to filter)'}</Text>
) : (
<Text>
{state.search.query}
<Text inverse> </Text>
</Text>
)}
<Text color={theme.muted}> ({searchMatchCount} match{searchMatchCount === 1 ? '' : 'es'})</Text>
</Text>
<Text> </Text>
{hiddenAbove > 0 ? <Text color={theme.muted}> {hiddenAbove} more</Text> : null}
{rows.items.map((nodeId) => (
<PickerRow key={nodeId} state={state} nodeId={nodeId} width={width} theme={theme} />
))}
{hiddenBelow > 0 ? <Text color={theme.muted}> {hiddenBelow} more</Text> : null}
{state.pendingConfirm === 'save-confirm' ? (
<Text color={theme.warning}>
{props.chrome.confirmSaveMessage
? props.chrome.confirmSaveMessage(state)
: 'Confirm save? Press Enter to confirm or Escape to go back.'}
</Text>
) : null}
{state.pendingConfirm === 'skip-empty' ? <Text color={theme.warning}>{skipEmptyMessage}</Text> : null}
{state.transientHint ? <Text color={theme.warning}>{state.transientHint.text}</Text> : null}
</Box>
<Text color={theme.active}></Text>
</Box>
);
}
function renderInk(tree: ReactNode, options: TreePickerInkRenderOptions): TreePickerInkInstance {
return renderInkRuntime(tree, {
stdin: options.stdin as NodeJS.ReadStream | undefined,
stdout: options.stdout as NodeJS.WriteStream,
stderr: options.stderr as NodeJS.WriteStream,
exitOnCtrlC: options.exitOnCtrlC,
patchConsole: options.patchConsole,
maxFps: options.maxFps,
alternateScreen: options.alternateScreen,
}) as TreePickerInkInstance;
}
export interface RenderTreePickerOptions {
renderInk?: (tree: ReactNode, options: TreePickerInkRenderOptions) => TreePickerInkInstance;
scriptedModeHint?: string;
}
export async function renderTreePickerTui(
input: TreePickerRenderInput,
io: TreePickerTuiIo,
options: RenderTreePickerOptions = {},
): Promise<TreePickerResult> {
let result: TreePickerResult = { kind: 'quit' };
let instance: TreePickerInkInstance | null = null;
try {
instance = (options.renderInk ?? renderInk)(
<TreePickerApp
{...input}
terminalRows={(io.stdout as { rows?: number }).rows ?? process.stdout.rows ?? 24}
terminalWidth={io.stdout.columns ?? process.stdout.columns}
onExit={(next) => {
result = next;
instance?.unmount();
}}
/>,
{
stdin: io.stdin,
stdout: io.stdout,
stderr: io.stderr,
exitOnCtrlC: false,
patchConsole: false,
maxFps: 30,
alternateScreen: false,
},
);
await instance.waitUntilExit();
instance.unmount();
return result;
} catch (error) {
const hint = options.scriptedModeHint ?? 'Picker requires a TTY.';
io.stderr.write(`${hint} ${sanitizeTreePickerTuiError(error)}\n`);
return { kind: 'quit' };
}
}