feat(cli): style next-actions note in TTY mode

Add createAgentNextActionsLineFormatter, an ANSI line transformer wired
into the "Required before using agents" Clack note. It activates only
when the target stream reports hasColors(), so non-TTY pipelines and
tests keep the existing plain-text output byte-identical.

Per-line rules: cyan-bold step numbers + bold titles; dim sub-prose
aligned under the title; dim-cyan bullet for .zip paths with HOME
shortened to ~; dim "›" replaces " > " breadcrumbs; RUN/PASTE/USE/OPEN
markers dimmed; already-styled lines pass through to avoid double-wrap.
This commit is contained in:
Andrey Avtomonov 2026-05-19 15:04:13 +02:00
parent fc6e4a2426
commit 9171ef72f4
2 changed files with 114 additions and 1 deletions

View file

@ -5,6 +5,7 @@ import { readKtxSetupState } from '@ktx/context/project';
import { strFromU8, unzipSync } from 'fflate';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createAgentNextActionsLineFormatter,
formatInstallSummaryLines,
plannedKtxAgentFiles,
readKtxAgentInstallManifest,
@ -1169,4 +1170,63 @@ describe('setup agents', () => {
expect(output).toContain('OpenCode commands installed');
expect(output).toContain('.agents guidance installed');
});
describe('createAgentNextActionsLineFormatter', () => {
function makeColorStdout(): { write: (chunk: string) => boolean; hasColors: () => boolean } {
return { write: () => true, hasColors: () => true };
}
function makePlainStdout(): { write: (chunk: string) => boolean; hasColors: () => boolean } {
return { write: () => true, hasColors: () => false };
}
const ESC = String.fromCharCode(27);
it('returns the line untouched when the stream cannot render colors', () => {
const format = createAgentNextActionsLineFormatter(makePlainStdout());
expect(format('2. Upload Claude Desktop skills')).toBe('2. Upload Claude Desktop skills');
expect(format(' /tmp/ktx/.ktx/agents/claude/ktx.zip')).toBe(' /tmp/ktx/.ktx/agents/claude/ktx.zip');
});
it('styles step headings and aligns sub-prose under the title', () => {
const format = createAgentNextActionsLineFormatter(makeColorStdout());
const heading = format('2. Upload Claude Desktop skills');
expect(heading).toContain(ESC);
expect(heading).toContain('2');
expect(heading).toContain('Upload Claude Desktop skills');
expect(heading).not.toMatch(/^2\. /);
const sub = format(' Toggle the uploaded KTX skills on.');
expect(sub).toMatch(/^ {3}/);
expect(sub).toContain('Toggle the uploaded KTX skills on.');
});
it('renders skill bundle .zip paths as bullets and shortens HOME to ~', () => {
const previousHome = process.env.HOME;
process.env.HOME = '/tmp/test-home';
try {
const format = createAgentNextActionsLineFormatter(makeColorStdout());
const line = format(' /tmp/test-home/.ktx/agents/claude/ktx-analytics.zip');
expect(line).toContain('•');
expect(line).toContain('~/.ktx/agents/claude/ktx-analytics.zip');
expect(line).not.toContain('/tmp/test-home/');
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
}
});
it('replaces breadcrumb separators with a typographic chevron', () => {
const format = createAgentNextActionsLineFormatter(makeColorStdout());
const line = format(' Open Claude Desktop: Customize > Skills > + > Create skill > Upload a skill.');
expect(line).toContain('');
expect(line).not.toContain(' > ');
});
it('leaves already-styled lines untouched to avoid double-wrapping', () => {
const format = createAgentNextActionsLineFormatter(makeColorStdout());
const preStyled = `${ESC}[1m2. Already styled${ESC}[22m`;
expect(format(preStyled)).toBe(preStyled);
});
});
});

View file

@ -3,6 +3,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path';
import type { Writable } from 'node:stream';
import { fileURLToPath } from 'node:url';
import { styleText } from 'node:util';
import { log, outro } from '@clack/prompts';
import {
loadKtxProject,
@ -114,6 +115,56 @@ function writeSetupOutro(io: KtxCliIo, message: string): void {
io.stdout.write(`\n${message}\n`);
}
const STEP_HEADING_RE = /^(\d+)\. (.+)$/;
const ACTION_MARKER_RE = /^(RUN|PASTE|USE|OPEN):$/;
export function createAgentNextActionsLineFormatter(
stdout: KtxCliIo['stdout'],
): (line: string) => string {
const maybeHasColors = (stdout as { hasColors?: unknown }).hasColors;
const supportsColor = typeof maybeHasColors === 'function' && Boolean(maybeHasColors.call(stdout));
if (!supportsColor) return (line) => line;
const homeDir = process.env.HOME ? resolve(process.env.HOME) : '';
const styleOptions = { validateStream: false } as const;
const dim = (s: string) => styleText('dim', s, styleOptions);
const bold = (s: string) => styleText('bold', s, styleOptions);
const cyanBold = (s: string) => styleText(['cyan', 'bold'], s, styleOptions);
const dimCyan = (s: string) => styleText(['dim', 'cyan'], s, styleOptions);
const shortenPath = (path: string): string => {
if (!homeDir) return path;
if (path === homeDir) return '~';
if (path.startsWith(`${homeDir}/`)) return `~/${path.slice(homeDir.length + 1)}`;
return path;
};
return (rawLine: string): string => {
if (rawLine.length === 0 || rawLine.includes('[')) return rawLine;
const heading = rawLine.match(STEP_HEADING_RE);
if (heading) {
return `${cyanBold(heading[1])} ${bold(heading[2])}`;
}
if (!rawLine.startsWith(' ')) return rawLine;
const body = rawLine.slice(2);
if (ACTION_MARKER_RE.test(body)) {
return ` ${dim(body)}`;
}
if (body.endsWith('.zip') && (body.startsWith('/') || body.startsWith('~'))) {
return ` ${dimCyan('•')} ${shortenPath(body)}`;
}
if (body.includes(' > ')) {
return ` ${body.replaceAll(' > ', ` ${dim('')} `)}`;
}
return ` ${dim(body)}`;
};
}
async function readJsonObject(path: string): Promise<Record<string, unknown>> {
if (!existsSync(path)) return {};
const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown;
@ -1235,7 +1286,9 @@ export async function runKtxSetupAgentsStep(
snippets,
});
if (args.showNextActions !== false) {
setupUi.note(nextActions, 'Required before using agents', io, { format: (line) => line });
setupUi.note(nextActions, 'Required before using agents', io, {
format: createAgentNextActionsLineFormatter(io.stdout),
});
writeSetupOutro(io, 'All set.');
}
return { status: 'ready', projectDir: args.projectDir, installs, nextActions };