From f7dd646e570c311c6481d8ceda6a44ccd4cf01ab Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 02:15:03 +0800 Subject: [PATCH] Terminal: colors, status emoji and a live spinner line for waits at a TTY; plain lines unchanged when piped, in CI, with NO_COLOR, TERM=dumb, --json or MCP; desktop download progress through the log --- CHANGELOG.md | 4 +++ cli/desktop.ts | 4 +-- cli/lib/log.ts | 69 +++++++++++++++++++++++++++++++++++++++-- tests/log-fancy.test.ts | 26 ++++++++++++++++ 4 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 tests/log-fancy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a0596..939cbef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **A livelier terminal.** At a terminal, lines get colors and status emoji (✅ ⚠️ ❌ 📦 🚀 🧪) and every wait ("waiting + for ...", uploads, downloads, progress) is one live spinner line with the elapsed time. Piped, CI, `NO_COLOR`, + `TERM=dumb`, `--json` and MCP keep the plain lines exactly as before. + - **MCP `console_verify` reads multi-line page text whole** (it kept only the first line on Windows), and long JSON output such as `console_plan` keeps its start and names a file with the whole output. Pitfall 100. diff --git a/cli/desktop.ts b/cli/desktop.ts index 8089388..5426361 100644 --- a/cli/desktop.ts +++ b/cli/desktop.ts @@ -26,7 +26,7 @@ import { verify } from './lib/minisign'; import { parsePlist } from './lib/plist'; import { addFolder } from './lib/registry'; import * as rt from './lib/rt'; -import { printJson, promptOut } from './lib/log'; +import { log, printJson, promptOut } from './lib/log'; import { stdinIsTTY } from './lib/tty'; type Fetch = (url: string) => Promise; @@ -58,7 +58,7 @@ export function pick(m: Manifest, keys: DownloadKey[], format?: string): [Downlo } /** Downloads one installer and keeps it only when its size, SHA-256 and minisign signature are right. */ -export async function download(d: Download, dir: string, get: Fetch = fetch, publicKey = PUBLIC_KEY, stallMs = 60_000, say: (s: string) => void = s => console.log(s)): Promise { +export async function download(d: Download, dir: string, get: Fetch = fetch, publicKey = PUBLIC_KEY, stallMs = 60_000, say: (s: string) => void = s => log('desktop', `downloading ${s.trim()}`)): Promise { const r = await withStall(get(d.url), stallMs, d.url); if (!r.ok) throw new Error(`${d.url}: HTTP ${r.status}`); const data = await readBody(r, d, stallMs, say); diff --git a/cli/lib/log.ts b/cli/lib/log.ts index 3790f6c..34bb78a 100644 --- a/cli/lib/log.ts +++ b/cli/lib/log.ts @@ -4,9 +4,72 @@ const t0 = Date.now(); const stamp = (): string => new Date().toISOString().slice(11, 19); const since = (): string => `${((Date.now() - t0) / 1000).toFixed(0)}s`; -export const log = (tag: string, msg: string): void => console.log(`${stamp()} [${tag}] ${msg}`); -export const warn = (tag: string, msg: string): void => console.warn(`${stamp()} [${tag}] WARN ${msg}`); -export const done = (tag: string, msg: string): void => console.log(`${stamp()} [${tag}] ${msg} (${since()})`); +/** + * Colors, status emoji and a spinner, only for a person at a terminal: stdout is a TTY and nothing asks for plain text + * (NO_COLOR, CI, TERM=dumb, a --json run, `storebound serve` / MCP children). Piped and logged output stays exactly + * the plain lines above, so `tail -f` and agents read the same text as before. + */ +export function fancy(env: Record = process.env, tty = !!process.stdout.isTTY): boolean { + return tty && !env.NO_COLOR && !env.CI && env.TERM !== 'dumb' && env.STOREBOUND_MACHINE !== '1' && env.STOREBOUND_MCP !== '1' && !jsonOutput(); +} + +const C = { dim: '\x1b[2m', cyan: '\x1b[36m', yellow: '\x1b[33m', green: '\x1b[32m', red: '\x1b[31m', bold: '\x1b[1m', off: '\x1b[0m' }; +const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +/** A wait line ("waiting for ...", "still processing", "3/14 ..." progress) is drawn as one live spinner line. */ +const WAIT = /^(waiting|still |polling|processing|uploading|downloading|building|[ ]*\d+%|\d+\/\d+ )/i; + +/** Emoji for a line, by what it says. */ +export function emojiFor(msg: string, level: 'log' | 'warn' | 'done'): string { + if (level === 'warn') return '⚠️ '; + if (level === 'done') return '✅'; + if (/\b(fail|error|refus|rejected|not kept)/i.test(msg)) return '❌'; + if (/^(upload|uploading)/i.test(msg)) return '📦'; + if (/\b(submit(ted)?|released?|publish(ed)?|live)\b/i.test(msg)) return '🚀'; + if (/^(dry run|would )/i.test(msg)) return '🧪'; + return '•'; +} + +let spin: { text: string; start: number; frame: number; timer: ReturnType } | null = null; +const clearSpin = (): void => { if (spin) process.stdout.write('\r\x1b[2K'); }; +function stopSpin(): void { if (spin) { clearInterval(spin.timer); clearSpin(); spin = null; } } +function drawSpin(): void { + if (!spin) return; + const s = Math.round((Date.now() - spin.start) / 1000); + const cols = (process.stdout.columns || 100) - 12; + process.stdout.write(`\r\x1b[2K${C.cyan}${FRAMES[spin.frame++ % FRAMES.length]}${C.off} ${spin.text.slice(0, cols)} ${C.dim}${s}s${C.off}`); +} +function showSpin(text: string): void { + if (spin) { spin.text = text; drawSpin(); return; } + spin = { text, start: Date.now(), frame: 0, timer: setInterval(drawSpin, 100) }; + spin.timer.unref?.(); + drawSpin(); +} +let hooked = false; +/** Any other output first removes the live spinner line, so lines never mix. */ +function hook(): void { + if (hooked) return; + hooked = true; + for (const k of ['log', 'info', 'warn', 'error'] as const) { + const orig = console[k].bind(console); + console[k] = (...a: unknown[]) => { const was = spin?.text; stopSpin(); orig(...a); if (was !== undefined && k !== 'error') showSpin(was); }; + } + process.on('exit', stopSpin); +} + +function line(level: 'log' | 'warn' | 'done', tag: string, msg: string): void { + const plain = `${stamp()} [${tag}] ${level === 'warn' ? 'WARN ' : ''}${msg}${level === 'done' ? ` (${since()})` : ''}`; + if (!fancy()) { (level === 'warn' ? console.warn : console.log)(plain); return; } + hook(); + if (level === 'log' && WAIT.test(msg)) { showSpin(`${C.bold}${tag}${C.off} ${msg}`); return; } + stopSpin(); + const color = level === 'warn' ? C.yellow : level === 'done' ? C.green : emojiFor(msg, level) === '❌' ? C.red : ''; + const text = `${C.dim}${stamp()}${C.off} ${emojiFor(msg, level)} ${C.cyan}${tag}${C.off} ${color}${msg}${color ? C.off : ''}${level === 'done' ? ` ${C.dim}(${since()})${C.off}` : ''}`; + (level === 'warn' ? console.warn : console.log)(text); +} + +export const log = (tag: string, msg: string): void => line('log', tag, msg); +export const warn = (tag: string, msg: string): void => line('warn', tag, msg); +export const done = (tag: string, msg: string): void => line('done', tag, msg); // The count lives on globalThis: the Node bundle (tools/build-node.ts) can carry this module twice (once for the // static imports, once inside a lazily initialised chunk), and both copies must see the same --json run. diff --git a/tests/log-fancy.test.ts b/tests/log-fancy.test.ts new file mode 100644 index 0000000..c3f87a3 --- /dev/null +++ b/tests/log-fancy.test.ts @@ -0,0 +1,26 @@ +// Terminal styling is for people only: piped, CI, NO_COLOR, TERM=dumb, MCP and serve children get the plain lines. +import { describe, expect, test } from 'bun:test'; +import { emojiFor, fancy } from '../cli/lib/log'; + +describe('fancy terminal output', () => { + test('only on a TTY with nothing asking for plain text', () => { + expect(fancy({}, true)).toBe(true); + expect(fancy({}, false)).toBe(false); + for (const env of [{ NO_COLOR: '1' }, { CI: 'true' }, { TERM: 'dumb' }, { STOREBOUND_MCP: '1' }, { STOREBOUND_MACHINE: '1' }]) expect(fancy(env, true)).toBe(false); + }); + test('emoji by level and message', () => { + expect(emojiFor('anything', 'done')).toBe('✅'); + expect(emojiFor('anything', 'warn')).toBe('⚠️ '); + expect(emojiFor('upload failed: 409', 'log')).toBe('❌'); + expect(emojiFor('uploading 3 files', 'log')).toBe('📦'); + expect(emojiFor('submitted for review', 'log')).toBe('🚀'); + expect(emojiFor('dry run: nothing changed', 'log')).toBe('🧪'); + }); + test('a piped run prints the plain line, no ANSI', () => { + const r = Bun.spawnSync(['bun', '-e', "const { log, warn, done } = await import('./cli/lib/log.ts'); log('t', 'waiting for x'); warn('t', 'careful'); done('t', 'ok');"], { cwd: `${import.meta.dir}/..`, stdout: 'pipe', stderr: 'pipe' }); + const out = r.stdout.toString() + r.stderr.toString(); + expect(out).not.toMatch(/\x1b\[/); + expect(out).toMatch(/\[t\] waiting for x/); + expect(out).toMatch(/\[t\] WARN careful/); + }); +});