mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-15 14:35:32 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6108de3cfc
commit
d017d74519
@@ -5,3 +5,5 @@ credentials/
|
||||
dist/
|
||||
repos/
|
||||
.turbo/
|
||||
|
||||
.DS_Store
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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<typeof watch> | 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, {
|
||||
|
||||
@@ -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<nev
|
||||
|
||||
function printPreservedContainerHint(containerName: string): void {
|
||||
console.log('');
|
||||
console.log(` Worker container preserved: ${containerName}`);
|
||||
console.log(` Inspect logs: docker logs ${containerName}`);
|
||||
console.log(` Remove: docker rm ${containerName}`);
|
||||
console.log(field(` Worker container preserved: ${containerName}`));
|
||||
console.log(field(` Inspect logs: docker logs ${containerName}`));
|
||||
console.log(field(` Remove: docker rm ${containerName}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
@@ -312,19 +313,19 @@ function printInfo(args: StartArgs, workspace: string, repoPath: string, workspa
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(` Target: ${args.url}`);
|
||||
console.log(` Repository: ${interactive ? repoPath : path.basename(repoPath)}`);
|
||||
console.log(` Workspace: ${workspace}`);
|
||||
console.log(field(` Target: ${args.url}`));
|
||||
console.log(field(` Repository: ${interactive ? repoPath : path.basename(repoPath)}`));
|
||||
console.log(field(` Workspace: ${workspace}`));
|
||||
if (args.config) {
|
||||
console.log(` Config: ${interactive ? path.resolve(args.config) : path.basename(args.config)}`);
|
||||
console.log(field(` Config: ${interactive ? path.resolve(args.config) : path.basename(args.config)}`));
|
||||
}
|
||||
if (args.pipelineTesting) {
|
||||
console.log(' Mode: Pipeline Testing');
|
||||
console.log(field(' Mode: Pipeline Testing'));
|
||||
}
|
||||
|
||||
const spec = resolveModelSpec();
|
||||
if (typeof spec !== 'string') {
|
||||
console.log(` Model: ${spec.providerId}:${spec.modelId}`);
|
||||
console.log(field(` Model: ${spec.providerId}:${spec.modelId}`));
|
||||
}
|
||||
|
||||
if (!interactive) {
|
||||
@@ -338,13 +339,13 @@ function printInfo(args: StartArgs, workspace: string, repoPath: string, workspa
|
||||
if (!args.follow) {
|
||||
const prefix = commandPrefix();
|
||||
console.log('');
|
||||
console.log(' Watch scan progress:');
|
||||
console.log(` Live logs: ${prefix} logs ${workspace}`);
|
||||
console.log(` Progress: ${prefix} status ${workspace}`);
|
||||
console.log(rule('Watch scan progress:'));
|
||||
console.log(field(` Live logs: ${prefix} logs ${workspace}`));
|
||||
console.log(field(` Progress: ${prefix} status ${workspace}`));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(' Report (when the scan finishes):');
|
||||
console.log(rule('Report (when the scan finishes):'));
|
||||
console.log(` ${reportPath}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Decorates a tailed workflow.log for the terminal.
|
||||
*
|
||||
* The worker writes workflow.log as plain text and the CLI reads it back, so all of the
|
||||
* chrome lives here on the read side. Nothing in this file changes what the log *says* —
|
||||
* it adds the section treatments the plain file has no way to carry:
|
||||
*
|
||||
* the log header and RESUMED banner -> 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), ''];
|
||||
}
|
||||
}
|
||||
+2
-28
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user