mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
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:
parent
56e06334d2
commit
28953eb616
7 changed files with 154 additions and 12 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 ? '—' : '--',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
61
packages/cli/src/setup-banner.ts
Normal file
61
packages/cli/src/setup-banner.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
54
packages/cli/test/setup-banner.test.ts
Normal file
54
packages/cli/test/setup-banner.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue