mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-16 08:25:14 +02:00
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:
parent
fc6e4a2426
commit
9171ef72f4
2 changed files with 114 additions and 1 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue