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

@ -191,7 +191,7 @@ describe('search and cursor movement', () => {
});
const searching = {
...state,
search: { editing: false, query: 'architecture' },
search: { query: 'architecture' },
};
expect(filterTree(searching)).toEqual({
@ -229,7 +229,7 @@ describe('bulk actions and reducer effects', () => {
});
const searching = {
...state,
search: { editing: false, query: 'architecture' },
search: { query: 'architecture' },
};
const selected = selectAllVisible(searching);
@ -306,12 +306,11 @@ describe('bulk actions and reducer effects', () => {
next: { ...state, pendingConfirm: 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(
reducer(
reducer(reducer(state, 'search-start').next, { type: 'search-input', value: 'a' }).next,
'search-submit',
).next.search,
).toEqual({ editing: false, query: 'a' });
reducer({ ...state, search: { query: 'foo' }, isNavigating: true }, 'search-clear').next,
).toEqual({ ...state, search: { query: '' }, isNavigating: false });
expect(reducer(state, 'quit')).toEqual({
next: state,
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', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),