mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
a66cd545d5
Adds a `fmtDuration(ms)` helper in `setup/lib/theme.ts` that returns
`47s` under a minute and `1m 34s` from 60s onward, then routes every
elapsed-time spinner suffix in the setup flow through it. Replaces
the inline `Math.round((Date.now() - start) / 1000)` + `(${elapsed}s)`
pattern at every site.
Format is consistent past 60s — `1m 0s` over `1m` — so the live
spinner doesn't change shape at every whole-minute crossing.
Sites updated: setup/auto.ts, setup/lib/{runner,tz-from-claude,
claude-assist}.ts, and setup/channels/{signal,whatsapp,telegram,
discord,slack}.ts. Pre-allocated suffix budgets in `fitToWidth`
calls bumped from `' (999s)'` to `' (99m 59s)'` so long-running
steps don't blow past the reserved width.
184 lines
6.3 KiB
TypeScript
184 lines
6.3 KiB
TypeScript
/**
|
|
* NanoClaw brand palette for the terminal.
|
|
*
|
|
* Colors pulled from assets/nanoclaw-logo.png:
|
|
* brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body
|
|
* brand navy ≈ #171B3B — the dark logo background + outlines
|
|
*
|
|
* Rendering gates:
|
|
* - No TTY (piped / redirected) → plain text, no ANSI
|
|
* - NO_COLOR set → plain text, no ANSI
|
|
* - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan)
|
|
* - Otherwise → kleur's 16-color cyan (closest fallback)
|
|
*/
|
|
import * as p from '@clack/prompts';
|
|
import k from 'kleur';
|
|
|
|
const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
const TRUECOLOR =
|
|
USE_ANSI &&
|
|
(process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit');
|
|
|
|
export function brand(s: string): string {
|
|
if (!USE_ANSI) return s;
|
|
if (TRUECOLOR) return `\x1b[38;2;43;183;206m${s}\x1b[0m`;
|
|
return k.cyan(s);
|
|
}
|
|
|
|
export function brandBold(s: string): string {
|
|
if (!USE_ANSI) return s;
|
|
if (TRUECOLOR) return `\x1b[1;38;2;43;183;206m${s}\x1b[0m`;
|
|
return k.bold(k.cyan(s));
|
|
}
|
|
|
|
export function brandChip(s: string): string {
|
|
if (!USE_ANSI) return s;
|
|
if (TRUECOLOR) {
|
|
return `\x1b[48;2;43;183;206m\x1b[38;2;23;27;59m\x1b[1m${s}\x1b[0m`;
|
|
}
|
|
return k.bgCyan(k.black(k.bold(s)));
|
|
}
|
|
|
|
/**
|
|
* Accent green (#3fba50) for emphasizing a single word inside prompt
|
|
* messages — currently the "you" in "What should your assistant call
|
|
* you?" so the operator parses at a glance who the question is about.
|
|
* Same TTY/NO_COLOR/truecolor gating as the rest of the palette.
|
|
*/
|
|
export function accentGreen(s: string): string {
|
|
if (!USE_ANSI) return s;
|
|
if (TRUECOLOR) return `\x1b[38;2;63;186;80m${s}\x1b[39m`;
|
|
return k.green(s);
|
|
}
|
|
|
|
/**
|
|
* Format an elapsed-time duration (in milliseconds) for the spinner
|
|
* suffixes setup writes everywhere. Sub-minute durations stay in plain
|
|
* seconds (`47s`); once the timer crosses 60 seconds we switch to the
|
|
* `Xm Ys` form (`2m 34s`) so a long step doesn't read as `247s` or
|
|
* similar. The format is consistent above 60s — `4m 0s` over `4m` —
|
|
* so live spinner output doesn't change shape at every whole minute.
|
|
*/
|
|
export function fmtDuration(ms: number): string {
|
|
const totalSec = Math.round(ms / 1000);
|
|
if (totalSec < 60) return `${totalSec}s`;
|
|
const m = Math.floor(totalSec / 60);
|
|
const s = totalSec % 60;
|
|
return `${m}m ${s}s`;
|
|
}
|
|
|
|
/**
|
|
* Brand body color for setup-flow prose. Used for card bodies (via the
|
|
* `note()` formatter) and `p.log.*` body arguments — anywhere the
|
|
* previous "dim" treatment was making prose hard to read or washing
|
|
* out embedded brand emphasis.
|
|
*
|
|
* Multi-line input is colored line-by-line so embedded line breaks
|
|
* don't bleed the SGR sequence across clack's gutter prefix.
|
|
*/
|
|
export function brandBody(s: string): string {
|
|
if (!USE_ANSI) return s;
|
|
if (TRUECOLOR) {
|
|
return s
|
|
.split('\n')
|
|
.map((line) => (line.length > 0 ? `\x1b[38;2;43;183;206m${line}\x1b[39m` : line))
|
|
.join('\n');
|
|
}
|
|
return s
|
|
.split('\n')
|
|
.map((line) => (line.length > 0 ? k.cyan(line) : line))
|
|
.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Wrap text so it fits inside clack's gutter without the terminal's soft
|
|
* wrap breaking the `│ …` bar on long lines. Works on a single string with
|
|
* embedded `\n`s; each logical line is wrapped independently.
|
|
*
|
|
* The `gutter` argument is the total horizontal overhead clack adds for
|
|
* the component the text lives in (e.g. 4 for `p.log.*`'s `│ ` prefix;
|
|
* 6-ish for `p.note`'s box). Caller picks it; we just subtract from
|
|
* `process.stdout.columns` and hard-wrap at word boundaries.
|
|
*/
|
|
export function wrapForGutter(text: string, gutter: number): string {
|
|
const cols = process.stdout.columns ?? 80;
|
|
const width = Math.max(30, cols - gutter);
|
|
return text
|
|
.split('\n')
|
|
.map((line) => wrapLine(line, width))
|
|
.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Wrap multi-line explanatory prose to the clack gutter. Previously
|
|
* dimmed its output (hence the name) — that made body copy hard to read
|
|
* against dark terminals. Dim is now reserved for preview/debug blocks
|
|
* (failure transcript tails, claude-assist streams); prose renders at
|
|
* the terminal's regular weight.
|
|
*/
|
|
export function dimWrap(text: string, gutter: number): string {
|
|
return wrapForGutter(text, gutter);
|
|
}
|
|
|
|
/**
|
|
* Wrap clack's `p.note` so card bodies render in the brand body color
|
|
* (#2b6fdc) instead of clack's default dim. Clack runs the formatter
|
|
* on each line individually, so `brandBody` colors each line cleanly
|
|
* without bleeding across the gutter prefix.
|
|
*/
|
|
export function note(message: string, title?: string): void {
|
|
p.note(message, title, { format: brandBody });
|
|
}
|
|
|
|
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
|
|
function visibleLength(s: string): number {
|
|
return s.replace(ANSI_RE, '').length;
|
|
}
|
|
|
|
/**
|
|
* Truncate a label so the final line — base + reserved suffix — fits in
|
|
* the terminal width. Use on spinner labels that get an elapsed counter
|
|
* appended: if the total exceeds terminal width, clack's cursor-up
|
|
* redraw math breaks and each tick stacks a copy of the line instead
|
|
* of replacing it.
|
|
*
|
|
* `suffix` is the reserved space for what we'll append after `fit()`
|
|
* returns (e.g. ` (999s)` or a tool-use breadcrumb). We don't include
|
|
* it in the output — caller appends it.
|
|
*/
|
|
export function fitToWidth(base: string, suffix: string): string {
|
|
const cols = process.stdout.columns ?? 80;
|
|
// Overhead we reserve before sizing the label:
|
|
// spinner icon (1) + 2 padding spaces = 3
|
|
// clack's animated ellipsis after the label = up to 3 (". " -> "...")
|
|
// 1-char safety margin so wide-char glyphs don't tip over the edge
|
|
// Total reserved budget = 7 cols plus the caller's suffix.
|
|
const budget = Math.max(20, cols - 7 - visibleLength(suffix));
|
|
return base.length > budget ? base.slice(0, budget - 1) + '…' : base;
|
|
}
|
|
|
|
function wrapLine(line: string, width: number): string {
|
|
if (visibleLength(line) <= width) return line;
|
|
const words = line.split(' ');
|
|
const rows: string[] = [];
|
|
let cur = '';
|
|
let curLen = 0;
|
|
for (const word of words) {
|
|
const wLen = visibleLength(word);
|
|
if (curLen === 0) {
|
|
cur = word;
|
|
curLen = wLen;
|
|
} else if (curLen + 1 + wLen <= width) {
|
|
cur += ' ' + word;
|
|
curLen += 1 + wLen;
|
|
} else {
|
|
rows.push(cur);
|
|
cur = word;
|
|
curLen = wLen;
|
|
}
|
|
}
|
|
if (cur) rows.push(cur);
|
|
return rows.join('\n');
|
|
}
|