From 28953eb616ad90005a4423157159f575b3521695 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 10 Jun 2026 16:47:34 +0200 Subject: [PATCH] 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. --- packages/cli/src/doctor.ts | 17 +++---- packages/cli/src/io/symbols.ts | 3 ++ packages/cli/src/io/tty.ts | 16 +++++++ packages/cli/src/setup-banner.ts | 61 +++++++++++++++++++++++++ packages/cli/src/setup-prompts.ts | 12 ++++- packages/cli/test/setup-banner.test.ts | 54 ++++++++++++++++++++++ packages/cli/test/setup-prompts.test.ts | 3 ++ 7 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/setup-banner.ts create mode 100644 packages/cli/test/setup-banner.test.ts diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts index 8eac91fa..7db51492 100644 --- a/packages/cli/src/doctor.ts +++ b/packages/cli/src/doctor.ts @@ -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 = { 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, }; diff --git a/packages/cli/src/io/symbols.ts b/packages/cli/src/io/symbols.ts index fe6045b3..52f05f49 100644 --- a/packages/cli/src/io/symbols.ts +++ b/packages/cli/src/io/symbols.ts @@ -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 ? '—' : '--', diff --git a/packages/cli/src/io/tty.ts b/packages/cli/src/io/tty.ts index 43467d4e..189f1660 100644 --- a/packages/cli/src/io/tty.ts +++ b/packages/cli/src/io/tty.ts @@ -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; +} diff --git a/packages/cli/src/setup-banner.ts b/packages/cli/src/setup-banner.ts new file mode 100644 index 00000000..29455a78 --- /dev/null +++ b/packages/cli/src/setup-banner.ts @@ -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; +} diff --git a/packages/cli/src/setup-prompts.ts b/packages/cli/src/setup-prompts.ts index e135004f..c6549222 100644 --- a/packages/cli/src/setup-prompts.ts +++ b/packages/cli/src/setup-prompts.ts @@ -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; } diff --git a/packages/cli/test/setup-banner.test.ts b/packages/cli/test/setup-banner.test.ts new file mode 100644 index 00000000..1e0a2c60 --- /dev/null +++ b/packages/cli/test/setup-banner.test.ts @@ -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(''); + }); +}); diff --git a/packages/cli/test/setup-prompts.test.ts b/packages/cli/test/setup-prompts.test.ts index 8e83c558..f3c2109b 100644 --- a/packages/cli/test/setup-prompts.test.ts +++ b/packages/cli/test/setup-prompts.test.ts @@ -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 }); });