diff --git a/packages/cli/src/prompt-navigation.test.ts b/packages/cli/src/prompt-navigation.test.ts index 4dd428df..9338b56e 100644 --- a/packages/cli/src/prompt-navigation.test.ts +++ b/packages/cli/src/prompt-navigation.test.ts @@ -28,12 +28,12 @@ describe('prompt navigation helpers', () => { 'Name this PostgreSQL connection\nKTX will use this short name in commands and config. You can rename it now.', ), ).toBe( - 'Name this PostgreSQL connection\n\nKTX will use this short name in commands and config. You can rename it now.\nPress Escape to go back.\n', + 'Name this PostgreSQL connection\n│\n│ KTX will use this short name in commands and config. You can rename it now.\n│ Press Escape to go back.\n│', ); }); it('adds a blank separator before compact text input values', () => { - expect(withTextInputNavigation('Project folder path')).toBe('Project folder path\nPress Escape to go back.\n'); + expect(withTextInputNavigation('Project folder path')).toBe('Project folder path\n│ Press Escape to go back.\n│'); }); it('normalizes already hinted text input prompts without duplicating the hint', () => { @@ -42,7 +42,19 @@ describe('prompt navigation helpers', () => { 'Name this PostgreSQL connection\nKTX will use this short name in commands and config. You can rename it now.\nPress Escape to go back.', ), ).toBe( - 'Name this PostgreSQL connection\n\nKTX will use this short name in commands and config. You can rename it now.\nPress Escape to go back.\n', + 'Name this PostgreSQL connection\n│\n│ KTX will use this short name in commands and config. You can rename it now.\n│ Press Escape to go back.\n│', ); }); + + it('is idempotent when text input navigation is applied twice', () => { + const once = withTextInputNavigation('Project folder path'); + expect(withTextInputNavigation(once)).toBe(once); + }); + + it('is idempotent when text input navigation with body is applied twice', () => { + const once = withTextInputNavigation( + 'Name this PostgreSQL connection\nKTX will use this short name in commands and config.', + ); + expect(withTextInputNavigation(once)).toBe(once); + }); }); diff --git a/packages/cli/src/prompt-navigation.ts b/packages/cli/src/prompt-navigation.ts index d80f2f97..c3644338 100644 --- a/packages/cli/src/prompt-navigation.ts +++ b/packages/cli/src/prompt-navigation.ts @@ -6,6 +6,26 @@ function removeTrailingBlankLines(message: string): string { return message.replace(/\n+$/, ''); } +function prefixContinuationLines(message: string): string { + const lines = message.split('\n'); + if (lines.length <= 1) return message; + const [title, ...body] = lines; + let trailingEmptyCount = 0; + while (trailingEmptyCount < body.length && body[body.length - 1 - trailingEmptyCount] === '') { + trailingEmptyCount++; + } + const contentBody = trailingEmptyCount > 0 ? body.slice(0, -trailingEmptyCount) : body; + const trailingBody = trailingEmptyCount > 0 ? body.slice(-trailingEmptyCount) : []; + return [ + title, + ...contentBody.map((line) => { + const stripped = line.replace(/^│\s*/, ''); + return stripped === '' ? '│' : `│ ${stripped}`; + }), + ...trailingBody, + ].join('\n'); +} + function withTextInputBodySpacing(message: string): string { const normalized = removeTrailingBlankLines(message); if (!normalized.includes('\n')) { @@ -39,7 +59,9 @@ export function withMultiselectNavigation(message: string): string { export function withTextInputNavigation(message: string): string { const messageWithoutHint = removeTrailingBlankLines(message) .split('\n') - .filter((line) => line !== TEXT_INPUT_NAVIGATION_HINT) + .filter((line) => !line.includes(TEXT_INPUT_NAVIGATION_HINT)) + .map((line) => line.replace(/^│\s*/, '')) .join('\n'); - return `${withTextInputBodySpacing(messageWithoutHint)}\n${TEXT_INPUT_NAVIGATION_HINT}\n`; + const full = `${withTextInputBodySpacing(messageWithoutHint)}\n${TEXT_INPUT_NAVIGATION_HINT}`; + return `${prefixContinuationLines(full)}\n│`; } diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index 1ce9b2e6..6a9721b9 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -375,7 +375,7 @@ export async function runKtxSetupAgentsStep( deps: KtxSetupAgentsDeps = {}, ): Promise { if (args.skipAgents) { - io.stdout.write('Agent integration skipped.\n'); + io.stdout.write('│ Agent integration skipped.\n'); return { status: 'skipped', projectDir: args.projectDir }; } if (!args.agents && args.inputMode === 'disabled') { diff --git a/packages/cli/src/setup-databases.test.ts b/packages/cli/src/setup-databases.test.ts index de91ae3f..4c2abfcd 100644 --- a/packages/cli/src/setup-databases.test.ts +++ b/packages/cli/src/setup-databases.test.ts @@ -58,10 +58,10 @@ function connectionNamePrompt(label: string): string { function textInputPrompt(message: string): string { const normalized = message.replace(/\n+$/, ''); if (!normalized.includes('\n')) { - return `${normalized}\nPress Escape to go back.\n`; + return `${normalized}\n│ Press Escape to go back.\n│`; } const [title, ...bodyLines] = normalized.split('\n'); - return `${title}\n\n${bodyLines.join('\n')}\nPress Escape to go back.\n`; + return `${title}\n│\n│ ${bodyLines.join('\n│ ')}\n│ Press Escape to go back.\n│`; } const legacyHistoricSqlServiceAccountPatternsKey = ['serviceAccount', 'UserPatterns'].join(''); diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index 080970cc..bbb39836 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -1115,7 +1115,7 @@ async function maybeRunHistoricSqlSetupProbe(input: { return; } - input.io.stdout.write('Historic SQL probe...\n'); + input.io.stdout.write('│ Historic SQL probe...\n'); const probe = input.deps.historicSqlProbe ?? defaultHistoricSqlProbe; const result = await probe({ projectDir: input.projectDir, @@ -1123,10 +1123,10 @@ async function maybeRunHistoricSqlSetupProbe(input: { dialect: 'postgres', }); for (const line of result.lines) { - input.io.stdout.write(`${line}\n`); + input.io.stdout.write(`│${line}\n`); } if (!result.ok) { - input.io.stdout.write('Setup written; first ingest run will fail until fixed.\n'); + input.io.stdout.write('│ Setup written; first ingest run will fail until fixed.\n'); } } @@ -1261,7 +1261,7 @@ async function chooseDrivers( return 'back'; } - io.stdout.write('KTX cannot work without at least one primary source. Select a source or press Escape to go back.\n'); + io.stdout.write('│ KTX cannot work without at least one primary source. Select a source or press Escape to go back.\n'); } } @@ -1325,7 +1325,7 @@ export async function runKtxSetupDatabasesStep( deps: KtxSetupDatabasesDeps = {}, ): Promise { if (args.skipDatabases) { - io.stdout.write('Primary source setup skipped. KTX cannot work until you add a primary source.\n'); + io.stdout.write('│ Primary source setup skipped. KTX cannot work until you add a primary source.\n'); return { status: 'skipped', projectDir: args.projectDir }; } @@ -1382,7 +1382,7 @@ export async function runKtxSetupDatabasesStep( if (drivers === 'missing-input') return { status: 'missing-input', projectDir: args.projectDir }; if (drivers.length === 0) { await markDatabasesComplete(args.projectDir, []); - io.stdout.write('KTX cannot work without a primary source.\n'); + io.stdout.write('│ KTX cannot work without a primary source.\n'); return { status: 'skipped', projectDir: args.projectDir }; } diff --git a/packages/cli/src/setup-embeddings.test.ts b/packages/cli/src/setup-embeddings.test.ts index 24c567ad..5f37697e 100644 --- a/packages/cli/src/setup-embeddings.test.ts +++ b/packages/cli/src/setup-embeddings.test.ts @@ -199,7 +199,7 @@ describe('setup embeddings step', () => { await vi.waitFor(() => { expect(io.stdout()).toContain( - '\r- Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.', + '\r│ - Testing local sentence-transformers embeddings (all-MiniLM-L6-v2, 384 dimensions). First run may take up to 60 seconds.', ); }); diff --git a/packages/cli/src/setup-embeddings.ts b/packages/cli/src/setup-embeddings.ts index f7995796..9354ad75 100644 --- a/packages/cli/src/setup-embeddings.ts +++ b/packages/cli/src/setup-embeddings.ts @@ -260,7 +260,7 @@ async function chooseCredentialRef( } if (choice === 'paste') { io.stdout.write( - `${[ + `│ ${[ `KTX will save the key in .ktx/secrets/${backend}-api-key with local file permissions,`, 'then write a file: reference in ktx.yaml.', ].join(' ')}\n`, @@ -352,7 +352,7 @@ function healthCheckStartText(backend: KtxSetupEmbeddingBackend, model: string, function startHealthCheckProgress(io: KtxCliIo, message: string): HealthCheckProgress { if (io.stdout.isTTY !== true) { - io.stdout.write(`${message}\n`); + io.stdout.write(`│ ${message}\n`); const noop = () => undefined; return { succeed: noop, @@ -363,7 +363,7 @@ function startHealthCheckProgress(io: KtxCliIo, message: string): HealthCheckPro let frameIndex = 0; let stopped = false; const writeFrame = () => { - io.stdout.write(`${CLEAR_CURRENT_LINE}${HEALTH_CHECK_SPINNER_FRAMES[frameIndex]} ${message}`); + io.stdout.write(`${CLEAR_CURRENT_LINE}│ ${HEALTH_CHECK_SPINNER_FRAMES[frameIndex]} ${message}`); }; writeFrame(); const interval = setInterval(() => { @@ -377,7 +377,7 @@ function startHealthCheckProgress(io: KtxCliIo, message: string): HealthCheckPro } stopped = true; clearInterval(interval); - io.stdout.write(`${CLEAR_CURRENT_LINE}${finalMessage}\n`); + io.stdout.write(`${CLEAR_CURRENT_LINE}│ ${finalMessage}\n`); }; return { @@ -396,7 +396,7 @@ export async function runKtxSetupEmbeddingsStep( deps: KtxSetupEmbeddingsDeps = {}, ): Promise { if (args.skipEmbeddings) { - io.stdout.write('Embeddings setup skipped.\n'); + io.stdout.write('│ Embeddings setup skipped.\n'); return { status: 'skipped', projectDir: args.projectDir }; } @@ -408,7 +408,7 @@ export async function runKtxSetupEmbeddingsStep( !args.embeddingApiKeyEnv && !args.embeddingApiKeyFile ) { - io.stdout.write(`Embeddings ready: yes (${project.config.ingest.embeddings.model})\n`); + io.stdout.write(`│ Embeddings ready: yes (${project.config.ingest.embeddings.model})\n`); return { status: 'ready', projectDir: args.projectDir }; } @@ -495,7 +495,7 @@ export async function runKtxSetupEmbeddingsStep( credentialRef, }), ); - io.stdout.write(`Embeddings ready: yes (${model}, ${dimensions} dimensions)\n`); + io.stdout.write(`│ Embeddings ready: yes (${model}, ${dimensions} dimensions)\n`); return { status: 'ready', projectDir: args.projectDir }; } diff --git a/packages/cli/src/setup-models.test.ts b/packages/cli/src/setup-models.test.ts index 0c7686f7..82f82875 100644 --- a/packages/cli/src/setup-models.test.ts +++ b/packages/cli/src/setup-models.test.ts @@ -312,7 +312,7 @@ describe('setup Anthropic model step', () => { expect(result.status).toBe('ready'); expect(prompts.select).not.toHaveBeenCalledWith(expect.objectContaining({ message: 'Paste Anthropic API key now?' })); expect(prompts.password).toHaveBeenCalledWith({ - message: 'Anthropic API key\nPress Escape to go back.\n', + message: 'Anthropic API key\n│ Press Escape to go back.\n│', }); }); @@ -464,7 +464,7 @@ describe('setup Anthropic model step', () => { ); expect(prompts.text).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Anthropic model ID\nPress Escape to go back.\n', + message: 'Anthropic model ID\n│ Press Escape to go back.\n│', placeholder: 'claude-sonnet-4-6', }), ); @@ -629,7 +629,7 @@ describe('setup Anthropic model step', () => { expect(result.status).toBe('ready'); expect(prompts.password).toHaveBeenCalledWith({ - message: 'Anthropic API key\nPress Escape to go back.\n', + message: 'Anthropic API key\n│ Press Escape to go back.\n│', }); await expect(readFile(join(tempDir, '.ktx/secrets/anthropic-api-key'), 'utf-8')).rejects.toMatchObject({ code: 'ENOENT', diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index 843691cd..6d3c6757 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -255,7 +255,7 @@ async function chooseCredentialRef( const prompts = deps.prompts ?? createPromptAdapter(); if (args.showPromptInstructions !== false) { io.stdout.write( - 'Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n', + '│ Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n', ); } while (true) { @@ -272,7 +272,7 @@ async function chooseCredentialRef( } if (choice === 'paste') { io.stdout.write( - 'KTX will save the key in .ktx/secrets/anthropic-api-key with local file permissions, then write a file: reference in ktx.yaml.\n', + '│ KTX will save the key in .ktx/secrets/anthropic-api-key with local file permissions, then write a file: reference in ktx.yaml.\n', ); const value = await prompts.password({ message: withTextInputNavigation('Anthropic API key') }); if (value === undefined) { @@ -394,7 +394,7 @@ export async function runKtxSetupAnthropicModelStep( deps: KtxSetupModelDeps = {}, ): Promise { if (args.skipLlm) { - io.stdout.write('LLM setup skipped.\n'); + io.stdout.write('│ LLM setup skipped.\n'); return { status: 'skipped', projectDir: args.projectDir }; } @@ -406,7 +406,7 @@ export async function runKtxSetupAnthropicModelStep( !args.anthropicApiKeyFile && !args.anthropicModel ) { - io.stdout.write(`LLM ready: yes (${project.config.llm.models.default})\n`); + io.stdout.write(`│ LLM ready: yes (${project.config.llm.models.default})\n`); return { status: 'ready', projectDir: args.projectDir }; } @@ -439,7 +439,7 @@ export async function runKtxSetupAnthropicModelStep( const health = await healthCheck(buildHealthConfig(credential.value, model.model)); if (health.ok) { await persistLlmConfig(args.projectDir, credential.ref, model.model); - io.stdout.write(`LLM ready: yes (${model.model})\n`); + io.stdout.write(`│ LLM ready: yes (${model.model})\n`); return { status: 'ready', projectDir: args.projectDir }; } diff --git a/packages/cli/src/setup-project.test.ts b/packages/cli/src/setup-project.test.ts index 3dd1b0cd..5562c59c 100644 --- a/packages/cli/src/setup-project.test.ts +++ b/packages/cli/src/setup-project.test.ts @@ -190,7 +190,7 @@ describe('setup project step', () => { ); expect(prompts.text).not.toHaveBeenCalled(); expect(result.status === 'ready' ? result.project.config.project : '').toBe('ktx-project'); - expect(testIo.stdout()).toContain(`KTX will create:\n ${projectDir}`); + expect(testIo.stdout()).toContain(`│ KTX will create:\n│ ${projectDir}`); await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined(); }); @@ -209,7 +209,7 @@ describe('setup project step', () => { expect(result.projectDir).toBe(projectDir); expect(prompts.text).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Project folder path\nPress Escape to go back.\n', + message: 'Project folder path\n│ Press Escape to go back.\n│', placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx', }), ); diff --git a/packages/cli/src/setup-project.ts b/packages/cli/src/setup-project.ts index d164e41d..175adaf7 100644 --- a/packages/cli/src/setup-project.ts +++ b/packages/cli/src/setup-project.ts @@ -143,7 +143,7 @@ async function loadExistingProject(projectDir: string, deps: KtxSetupProjectDeps } function printProjectSummary(io: KtxCliIo, projectDir: string): void { - io.stdout.write(`Project: ${projectDir}\n`); + io.stdout.write(`│ Project: ${projectDir}\n`); } async function promptForNewProjectDir( @@ -155,8 +155,8 @@ async function promptForNewProjectDir( const defaultProjectDir = join(projectDir, DEFAULT_NEW_PROJECT_FOLDER_NAME); while (true) { - io.stdout.write(`Relative paths are resolved from:\n ${projectDir}\n`); - io.stdout.write(`Home paths are resolved from:\n ${homeDir}\n`); + io.stdout.write(`│ Relative paths are resolved from:\n│ ${projectDir}\n`); + io.stdout.write(`│ Home paths are resolved from:\n│ ${homeDir}\n`); const destinationChoice = await prompts.select({ message: 'Where should KTX create the project?', options: [ @@ -220,7 +220,7 @@ async function promptForNewProjectDir( confirmedCreation = true; } - io.stdout.write(`KTX will create:\n ${selectedDir}\n`); + io.stdout.write(`│ KTX will create:\n│ ${selectedDir}\n`); if (state !== 'non-empty-directory') { const createAction = await prompts.select({ message: `Create KTX project at ${selectedDir}?`, @@ -324,7 +324,7 @@ export async function runKtxSetupProjectStep( const prompts = deps.prompts ?? createClackSetupProjectPromptAdapter(); io.stdout.write( - 'Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n', + '│ Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n', ); while (true) { const choice = await prompts.select({ diff --git a/packages/cli/src/setup-sources.test.ts b/packages/cli/src/setup-sources.test.ts index b75e2f65..edadc17e 100644 --- a/packages/cli/src/setup-sources.test.ts +++ b/packages/cli/src/setup-sources.test.ts @@ -66,10 +66,10 @@ function connectionNamePrompt(label: string): string { function textInputPrompt(message: string): string { const normalized = message.replace(/\n+$/, ''); if (!normalized.includes('\n')) { - return `${normalized}\nPress Escape to go back.\n`; + return `${normalized}\n│ Press Escape to go back.\n│`; } const [title, ...bodyLines] = normalized.split('\n'); - return `${title}\n\n${bodyLines.join('\n')}\nPress Escape to go back.\n`; + return `${title}\n│\n│ ${bodyLines.join('\n│ ')}\n│ Press Escape to go back.\n│`; } describe('setup sources step', () => { diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index 42b7a7a9..dc010b0a 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -699,7 +699,7 @@ async function runInitialSourceIngestWithRecovery(input: { deps: KtxSetupSourcesDeps; }): Promise<'ready' | 'continue' | 'back' | 'failed'> { while (true) { - input.io.stdout.write(`Building context from ${input.connectionId}. Large sources can take a while.\n`); + input.io.stdout.write(`│ Building context from ${input.connectionId}. Large sources can take a while.\n`); const ingestCode = await (input.deps.runInitialIngest ?? defaultRunInitialIngest)( input.args.projectDir, input.connectionId, @@ -727,8 +727,8 @@ async function runInitialSourceIngestWithRecovery(input: { continue; } if (action === 'continue') { - input.io.stdout.write(`Context source saved without a completed context build for ${input.connectionId}.\n`); - input.io.stdout.write(`Run later: ktx ingest ${input.connectionId}\n`); + input.io.stdout.write(`│ Context source saved without a completed context build for ${input.connectionId}.\n`); + input.io.stdout.write(`│ Run later: ktx ingest ${input.connectionId}\n`); return 'continue'; } return 'back'; @@ -1355,7 +1355,7 @@ export async function runKtxSetupSourcesStep( try { if (args.skipSources) { await markSourcesComplete(args.projectDir); - io.stdout.write('Context source setup skipped.\n'); + io.stdout.write('│ Context source setup skipped.\n'); return { status: 'skipped', projectDir: args.projectDir }; } @@ -1368,7 +1368,7 @@ export async function runKtxSetupSourcesStep( return { status: 'failed', projectDir: args.projectDir }; } if (args.inputMode !== 'disabled') { - io.stdout.write(`${message}\n`); + io.stdout.write(`│ ${message}\n`); return { status: 'skipped', projectDir: args.projectDir }; } } @@ -1392,7 +1392,7 @@ export async function runKtxSetupSourcesStep( return { status: 'missing-input', projectDir: args.projectDir }; } await markSourcesComplete(args.projectDir); - io.stdout.write('No context sources selected.\n'); + io.stdout.write('│ No context sources selected.\n'); return { status: 'skipped', projectDir: args.projectDir }; } @@ -1465,7 +1465,7 @@ export async function runKtxSetupSourcesStep( break; } } else { - io.stdout.write(`Context source ${connectionId} saved. It will be built during the context build step.\n`); + io.stdout.write(`│ Context source ${connectionId} saved. It will be built during the context build step.\n`); } readyConnectionIds.push(connectionId); } diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts index b36b0923..e74dca5d 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/src/setup.test.ts @@ -715,7 +715,7 @@ describe('setup status', () => { expect(projectPrompts.text).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Project folder path\nPress Escape to go back.\n', + message: 'Project folder path\n│ Press Escape to go back.\n│', placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx', }), );