diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index a52a3eba..b94d65db 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -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 ` | Context-source connector type: `dbt`, `metricflow`, `metabase`, `looker`, `lookml`, or `notion` | diff --git a/packages/cli/src/setup-databases.test.ts b/packages/cli/src/setup-databases.test.ts index a9da0f51..354ba24b 100644 --- a/packages/cli/src/setup-databases.test.ts +++ b/packages/cli/src/setup-databases.test.ts @@ -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' }, ], diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index 058692ae..7781610c 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -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, diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts index ff8513b7..26dc0324 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/src/setup.test.ts @@ -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', diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 825170c0..422f95c5 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -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, ); diff --git a/packages/cli/src/tree-picker-state.test.ts b/packages/cli/src/tree-picker-state.test.ts index 20c2001e..e8d6afd1 100644 --- a/packages/cli/src/tree-picker-state.test.ts +++ b/packages/cli/src/tree-picker-state.test.ts @@ -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()), diff --git a/packages/cli/src/tree-picker-state.ts b/packages/cli/src/tree-picker-state.ts index 3e96e096..2392ef68 100644 --- a/packages/cli/src/tree-picker-state.ts +++ b/packages/cli/src/tree-picker-state.ts @@ -25,7 +25,8 @@ export interface PickerState { expanded: Set; checked: Set; 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, + }; } } diff --git a/packages/cli/src/tree-picker-tui.test.tsx b/packages/cli/src/tree-picker-tui.test.tsx index 3a8dcc7b..18877778 100644 --- a/packages/cli/src/tree-picker-tui.test.tsx +++ b/packages/cli/src/tree-picker-tui.test.tsx @@ -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'); diff --git a/packages/cli/src/tree-picker-tui.tsx b/packages/cli/src/tree-picker-tui.tsx index 57525270..94fc0dd6 100644 --- a/packages/cli/src/tree-picker-tui.tsx +++ b/packages/cli/src/tree-picker-tui.tsx @@ -32,7 +32,7 @@ const NO_COLOR_THEME = { type TreePickerTheme = Record; 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, ): 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} ))} - {showSearch ? ( - - / + + Search: + {state.isNavigating ? ( + {state.search.query || '(type to filter)'} + ) : ( {state.search.query} - {state.search.editing ? '█' : ''} + - ({searchMatchCount} matches) - - ) : null} + )} + ({searchMatchCount} match{searchMatchCount === 1 ? '' : 'es'}) + {hiddenAbove > 0 ? ↑ {hiddenAbove} more : null} {rows.items.map((nodeId) => (