Merge pull request #45 from Kaelio/luca/klo-654-improve-indents

feat(cli): add box-drawing prefixes to setup messages
This commit is contained in:
Luca Martial 2026-05-12 19:58:55 -04:00 committed by GitHub
commit fcdf5234c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 81 additions and 47 deletions

View file

@ -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\nKTX will use this short name in commands and config. You can rename it now.\nPress 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\nPress 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\nKTX will use this short name in commands and config. You can rename it now.\nPress 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);
});
});

View file

@ -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│`;
}

View file

@ -375,7 +375,7 @@ export async function runKtxSetupAgentsStep(
deps: KtxSetupAgentsDeps = {},
): Promise<KtxSetupAgentsResult> {
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') {

View file

@ -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}\nPress 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')}\nPress Escape to go back.\n`;
}
const legacyHistoricSqlServiceAccountPatternsKey = ['serviceAccount', 'UserPatterns'].join('');

View file

@ -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<KtxSetupDatabasesResult> {
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 };
}

View file

@ -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.',
);
});

View file

@ -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<KtxSetupEmbeddingsResult> {
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 };
}

View file

@ -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\nPress 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\nPress 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\nPress Escape to go back.\n',
});
await expect(readFile(join(tempDir, '.ktx/secrets/anthropic-api-key'), 'utf-8')).rejects.toMatchObject({
code: 'ENOENT',

View file

@ -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<KtxSetupModelResult> {
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 };
}

View file

@ -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\nPress Escape to go back.\n',
placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx',
}),
);

View file

@ -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({

View file

@ -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}\nPress 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')}\nPress Escape to go back.\n`;
}
describe('setup sources step', () => {

View file

@ -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);
}

View file

@ -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\nPress Escape to go back.\n',
placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx',
}),
);