mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
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:
parent
96952fb43c
commit
cfd1749ab9
9 changed files with 292 additions and 83 deletions
|
|
@ -136,6 +136,10 @@ Enabling query history makes deep ingest readiness matter for later
|
|||
|
||||
### Context Sources
|
||||
|
||||
In interactive setup, after you configure a database, choose
|
||||
**Skip context sources** to leave optional context-source setup complete with no
|
||||
sources. This is equivalent to passing `--skip-sources` in scripted setup.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--source <type>` | Context-source connector type: `dbt`, `metricflow`, `metabase`, `looker`, `lookml`, or `notion` |
|
||||
|
|
|
|||
|
|
@ -695,6 +695,7 @@ describe('setup databases step', () => {
|
|||
message: 'Databases configured: warehouse\nWhat would you like to do?',
|
||||
options: [
|
||||
{ value: 'continue', label: 'Continue to context sources' },
|
||||
{ value: 'skip-sources', label: 'Skip context sources' },
|
||||
{ value: 'edit', label: 'Edit an existing database' },
|
||||
{ value: 'add', label: 'Add another database' },
|
||||
],
|
||||
|
|
@ -703,6 +704,48 @@ describe('setup databases step', () => {
|
|||
expect(scanConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can skip context sources from the configured database menu', async () => {
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
' url: env:DATABASE_URL',
|
||||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
|
||||
const prompts = makePromptAdapter({ selectValues: ['skip-sources'] });
|
||||
const testConnection = vi.fn(async () => 0);
|
||||
const scanConnection = vi.fn(async () => 0);
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
inputMode: 'auto',
|
||||
skipDatabases: false,
|
||||
databaseSchemas: [],
|
||||
disableQueryHistory: true,
|
||||
},
|
||||
makeIo().io,
|
||||
{ prompts, testConnection, scanConnection },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'ready',
|
||||
projectDir: tempDir,
|
||||
connectionIds: ['warehouse'],
|
||||
skipSources: true,
|
||||
});
|
||||
expect(testConnection).not.toHaveBeenCalled();
|
||||
expect(scanConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves existing database ids when adding another database from the configured menu', async () => {
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
|
|
@ -753,6 +796,7 @@ describe('setup databases step', () => {
|
|||
message: 'Databases configured: warehouse\nWhat would you like to do?',
|
||||
options: [
|
||||
{ value: 'continue', label: 'Continue to context sources' },
|
||||
{ value: 'skip-sources', label: 'Skip context sources' },
|
||||
{ value: 'edit', label: 'Edit an existing database' },
|
||||
{ value: 'add', label: 'Add another database' },
|
||||
],
|
||||
|
|
@ -801,6 +845,7 @@ describe('setup databases step', () => {
|
|||
message: 'Databases configured: postgres-warehouse\nWhat would you like to do?',
|
||||
options: [
|
||||
{ value: 'continue', label: 'Continue to context sources' },
|
||||
{ value: 'skip-sources', label: 'Skip context sources' },
|
||||
{ value: 'edit', label: 'Edit an existing database' },
|
||||
{ value: 'add', label: 'Add another database' },
|
||||
],
|
||||
|
|
@ -846,6 +891,7 @@ describe('setup databases step', () => {
|
|||
message: 'Databases configured: postgres-warehouse\nWhat would you like to do?',
|
||||
options: [
|
||||
{ value: 'continue', label: 'Continue to context sources' },
|
||||
{ value: 'skip-sources', label: 'Skip context sources' },
|
||||
{ value: 'edit', label: 'Edit an existing database' },
|
||||
{ value: 'add', label: 'Add another database' },
|
||||
],
|
||||
|
|
@ -890,6 +936,7 @@ describe('setup databases step', () => {
|
|||
message: 'Databases configured: warehouse\nWhat would you like to do?',
|
||||
options: [
|
||||
{ value: 'continue', label: 'Continue to context sources' },
|
||||
{ value: 'skip-sources', label: 'Skip context sources' },
|
||||
{ value: 'edit', label: 'Edit an existing database' },
|
||||
{ value: 'add', label: 'Add another database' },
|
||||
],
|
||||
|
|
@ -936,6 +983,7 @@ describe('setup databases step', () => {
|
|||
message: 'Databases configured: warehouse\nWhat would you like to do?',
|
||||
options: [
|
||||
{ value: 'continue', label: 'Continue to context sources' },
|
||||
{ value: 'skip-sources', label: 'Skip context sources' },
|
||||
{ value: 'edit', label: 'Edit an existing database' },
|
||||
{ value: 'add', label: 'Add another database' },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -56,7 +56,12 @@ export interface KtxSetupDatabasesArgs {
|
|||
}
|
||||
|
||||
export type KtxSetupDatabasesResult =
|
||||
| { status: 'ready'; projectDir: string; connectionIds: string[] }
|
||||
| {
|
||||
status: 'ready';
|
||||
projectDir: string;
|
||||
connectionIds: string[];
|
||||
skipSources?: boolean;
|
||||
}
|
||||
| { status: 'skipped'; projectDir: string }
|
||||
| { status: 'back'; projectDir: string }
|
||||
| { status: 'missing-input'; projectDir: string }
|
||||
|
|
@ -633,6 +638,7 @@ function configuredPrimarySourcesPrompt(connectionIds: string[]): {
|
|||
message: `Databases configured: ${connectionIds.join(', ')}\nWhat would you like to do?`,
|
||||
options: [
|
||||
{ value: 'continue', label: 'Continue to context sources' },
|
||||
{ value: 'skip-sources', label: 'Skip context sources' },
|
||||
{ value: 'edit', label: 'Edit an existing database' },
|
||||
{ value: 'add', label: 'Add another database' },
|
||||
],
|
||||
|
|
@ -2167,6 +2173,15 @@ export async function runKtxSetupDatabasesStep(
|
|||
await markDatabasesComplete(args.projectDir, selectedConnectionIds);
|
||||
return { status: 'ready', projectDir: args.projectDir, connectionIds: selectedConnectionIds };
|
||||
}
|
||||
if (action === 'skip-sources') {
|
||||
await markDatabasesComplete(args.projectDir, selectedConnectionIds);
|
||||
return {
|
||||
status: 'ready',
|
||||
projectDir: args.projectDir,
|
||||
connectionIds: selectedConnectionIds,
|
||||
skipSources: true,
|
||||
};
|
||||
}
|
||||
if (action === 'edit') {
|
||||
const connectionId = await choosePrimarySourceToEdit({
|
||||
projectDir: args.projectDir,
|
||||
|
|
|
|||
|
|
@ -1641,6 +1641,67 @@ describe('setup status', () => {
|
|||
expect(calls).toEqual(['model', 'embeddings', 'databases', 'sources']);
|
||||
});
|
||||
|
||||
it('passes context-source skip selection from database setup into the sources step', async () => {
|
||||
const calls: string[] = [];
|
||||
const io = makeIo();
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
skipAgents: true,
|
||||
inputMode: 'disabled',
|
||||
yes: true,
|
||||
cliVersion: '0.2.0',
|
||||
skipLlm: true,
|
||||
skipEmbeddings: true,
|
||||
skipDatabases: false,
|
||||
skipSources: false,
|
||||
databaseSchemas: [],
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
model: async () => {
|
||||
calls.push('model');
|
||||
return { status: 'skipped', projectDir: tempDir };
|
||||
},
|
||||
embeddings: async () => {
|
||||
calls.push('embeddings');
|
||||
return { status: 'skipped', projectDir: tempDir };
|
||||
},
|
||||
databases: async () => {
|
||||
calls.push('databases');
|
||||
return {
|
||||
status: 'ready',
|
||||
projectDir: tempDir,
|
||||
connectionIds: ['warehouse'],
|
||||
skipSources: true,
|
||||
};
|
||||
},
|
||||
sources: async (args) => {
|
||||
expect(args.skipSources).toBe(true);
|
||||
calls.push('sources');
|
||||
return { status: 'skipped', projectDir: tempDir };
|
||||
},
|
||||
runtime: async () => {
|
||||
calls.push('runtime');
|
||||
return runtimeReady(tempDir);
|
||||
},
|
||||
context: async () => {
|
||||
calls.push('context');
|
||||
return { status: 'ready', projectDir: tempDir, runId: 'setup-context-local-test' };
|
||||
},
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(calls).toEqual(['model', 'embeddings', 'databases', 'sources', 'runtime', 'context']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
backend: 'vertex',
|
||||
|
|
|
|||
|
|
@ -667,6 +667,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
const shouldRunContext = !agentOnlySetup && (!runOnly || runOnly === 'context');
|
||||
const shouldRunAgents = agentsRequested || !runOnly || runOnly === 'agents';
|
||||
const showPromptInstructions = projectResult.confirmedCreation !== true;
|
||||
let skipSourcesFromDatabaseMenu = false;
|
||||
|
||||
const setupSteps: KtxSetupFlowStep[] = agentOnlySetup
|
||||
? []
|
||||
|
|
@ -680,7 +681,9 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
if (step === 'models') return !args.skipLlm && shouldRunModels;
|
||||
if (step === 'embeddings') return !args.skipEmbeddings && shouldRunEmbeddings;
|
||||
if (step === 'databases') return !args.skipDatabases && shouldRunDatabases;
|
||||
if (step === 'sources') return args.skipSources !== true && shouldRunSources;
|
||||
if (step === 'sources') {
|
||||
return args.skipSources !== true && !skipSourcesFromDatabaseMenu && shouldRunSources;
|
||||
}
|
||||
if (step === 'runtime') return shouldRunRuntime;
|
||||
if (step === 'context') return shouldRunContext;
|
||||
return shouldRunAgents && args.skipAgents !== true;
|
||||
|
|
@ -743,7 +746,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
const databasesRunner =
|
||||
deps.databases ??
|
||||
((databaseArgs, databaseIo) => runKtxSetupDatabasesStep(databaseArgs, databaseIo, deps.databasesDeps));
|
||||
stepResult = await databasesRunner(
|
||||
const databaseResult = await databasesRunner(
|
||||
{
|
||||
projectDir: projectResult.projectDir,
|
||||
inputMode: args.inputMode,
|
||||
|
|
@ -768,6 +771,8 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
},
|
||||
io,
|
||||
);
|
||||
skipSourcesFromDatabaseMenu = databaseResult.status === 'ready' && databaseResult.skipSources === true;
|
||||
stepResult = databaseResult;
|
||||
} else if (step === 'sources') {
|
||||
const sourcesRunner =
|
||||
deps.sources ?? ((sourceArgs, sourceIo) => runKtxSetupSourcesStep(sourceArgs, sourceIo, deps.sourcesDeps));
|
||||
|
|
@ -794,7 +799,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
|
|||
...(args.notionCrawlMode ? { notionCrawlMode: args.notionCrawlMode } : {}),
|
||||
...(args.notionRootPageIds ? { notionRootPageIds: args.notionRootPageIds } : {}),
|
||||
runInitialSourceIngest: args.runInitialSourceIngest ?? false,
|
||||
skipSources: args.skipSources === true || !shouldRunSources,
|
||||
skipSources: args.skipSources === true || !shouldRunSources || skipSourcesFromDatabaseMenu,
|
||||
},
|
||||
io,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,38 +83,71 @@ function normalizeFrameWrap(frame: string | undefined): string {
|
|||
}
|
||||
|
||||
describe('treePickerCommandForInkInput', () => {
|
||||
it('maps browse, search, and confirm input to reducer commands', () => {
|
||||
expect(treePickerCommandForInkInput('', { downArrow: true }, state().search, null)).toBe('cursor-down');
|
||||
expect(treePickerCommandForInkInput('', { upArrow: true }, state().search, null)).toBe('cursor-up');
|
||||
expect(treePickerCommandForInkInput('', { rightArrow: true }, state().search, null)).toBe('cursor-right');
|
||||
expect(treePickerCommandForInkInput('', { leftArrow: true }, state().search, null)).toBe('cursor-left');
|
||||
expect(treePickerCommandForInkInput(' ', {}, state().search, null)).toBe('toggle-check');
|
||||
expect(treePickerCommandForInkInput('/', {}, state().search, null)).toBe('search-start');
|
||||
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();
|
||||
const browse = (overrides: Partial<{ search: { query: string }; isNavigating: boolean; pendingConfirm: null }> = {}) => ({
|
||||
search: { query: '' },
|
||||
isNavigating: false,
|
||||
pendingConfirm: null,
|
||||
...overrides,
|
||||
});
|
||||
const confirming = { ...browse(), pendingConfirm: 'save-confirm' as const };
|
||||
|
||||
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',
|
||||
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',
|
||||
);
|
||||
expect(treePickerCommandForInkInput('', { return: true }, { editing: true, query: 'x' }, null)).toBe(
|
||||
'search-submit',
|
||||
);
|
||||
expect(treePickerCommandForInkInput('', { escape: true }, { editing: true, query: 'x' }, null)).toBe(
|
||||
'search-cancel',
|
||||
expect(treePickerCommandForInkInput('', { delete: true }, browse({ search: { query: 'x' } }))).toBe(
|
||||
'search-backspace',
|
||||
);
|
||||
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');
|
||||
expect(treePickerCommandForInkInput('', { return: true }, state().search, 'save-confirm')).toBe('save-confirm');
|
||||
expect(treePickerCommandForInkInput('n', {}, state().search, 'save-confirm')).toBe('save-cancel');
|
||||
it('confirm prompts intercept y/n/Enter/Esc before search routing', () => {
|
||||
expect(treePickerCommandForInkInput('y', {}, confirming)).toBe('save-confirm');
|
||||
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('◻ Marketing');
|
||||
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', () => {
|
||||
|
|
@ -238,7 +272,7 @@ describe('TreePickerApp', () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
stdin.write(' ');
|
||||
stdin.write('\t');
|
||||
await waitForInkInput();
|
||||
expect(lastFrame()).toContain('◼ Engineering Docs');
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const NO_COLOR_THEME = {
|
|||
type TreePickerTheme = Record<keyof typeof COLOR_THEME, string>;
|
||||
|
||||
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 =
|
||||
'Nothing selected. Skip this step? Press Enter to skip or Escape to go back.';
|
||||
|
|
@ -50,6 +50,8 @@ interface InkKey {
|
|||
return?: boolean;
|
||||
escape?: boolean;
|
||||
ctrl?: boolean;
|
||||
tab?: boolean;
|
||||
shift?: boolean;
|
||||
backspace?: boolean;
|
||||
delete?: boolean;
|
||||
}
|
||||
|
|
@ -147,35 +149,27 @@ function truncateText(value: string, width: number): string {
|
|||
export function treePickerCommandForInkInput(
|
||||
input: string,
|
||||
key: InkKey,
|
||||
search: PickerState['search'],
|
||||
pendingConfirm: PickerState['pendingConfirm'],
|
||||
state: Pick<PickerState, 'search' | 'isNavigating' | 'pendingConfirm'>,
|
||||
): PickerCommand | null {
|
||||
if (pendingConfirm) {
|
||||
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 (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 === '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.return) return 'save-request';
|
||||
if (input === ' ') return 'toggle-check';
|
||||
if (input === '/') return 'search-start';
|
||||
if (input === 'a') return 'toggle-select-all-visible';
|
||||
if (input === 'n') return 'select-none';
|
||||
if (key.escape) return 'quit';
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -220,14 +214,13 @@ export function TreePickerApp(props: TreePickerAppProps): ReactNode {
|
|||
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' ? 10 : 9;
|
||||
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 showSearch = state.search.editing || state.search.query.trim().length > 0;
|
||||
const helpText = props.chrome.helpText ?? DEFAULT_TREE_PICKER_HELP_TEXT;
|
||||
const skipEmptyMessage = props.chrome.skipEmptyMessage ?? DEFAULT_SKIP_EMPTY_MESSAGE;
|
||||
|
||||
|
|
@ -258,7 +251,7 @@ export function TreePickerApp(props: TreePickerAppProps): ReactNode {
|
|||
}, [state.transientHint?.expiresAt]);
|
||||
|
||||
useInput((input, key) => {
|
||||
const command = treePickerCommandForInkInput(input, key, stateRef.current.search, stateRef.current.pendingConfirm);
|
||||
const command = treePickerCommandForInkInput(input, key, stateRef.current);
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -308,16 +301,18 @@ export function TreePickerApp(props: TreePickerAppProps): ReactNode {
|
|||
{warning}
|
||||
</Text>
|
||||
))}
|
||||
{showSearch ? (
|
||||
<Text>
|
||||
<Text color={theme.muted}>/ </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}
|
||||
{state.search.editing ? '█' : ''}
|
||||
<Text inverse> </Text>
|
||||
</Text>
|
||||
<Text color={theme.muted}> ({searchMatchCount} matches)</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
)}
|
||||
<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) => (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue