From d017d7451983f16862816f67e6ac295af16f580f Mon Sep 17 00:00:00 2001 From: george-keygraph Date: Wed, 26 Aug 2026 11:09:17 -0700 Subject: [PATCH] feat(cli): sunset wordmark and section hierarchy in terminal output Add a section-chrome module (chrome.ts) and a log decorator (log-render.ts) that give the CLI a coherent visual hierarchy beneath the SHANNON splash, all drawn from the existing sunset ramp: - splash.ts now sources the ramp from chrome.ts (one definition) - start's launch info gets a solid-yellow rule under each section label, with aligned Label: values greyed - streaming scan logs (logs, and start --follow) get one per-phase gutter bar that walks the ramp, dimmed timestamps, red on [ERROR], and the end-of-run summary framed in a corner panel Named chrome.ts to avoid the existing ui.ts (spinner/step helpers). Presentation only: the worker and workflow.log are untouched, the log on disk stays plain text (tail/grep and the failure marker keep working), and non-TTY / NO_COLOR output is byte-identical to before. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 2 + apps/cli/src/chrome.ts | 159 +++++++++++++++++++++++++++++++ apps/cli/src/commands/logs.ts | 10 +- apps/cli/src/commands/start.ts | 27 +++--- apps/cli/src/log-render.ts | 167 +++++++++++++++++++++++++++++++++ apps/cli/src/splash.ts | 30 +----- 6 files changed, 352 insertions(+), 43 deletions(-) create mode 100644 apps/cli/src/chrome.ts create mode 100644 apps/cli/src/log-render.ts diff --git a/.gitignore b/.gitignore index c24f52e0..bc35e3e1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ credentials/ dist/ repos/ .turbo/ + +.DS_Store diff --git a/apps/cli/src/chrome.ts b/apps/cli/src/chrome.ts new file mode 100644 index 00000000..34dafc2a --- /dev/null +++ b/apps/cli/src/chrome.ts @@ -0,0 +1,159 @@ +/** + * Section chrome — the sunset-ramp hierarchy beneath the splash wordmark. + * (Distinct from ui.ts, which holds the spinner/step helpers.) + * + * Three treatments, one job each: + * rule() static blocks that print once — `start`, `status`, a log's header + * gutter() streaming output, where a section scrolls off the top of the screen + * panel() the single summary block at the end of a run + * + * Presentation only. None of this is ever written to workflow.log — the file on disk + * stays plain text so `tail`, `grep`, and the completion regex in commands/logs.ts keep + * working against it. + */ + +import { supportsColor } from './tty.js'; + +/** + * Sunset ramp, yellow at the top row down to burnt orange at the base. + * The wordmark paints row i with stop i and edges it with stop i + 1; section chrome + * draws from the same seven stops so the hierarchy reads as one family. + * `xterm` is the 256-color approximation for terminals without 24-bit color. + */ +export const SUNSET: ReadonlyArray<{ rgb: readonly [number, number, number]; xterm: number }> = [ + { rgb: [247, 203, 45], xterm: 220 }, + { rgb: [246, 182, 38], xterm: 220 }, + { rgb: [245, 160, 32], xterm: 214 }, + { rgb: [242, 141, 28], xterm: 214 }, + { rgb: [238, 121, 24], xterm: 208 }, + { rgb: [231, 100, 21], xterm: 208 }, + { rgb: [222, 82, 19], xterm: 202 }, +]; + +/** Half-block bar for streaming sections — the wordmark's █ at one eighth the weight. */ +const BAR = '▌'; + +/** Columns reserved to the left of every section, matching the existing output grid. */ +const INDENT = 2; + +/** Rules stop here even in a wide terminal; a rule spanning 200 columns reads as a divider, not a header. */ +const MAX_RULE = 64; + +export interface Palette { + color: boolean; + RESET: string; + WHITE: string; + GRAY: string; + DIM: string; + RED: string; + /** The seven sunset stops, ready to emit. Empty strings when color is off. */ + ramp: string[]; + /** Ramp stop 0 — the solid yellow used for every static rule. */ + YELLOW: string; +} + +/** + * Build the escape set for the current terminal, degrading 24-bit → 256-color → bare text. + * Resolved per call rather than at import so NO_COLOR/FORCE_COLOR are honored whenever they land. + */ +export function palette(): Palette { + const color = supportsColor(); + const truecolor = color && /truecolor|24bit/i.test(process.env.COLORTERM ?? ''); + + const ramp = SUNSET.map(({ rgb: [r, g, b], xterm }) => { + if (!color) return ''; + return truecolor ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${xterm}m`; + }); + + return { + color, + RESET: color ? '\x1b[0m' : '', + WHITE: color ? '\x1b[1;97m' : '', + GRAY: color ? '\x1b[0;37m' : '', + DIM: color ? '\x1b[90m' : '', + RED: color ? '\x1b[0;31m' : '', + ramp, + YELLOW: ramp[0] ?? '', + }; +} + +/** Usable width, leaving the indent and a column of breathing room at the right edge. */ +function columns(): number { + return process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 80; +} + +/** Printed width of a string, ignoring any escapes already embedded in it. */ +export function visibleWidth(text: string): number { + // biome-ignore lint/suspicious/noControlCharactersInRegex: matching SGR escapes is the point + return text.replace(/\x1b\[[0-9;]*m/g, '').length; +} + +/** + * Option A — a static section header: the existing label, then a solid yellow rule. + * The label keeps whatever case and punctuation it already had; only the rule is added. + * Degrades to an undecorated label when the terminal is too narrow to carry one. + */ +export function rule(label: string, indent = INDENT): string { + const { WHITE, YELLOW, RESET } = palette(); + const pad = ' '.repeat(indent); + const width = Math.min(MAX_RULE, columns() - indent - 1); + const dashes = width - visibleWidth(label) - 1; + + if (dashes < 2) return `${pad}${WHITE}${label}${RESET}`; + return `${pad}${WHITE}${label}${RESET} ${YELLOW}${'─'.repeat(dashes)}${RESET}`; +} + +/** + * Grey the label half of an aligned `Label: value` line, leaving the value at default + * weight. Purely additive: the string's own characters are never rewritten, so alignment + * that was already correct stays correct. + */ +export function field(line: string): string { + const { GRAY, RESET } = palette(); + const match = /^(\s*)([A-Za-z][A-Za-z ]*:)(\s*)(.*)$/.exec(line); + if (!match) return line; + return `${match[1]}${GRAY}${match[2]}${RESET}${match[3]}${match[4]}`; +} + +/** + * Option C — one line of a streaming section, carrying the section's bar in the gutter. + * `stop` indexes the sunset ramp and wraps, so consecutive sections stay distinguishable + * however many a run produces. + */ +export function gutter(text: string, stop: number, indent = INDENT): string { + const { ramp, RESET } = palette(); + const color = ramp[((stop % ramp.length) + ramp.length) % ramp.length] ?? ''; + const pad = ' '.repeat(indent); + // Trailing space is dropped on empty lines so sections don't emit trailing whitespace. + return text ? `${pad}${color}${BAR}${RESET} ${text}` : `${pad}${color}${BAR}${RESET}`; +} + +/** + * Option E — the framed summary block, used once at the end of a run. + * Falls back to a rule plus indented lines when the terminal is too narrow to hold the + * frame, since a box that wraps is worse than no box at all. + */ +export function panel(title: string, body: string[], indent = INDENT): string[] { + const { WHITE, YELLOW, RESET } = palette(); + const pad = ' '.repeat(indent); + const titleWidth = visibleWidth(title); + const widest = body.reduce((max, line) => Math.max(max, visibleWidth(line)), 0); + + const available = columns() - indent - 6; + const inner = Math.max(titleWidth + 1, widest); + + if (available < inner || available < titleWidth + 3) { + return [rule(title, indent), '', ...body.map((line) => `${pad} ${line}`)]; + } + + const frame = (s: string): string => `${YELLOW}${s}${RESET}`; + const top = `${pad}${frame('╭─')} ${WHITE}${title}${RESET} ${frame(`${'─'.repeat(inner + 1 - titleWidth)}╮`)}`; + const bottom = `${pad}${frame(`╰${'─'.repeat(inner + 4)}╯`)}`; + + const rows = body.map((line) => { + const fill = ' '.repeat(inner - visibleWidth(line)); + return `${pad}${frame('│')} ${line}${fill} ${frame('│')}`; + }); + + return [top, ...rows, bottom]; +} diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts index 84989c3f..97059425 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -11,8 +11,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { watch } from 'chokidar'; +import { field } from '../chrome.js'; import { fail } from '../errors.js'; import { getWorkspacesDir } from '../home.js'; +import { LogRenderer } from '../log-render.js'; import { resolveRunFile } from '../paths.js'; import { resolveWorkflowId } from '../session.js'; import { waitForWorkflowClose } from '../temporal-client.js'; @@ -90,6 +92,9 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom let position = 0; let done = false; let sawFailure = false; + // Decorates the streamed log for the terminal; a pass-through when colour is off, so + // piped/redirected output stays byte-identical and the failure check still sees raw text. + const renderer = new LogRenderer(); const controller = new AbortController(); let watcher: ReturnType | undefined; @@ -99,7 +104,7 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom const { size } = fs.statSync(logFile); if (size <= position) return; const data = readRange(logFile, position, size); - process.stdout.write(data); + process.stdout.write(renderer.write(data)); position = size; if (!sawFailure && FAILURE_MARKER.test(data)) { sawFailure = true; @@ -112,6 +117,7 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom function finish(): void { if (done) return; done = true; + process.stdout.write(renderer.end()); controller.abort(); if (watcher) { watcher.close().finally(() => resolve({ sawFailure })); @@ -165,7 +171,7 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom export function logs(workspaceId: string): void { const logFile = resolveLogFile(workspaceId); const workflowId = resolveWorkflowId(workspaceId); - console.error(stdoutIsTerminal() ? `Tailing scan log: ${logFile}` : 'Tailing scan log'); + console.error(stdoutIsTerminal() ? field(`Tailing scan log: ${logFile}`) : 'Tailing scan log'); let unreachable = false; tailUntilComplete(logFile, { diff --git a/apps/cli/src/commands/start.ts b/apps/cli/src/commands/start.ts index 0f58f5ae..48e09dbc 100644 --- a/apps/cli/src/commands/start.ts +++ b/apps/cli/src/commands/start.ts @@ -10,6 +10,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import * as p from '@clack/prompts'; +import { field, rule } from '../chrome.js'; import { ensureDocker, ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js'; import { buildEnvFlags, loadEnv, resolveHostPiAuthPath, shouldUsePiAuth, validateCredentials } from '../env.js'; import { fail } from '../errors.js'; @@ -298,9 +299,9 @@ async function followScan(workspace: string, workspacesDir: string): Promise rule() (their ==== bars become the rule) + * everything between phases -> gutter() (one bar per phase, walking the ramp) + * the closing Scan COMPLETED block -> panel() (its ==== bars become the frame) + * + * When stdout is not a terminal the renderer is a pass-through and emits the file's bytes + * unchanged, so redirected logs, pipes, and CI keep grepping the same text they always did. + */ + +import { field, gutter, palette, panel, rule } from './chrome.js'; + +/** The ==== bars that open and close a block; replaced by our own chrome. */ +const BLOCK_BAR = /^={10,}\s*$/; + +/** The ──── bar dividing a block's title from its body; replaced by the panel frame. */ +const INNER_BAR = /^─{10,}\s*$/; + +/** `[2026-08-26 17:04:11] ` — every streamed event line carries one. */ +const TIMESTAMP = /^(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\])( .*)$/; + +/** A phase transition opens a new gutter section. */ +const PHASE_START = /^\[[^\]]*\] \[PHASE\] Starting: /; + +/** Titles of the two banner blocks, which take the static rule treatment. */ +const BANNER_TITLE = /^(Shannon Pentest - Scan Log|RESUMED)$/; + +/** Title of the final block, which takes the panel treatment. Mirrors logs.ts's completion regex. */ +const COMPLETION_TITLE = /^Scan (COMPLETED|FAILED)$/; + +/** Dim the timestamp so the message reads first; errors take the semantic red, not a ramp colour. */ +function colorizeEvent(line: string): string { + const { DIM, RED, RESET } = palette(); + const match = TIMESTAMP.exec(line); + if (!match) return line; + const rest = match[2] ?? ''; + const body = rest.includes('[ERROR]') ? `${RED}${rest}${RESET}` : rest; + return `${DIM}${match[1]}${RESET}${body}`; +} + +type Mode = 'stream' | 'banner' | 'summary'; + +export class LogRenderer { + /** Bytes past the last newline, held until the rest of the line arrives. */ + private carry = ''; + private mode: Mode = 'stream'; + /** Suppresses a second consecutive blank line; starts true so the stream can't open on one. */ + private lastBlank = true; + /** Current sunset stop for the gutter bar; advanced by each phase transition. */ + private stop = 0; + private summaryTitle = ''; + private summaryBody: string[] = []; + private readonly passthrough: boolean; + + constructor() { + this.passthrough = !palette().color; + } + + /** Decorate a chunk of newly appended log text. Incomplete trailing lines are held back. */ + write(chunk: string): string { + if (this.passthrough) return chunk; + + const text = this.carry + chunk; + const lines = text.split('\n'); + // The final element is whatever followed the last newline — possibly a partial line. + this.carry = lines.pop() ?? ''; + + const out: string[] = []; + for (const line of lines) { + out.push(...this.renderLine(line)); + } + return this.emit(out); + } + + /** Join rendered lines, dropping blank runs left behind by the bars we removed. */ + private emit(lines: string[]): string { + const kept: string[] = []; + for (const line of lines) { + const blank = line === ''; + if (blank && this.lastBlank) continue; + this.lastBlank = blank; + kept.push(line); + } + return kept.length ? `${kept.join('\n')}\n` : ''; + } + + /** Flush a held partial line and close an unterminated summary block. */ + end(): string { + if (this.passthrough) return ''; + + const out: string[] = []; + if (this.carry) { + out.push(...this.renderLine(this.carry)); + this.carry = ''; + } + if (this.mode === 'summary') { + out.push(...this.closeSummary()); + } + return this.emit(out); + } + + private renderLine(raw: string): string[] { + // Strip the \r from CRLF logs so it never lands in the middle of a decorated line. + const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw; + + // Only a ==== bar closes the summary; its ──── divider is chrome we replace, not a terminator. + if (BLOCK_BAR.test(line)) { + return this.mode === 'summary' ? this.closeSummary() : []; + } + if (INNER_BAR.test(line)) return []; + + if (COMPLETION_TITLE.test(line)) { + this.mode = 'summary'; + this.summaryTitle = line; + this.summaryBody = []; + return ['']; + } + + if (BANNER_TITLE.test(line)) { + this.mode = 'banner'; + return ['', rule(line)]; + } + + if (this.mode === 'summary') { + this.summaryBody.push(line); + return []; + } + + if (this.mode === 'banner') { + // The banner runs until the first streamed event. + if (!TIMESTAMP.test(line)) { + return [line.trim() ? ` ${field(line)}` : '']; + } + this.mode = 'stream'; + } + + // A blank line separates sections; the bar resumes on the next line of content. + if (!line.trim()) return ['']; + + if (PHASE_START.test(line)) { + // Two stops per phase, not one: the 256-colour tier collapses the seven stops into + // four xterm colours, and a single step would give consecutive phases the same bar. + // Seven is odd, so a stride of two still visits every stop before repeating. + this.stop += 2; + } + return [gutter(colorizeEvent(line), this.stop)]; + } + + private closeSummary(): string[] { + const title = this.summaryTitle; + const body = [...this.summaryBody]; + while (body.length && !body[body.length - 1]?.trim()) body.pop(); + while (body.length && !body[0]?.trim()) body.shift(); + + this.mode = 'stream'; + this.summaryTitle = ''; + this.summaryBody = []; + + const rows = body.map((line) => (line.trim() ? field(line) : '')); + return [...panel(title, rows), '']; + } +} diff --git a/apps/cli/src/splash.ts b/apps/cli/src/splash.ts index 6a02adcc..86d34ce4 100644 --- a/apps/cli/src/splash.ts +++ b/apps/cli/src/splash.ts @@ -3,7 +3,7 @@ * Color escapes are gated on terminal support; the Unicode art is always kept. */ -import { supportsColor } from './tty.js'; +import { palette } from './chrome.js'; /** SHANNON wordmark. Block glyphs take the row fill; box-drawing strokes take the deeper edge shade. */ const SHANNON = [ @@ -15,34 +15,8 @@ const SHANNON = [ '╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═══╝', ]; -/** - * Sunset ramp, yellow at the top row down to burnt orange at the base. - * Wordmark row i is filled with stop i and edged with stop i + 1, so the - * box-drawing strokes read as a shadow one shade deeper than their row. - * `xterm` is the 256-color approximation for terminals without 24-bit color. - */ -const SUNSET: ReadonlyArray<{ rgb: readonly [number, number, number]; xterm: number }> = [ - { rgb: [247, 203, 45], xterm: 220 }, - { rgb: [246, 182, 38], xterm: 220 }, - { rgb: [245, 160, 32], xterm: 214 }, - { rgb: [242, 141, 28], xterm: 214 }, - { rgb: [238, 121, 24], xterm: 208 }, - { rgb: [231, 100, 21], xterm: 208 }, - { rgb: [222, 82, 19], xterm: 202 }, -]; - export function displaySplash(version?: string): void { - const color = supportsColor(); - const truecolor = color && /truecolor|24bit/i.test(process.env.COLORTERM ?? ''); - const RESET = color ? '\x1b[0m' : ''; - const WHITE = color ? '\x1b[1;97m' : ''; - const GRAY = color ? '\x1b[0;37m' : ''; - const DIM = color ? '\x1b[90m' : ''; - - const ramp = SUNSET.map(({ rgb: [r, g, b], xterm }) => { - if (!color) return ''; - return truecolor ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${xterm}m`; - }); + const { color, RESET, WHITE, GRAY, DIM, ramp } = palette(); /** Color one wordmark row, emitting an escape only where the run changes. Spaces stay unpainted. */ const paint = (row: string, fill: string, edge: string): string => {