mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-15 06:25:32 +02:00
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>
168 lines
5.8 KiB
TypeScript
168 lines
5.8 KiB
TypeScript
/**
|
|
* 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), ''];
|
|
}
|
|
}
|