feat(cli): skip-context-sources menu + clack-style tree picker UX (#213)

* feat(cli): add 'skip context sources' option to database setup menu

After databases are configured, the post-setup menu now offers a 'Skip
context sources' choice equivalent to passing --skip-sources, which
plumbs through KtxSetupDatabasesResult.skipSources to bypass the
context-source step in the same run.

* 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:29:37 +02:00 committed by GitHub
parent 96952fb43c
commit cfd1749ab9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 292 additions and 83 deletions

View file

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