feat(cli): add ktx wordmark banner to setup intro (#290)

Render a lowercase ktx half-block wordmark with the brand-orange gradient
above the `ktx setup` intro on interactive TTYs. The banner degrades
through truecolor, xterm-256, and monochrome, and is skipped on non-TTY,
non-Unicode, or too-narrow terminals.

Extract shared color/Unicode capability detection into io helpers
(shouldUseColorOutput, colorDepthForOutput, unicodeSupported) so the
banner and doctor report route through one implementation.
This commit is contained in:
Andrey Avtomonov 2026-06-10 16:47:34 +02:00 committed by GitHub
parent 56e06334d2
commit 28953eb616
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 154 additions and 12 deletions

View file

@ -5,6 +5,7 @@ import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import type { KtxConfigIssue } from './context/project/config.js';
import { shouldUseColorOutput } from './io/tty.js';
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
import type { BuildProjectStatusOptions } from './status-project.js';
@ -233,12 +234,6 @@ const GROUP_LABEL: Record<DoctorGroup, string> = {
history: 'Query history',
};
function shouldUseColor(io: KtxDoctorIo): boolean {
if (io.stdout.isTTY !== true) return false;
const env = process.env;
return !env.NO_COLOR && env.TERM !== 'dumb' && !env.CI;
}
function styleStatus(useColor: boolean, status: DoctorStatus, text: string): string {
if (!useColor) return text;
const code = status === 'pass' ? 32 : status === 'warn' ? 33 : 31;
@ -480,7 +475,7 @@ export function renderInvalidConfigMessage(
return;
}
const useColor = shouldUseColor(io);
const useColor = shouldUseColorOutput(io.stdout);
const dim = (text: string) => styleDim(useColor, text);
const bold = (text: string) => styleBold(useColor, text);
const status = (s: DoctorStatus, text: string) => styleStatus(useColor, s, text);
@ -522,7 +517,7 @@ export function renderValidConfigMessage(
return;
}
const useColor = shouldUseColor(io);
const useColor = shouldUseColorOutput(io.stdout);
const dim = (text: string) => styleDim(useColor, text);
const bold = (text: string) => styleBold(useColor, text);
const status = (s: DoctorStatus, text: string) => styleStatus(useColor, s, text);
@ -557,7 +552,7 @@ export function renderMissingProjectMessage(
return;
}
const useColor = shouldUseColor(io);
const useColor = shouldUseColorOutput(io.stdout);
const dim = (text: string) => styleDim(useColor, text);
const bold = (text: string) => styleBold(useColor, text);
const abbreviated = abbreviateHome(projectDir) ?? projectDir;
@ -638,7 +633,7 @@ export async function runKtxDoctor(
io.stdout.write(
renderProjectStatus(projectStatus, {
verbose,
useColor: shouldUseColor(io),
useColor: shouldUseColorOutput(io.stdout),
durationMs: Date.now() - startedAt,
toolchainChecks,
}),
@ -651,7 +646,7 @@ export async function runKtxDoctor(
const report: DoctorReport = { title: 'KTX status', checks: setupChecks };
const renderOptions: RenderOptions = {
verbose: args.verbose ?? false,
useColor: shouldUseColor(io),
useColor: shouldUseColorOutput(io.stdout),
durationMs: Date.now() - startedAt,
command: args.command,
};

View file

@ -14,6 +14,9 @@ function detectUnicodeSupport(env: NodeJS.ProcessEnv = process.env): boolean {
const unicode = detectUnicodeSupport();
/** Whether the active terminal renders Unicode glyphs (block/box drawing, arrows). */
export const unicodeSupported = unicode;
export const SYMBOLS = {
middot: unicode ? '·' : '-',
emDash: unicode ? '—' : '--',

View file

@ -15,3 +15,19 @@ export function isWritableTtyOutput(output: KtxCliOutput): output is KtxCliOutpu
typeof (output as { columns?: unknown }).columns !== 'undefined'
);
}
export function shouldUseColorOutput(output: { isTTY?: boolean }): boolean {
if (output.isTTY !== true) return false;
const env = process.env;
return !env.NO_COLOR && env.TERM !== 'dumb' && !env.CI;
}
/**
* Color depth in bits for the given output: 1 when color is disabled, the
* stream-reported depth when available, and a 16-color baseline otherwise.
*/
export function colorDepthForOutput(output: KtxCliOutput): number {
if (!shouldUseColorOutput(output)) return 1;
const getColorDepth = (output as { getColorDepth?: () => number }).getColorDepth;
return typeof getColorDepth === 'function' ? getColorDepth.call(output) : 4;
}

View file

@ -0,0 +1,61 @@
/**
* The `ktx setup` intro banner: a lowercase ktx wordmark drawn with
* half-block glyphs over the brand-orange gradient, followed by the product
* tagline. Rendering is a pure function of the options so output stays
* deterministic in tests.
*/
interface KtxBannerRow {
art: string;
/** Truecolor gradient stop; the middle row is ktx orange #f97316. */
rgb: readonly [number, number, number];
/** Closest xterm-256 color to {@link KtxBannerRow.rgb}. */
ansi256: number;
}
const WORDMARK: readonly KtxBannerRow[] = [
{ art: '███ ███', rgb: [253, 186, 116], ansi256: 215 },
{ art: '███ ▄██▀ ▀▀███▀▀ ▀██▄ ▄██▀', rgb: [251, 146, 60], ansi256: 214 },
{ art: '███▄██▀ ███ ▀████▀', rgb: [249, 115, 22], ansi256: 208 },
{ art: '███▀██▄ ███ ▄████▄', rgb: [234, 88, 12], ansi256: 202 },
{ art: '███ ▀██▄ ███ ▄██▀ ▀██▄', rgb: [194, 65, 12], ansi256: 166 },
];
const TAGLINE = 'context layer for data agents';
const INDENT = ' ';
const BANNER_WIDTH = Math.max(...WORDMARK.map((row) => row.art.length), TAGLINE.length) + INDENT.length;
export interface KtxSetupBannerOptions {
/** Terminal width in columns. */
columns: number;
/** Color depth in bits, as reported by `tty.WriteStream#getColorDepth`; 1 disables color. */
colorDepth: number;
/** Whether the terminal renders Unicode block glyphs. */
unicode: boolean;
}
/**
* Returns the banner block ending right above the clack intro line, or an
* empty string when the terminal cannot display it (no Unicode support or
* too narrow).
*/
export function renderKtxSetupBanner(options: KtxSetupBannerOptions): string {
if (!options.unicode || options.columns < BANNER_WIDTH) {
return '';
}
const art = WORDMARK.map((row) => INDENT + colorizeBannerRow(row, options.colorDepth));
const tagline = INDENT + (options.colorDepth > 1 ? `\u001b[2m${TAGLINE}\u001b[22m` : TAGLINE);
return `\n${art.join('\n')}\n\n${tagline}\n\n`;
}
function colorizeBannerRow(row: KtxBannerRow, colorDepth: number): string {
if (colorDepth >= 24) {
const [r, g, b] = row.rgb;
return `\u001b[38;2;${r};${g};${b}m${row.art}\u001b[39m`;
}
if (colorDepth >= 8) {
return `\u001b[38;5;${row.ansi256}m${row.art}\u001b[39m`;
}
return row.art;
}

View file

@ -12,8 +12,10 @@ import {
text,
} from '@clack/prompts';
import type { KtxCliIo } from './cli-runtime.js';
import { isWritableTtyOutput } from './io/tty.js';
import { unicodeSupported } from './io/symbols.js';
import { colorDepthForOutput, isWritableTtyOutput } from './io/tty.js';
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
import { renderKtxSetupBanner } from './setup-banner.js';
import { revealPassword } from './reveal-password-prompt.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
@ -215,6 +217,14 @@ export function createKtxSetupUiAdapter(): KtxSetupUiAdapter {
return {
intro(title, io) {
if (isWritableTtyOutput(io.stdout)) {
const banner = renderKtxSetupBanner({
columns: io.stdout.columns ?? 80,
colorDepth: colorDepthForOutput(io.stdout),
unicode: unicodeSupported,
});
if (banner !== '') {
io.stdout.write(banner);
}
intro(title, { output: io.stdout });
return;
}

View file

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { renderKtxSetupBanner } from '../src/setup-banner.js';
const WIDE = { columns: 120, colorDepth: 1, unicode: true };
describe('renderKtxSetupBanner', () => {
it('renders the wordmark and tagline without ANSI codes when color is off', () => {
const banner = renderKtxSetupBanner(WIDE);
expect(banner).toContain('██');
expect(banner).toContain('context layer for data agents');
expect(banner).not.toContain('\u001b[');
expect(banner.endsWith('\n\n')).toBe(true);
});
it('fits within the reported terminal width', () => {
const banner = renderKtxSetupBanner({ ...WIDE, columns: 40 });
for (const line of banner.split('\n')) {
expect(line.length).toBeLessThanOrEqual(40);
}
expect(banner).not.toBe('');
});
it('uses truecolor gradient codes at 24-bit depth', () => {
const banner = renderKtxSetupBanner({ ...WIDE, colorDepth: 24 });
expect(banner).toContain('\u001b[38;2;249;115;22m');
expect(banner).toContain('\u001b[2mcontext layer for data agents\u001b[22m');
});
it('falls back to xterm-256 codes at 8-bit depth', () => {
const banner = renderKtxSetupBanner({ ...WIDE, colorDepth: 8 });
expect(banner).toContain('\u001b[38;5;208m');
expect(banner).not.toContain('\u001b[38;2;');
});
it('renders monochrome art at 16-color depth', () => {
const banner = renderKtxSetupBanner({ ...WIDE, colorDepth: 4 });
expect(banner).toContain('██');
expect(banner).not.toContain('\u001b[38;');
});
it('returns an empty string when the terminal is too narrow', () => {
expect(renderKtxSetupBanner({ ...WIDE, columns: 24 })).toBe('');
});
it('returns an empty string without Unicode support', () => {
expect(renderKtxSetupBanner({ ...WIDE, unicode: false })).toBe('');
});
});

View file

@ -254,6 +254,9 @@ describe('setup prompt adapter', () => {
ui.intro('KTX setup', io);
ui.note(' $ ktx status', 'What you can do next', io);
const bannerWrite = output.write.mock.calls.map((call) => String(call[0])).join('');
expect(bannerWrite).toContain('██');
expect(bannerWrite).toContain('context layer for data agents');
expect(mocks.intro).toHaveBeenCalledWith('KTX setup', { output });
expect(mocks.note).toHaveBeenCalledWith(' $ ktx status', 'What you can do next', { output });
});