/** * Pure rendering logic for pi-input-bar. No pi imports so test.mjs can import * this file directly (Node ≥ 22.18 type stripping). * * Lines are built from plain-text segments with a color name attached; the * extension maps color names to theme.fg() calls. Width math always runs on * the plain text, so ANSI codes never break the layout. */ export type ColorName = | "dim" | "muted" | "text" | "accent" | "success" | "warning" | "error" | "thinking"; export type Colorize = (color: ColorName, text: string) => string; export interface Segment { text: string; color: ColorName; } /** Format token counts for compact display (same semantics as pi's footer). */ export function formatTokens(count: number): string { if (count < 1000) return count.toString(); if (count < 10000) return `${(count / 1000).toFixed(1)}k`; if (count < 1000000) return `${Math.round(count / 1000)}k`; if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; return `${Math.round(count / 1000000)}M`; } const ANSI_RE = /\x1b\[[0-9;]*m/g; export function stripAnsi(s: string): string { return s.replace(ANSI_RE, ""); } /** Approximate terminal cell width of a single code point. */ function charWidth(cp: number): number { // Zero-width: combining marks, ZWJ/ZWNJ, variation selectors if ( (cp >= 0x0300 && cp <= 0x036f) || cp === 0x200b || cp === 0x200c || cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f) ) { return 0; } // Wide: CJK, Hangul, fullwidth forms, emoji blocks if ( (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x20000 && cp <= 0x3fffd) ) { return 2; } return 1; } /** Visible terminal width of a string (ANSI stripped, wide chars counted as 2). */ export function visibleWidth(s: string): number { let w = 0; for (const ch of stripAnsi(s)) w += charWidth(ch.codePointAt(0)!); return w; } /** Width-measuring function; pi-tui's visibleWidth is injected at runtime. */ export type WidthFn = (s: string) => number; function segsWidth(segs: Segment[], widthFn: WidthFn): number { let w = 0; for (const s of segs) w += widthFn(s.text); return w; } function paint(segs: Segment[], c: Colorize): string { return segs.map((s) => c(s.color, s.text)).join(""); } /** * Compose one line: left segments, a dim "─" filler, right segments. * If both sides don't fit, the right side is dropped; if the left alone * doesn't fit, its last segments are dropped until it does. */ export function composeLine( left: Segment[], right: Segment[], width: number, c: Colorize, widthFn: WidthFn = visibleWidth, ): string { const l = [...left]; let r = [...right]; if (segsWidth(l, widthFn) + segsWidth(r, widthFn) + 2 > width) r = []; while (l.length > 1 && segsWidth(l, widthFn) + segsWidth(r, widthFn) + 2 > width) l.pop(); const gap = Math.max(1, width - segsWidth(l, widthFn) - segsWidth(r, widthFn)); return paint(l, c) + c("dim", "─".repeat(gap)) + paint(r, c); } /** Short labels for loop-police detector names, grouped by family. */ export const LOOP_KIND_LABELS: Record = { thinking_loop: "think", semantic_loop: "think", output_loop: "out", output_semantic_loop: "out", stagnation: "stag", file_read_loop: "file", file_scan_loop: "file", search_spiral: "search", tool_loop: "tool", }; /** * Compact per-session summary of loop-police detections: * "10 loops (tool 7, think 3)" — undefined when there are none, so the bar * renders exactly as without loop-police installed. */ export function loopSummary(counts: ReadonlyMap): string | undefined { let total = 0; const byLabel = new Map(); for (const [kind, n] of counts) { if (n <= 0) continue; total += n; const label = LOOP_KIND_LABELS[kind] ?? kind; byLabel.set(label, (byLabel.get(label) ?? 0) + n); } if (total === 0) return undefined; const parts = [...byLabel.entries()] .sort((a, b) => b[1] - a[1]) .map(([label, n]) => `${label} ${n}`); return `${total} loop${total === 1 ? "" : "s"} (${parts.join(", ")})`; } export interface TopBarData { folder: string; branch: string | null; streaming: boolean; provider: string | undefined; modelId: string | undefined; /** Thinking level, or undefined when the model has no reasoning. */ effort: string | undefined; /** loopSummary() text, or undefined when loop-police reported nothing. */ loops: string | undefined; icons: boolean; } export function topBarSegments(d: TopBarData): { left: Segment[]; right: Segment[] } { const sep: Segment = { text: " :: ", color: "dim" }; const left: Segment[] = [ { text: "──", color: "dim" }, { text: d.icons ? " ⌂ " : " ", color: "accent" }, { text: d.folder, color: "accent" }, ]; if (d.branch) { left.push(sep, { text: d.icons ? "⎇ " : "", color: "success" }, { text: d.branch, color: "success" }); } left.push({ text: d.streaming ? " ● " : " ○ ", color: d.streaming ? "warning" : "dim" }); if (d.loops) { left.push({ text: (d.icons ? "⚠ " : "") + d.loops + " ", color: "warning" }); } const right: Segment[] = []; if (d.modelId) { const model = d.provider ? `${d.provider}/${d.modelId}` : d.modelId; right.push({ text: ` ${model}`, color: "success" }); if (d.effort) { right.push(sep, { text: (d.icons ? "✦ " : "") + d.effort, color: "thinking" }); } right.push({ text: " ──", color: "dim" }); } return { left, right }; } export interface BottomBarData { /** Context usage percent, or null when unknown (right after compaction). */ percent: number | null; contextWindow: number; input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; /** Generation tokens per second, or undefined when not yet measured. */ tps: number | undefined; /** True while the tps value is a live in-stream estimate. */ tpsLive: boolean; /** Prefill (prompt processing) tokens per second, or undefined when unknown. */ pps: number | undefined; icons: boolean; } function formatRate(rate: number): string { return rate >= 100 ? rate.toFixed(0) : rate.toFixed(1); } export function bottomBarSegments(d: BottomBarData): Segment[] { const sep: Segment = { text: " :: ", color: "dim" }; const pctColor: ColorName = d.percent === null ? "muted" : d.percent > 90 ? "error" : d.percent > 70 ? "warning" : "success"; const pct = d.percent === null ? "?" : `${d.percent.toFixed(0)}%`; const segs: Segment[] = [ { text: d.icons ? " ⛁ " : " ", color: pctColor }, { text: `${pct}/${formatTokens(d.contextWindow)}`, color: pctColor }, ]; const stats: string[] = []; if (d.input) stats.push(`↑${formatTokens(d.input)}`); if (d.output) stats.push(`↓${formatTokens(d.output)}`); if (d.cacheRead) stats.push(`R${formatTokens(d.cacheRead)}`); if (d.cacheWrite) stats.push(`W${formatTokens(d.cacheWrite)}`); if (stats.length) segs.push(sep, { text: stats.join(" "), color: "muted" }); if (d.cost) segs.push(sep, { text: `$${d.cost.toFixed(3)}`, color: "muted" }); if (d.tps !== undefined || d.pps !== undefined) { // With both rates, label them llama.cpp-style (pp = prefill, tg = generation) const parts: string[] = []; if (d.pps !== undefined) parts.push(`pp ${formatRate(d.pps)}`); if (d.tps !== undefined) { const tg = `${formatRate(d.tps)}${d.tpsLive ? "~" : ""}`; parts.push(d.pps !== undefined ? `tg ${tg}` : tg); } segs.push(sep, { text: `${d.icons ? "⚡" : ""}${parts.join(" · ")} t/s`, color: d.tpsLive ? "warning" : "muted", }); } segs.push({ text: " ──", color: "dim" }); return segs; } /** Right-aligned bottom line: dim filler, then the stats block. */ export function composeBottomLine( segs: Segment[], width: number, c: Colorize, widthFn: WidthFn = visibleWidth, ): string { const s = [...segs]; // Drop middle segments (keep context% and trailing space) until it fits. while (s.length > 2 && segsWidth(s, widthFn) + 1 > width) s.splice(2, 1); const gap = Math.max(1, width - segsWidth(s, widthFn)); return c("dim", "─".repeat(gap)) + paint(s, c); } /** * Replace the first and last visible characters of a line (skipping ANSI * codes) — used to turn flat border lines into box corners (╭─…─╮ / ╰─…─╯). * Assumes the replacements have the same cell width as the replaced chars. */ export function replaceEdgeChars(line: string, open: string, close: string): string { const tokens = line.split(/(\x1b\[[0-9;]*m)/); let first = -1; let last = -1; for (let i = 0; i < tokens.length; i++) { if (tokens[i].length === 0 || tokens[i].startsWith("\x1b")) continue; if (first === -1) first = i; last = i; } if (first === -1) return line; const firstChars = [...tokens[first]]; firstChars[0] = open; tokens[first] = firstChars.join(""); // When first === last this reads the already-updated token, which is intended const lastChars = [...tokens[last]]; lastChars[lastChars.length - 1] = close; tokens[last] = lastChars.join(""); return tokens.join(""); } /** Working-word pool, Claude Code style. */ export const DEFAULT_WORDS = [ "Thinking", "Pondering", "Percolating", "Brewing", "Conjuring", "Scheming", "Marinating", "Ruminating", "Noodling", "Cogitating", "Simmering", "Musing", "Tinkering", "Weaving", "Distilling", "Incubating", "Hatching", "Whirring", "Crunching", "Divining", "Untangling", "Spelunking", "Wrangling", "Vibing", ]; /** Pick a random word; rnd injectable for tests. */ export function pickWord(words: readonly string[], rnd: () => number = Math.random): string { if (words.length === 0) return "Thinking"; return words[Math.min(words.length - 1, Math.floor(rnd() * words.length))]; } /** Estimate tokens from a character count (~4 chars/token). */ export function estimateTokens(chars: number): number { return chars / 4; } /** Compute tokens/sec; undefined when the sample is too small to be meaningful. */ export function tokensPerSecond(tokens: number, elapsedMs: number): number | undefined { if (tokens <= 0 || elapsedMs < 500) return undefined; return tokens / (elapsedMs / 1000); }