From 4ead9db7f130fc5d3971514b101a3ef8b86a5635 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 13:17:00 -0700 Subject: [PATCH 01/16] Rebuild the details dialog as tabs: turn, session, history; fit its height, width and padding --- CHANGELOG.md | 15 ++ README.md | 35 ++-- detail.ts | 115 +---------- dialog.ts | 453 +++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + session.ts | 202 +++++++------------ test/detail.test.mjs | 48 +---- tui.tsx | 254 ++++++++++++++---------- 8 files changed, 715 insertions(+), 408 deletions(-) create mode 100644 dialog.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1042afe..efbf040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## [Unreleased] +### Changed +- The details dialog is one column with three tabs -- Turn, Session and + History -- switched with `tab`, instead of two side-by-side columns. It is + as tall as its content (up to most of the screen) rather than always + near full height, a fixed 72 cells wide with even padding, and uses + section rules, bars for shares, and bold values beside dim labels. +- The Turn tab adds a timeline: each step's wait, generation and tools on + one time scale. Its steps table shows the engine's own per-step rate + beside OpenCode's where the engine is read per step. +- `ctrl+shift+h` opens the dialog's History tab instead of the history + panel. The tab lays each turn out in fixed columns, so a long row no + longer wraps; `s` switches between this session and every session. +- `/headsup session` and `/headsup history` open the dialog on those tabs. + ## [0.4.0] – 2026-09-25 ### Added - A **details dialog**, opened by `details ›` under the sidebar boxes, diff --git a/README.md b/README.md index 0bde6b4..d761323 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,10 @@ Equivalent, if you keep your config in version control: | Key | Does | | --- | --- | | `ctrl+shift+m` | Collapse/expand the last-turn box. Clicking its heading does the same. | -| `ctrl+shift+h` | Open/close the per-turn history panel. | -| `ctrl+shift+d` | Open/close the details dialog. So do `/headsup` and clicking `details ›`. | +| `ctrl+shift+d` | Open/close the details dialog on the Turn tab. So do `/headsup` and clicking `details ›`. | +| `ctrl+shift+h` | Open the details dialog on the History tab. | + +`/headsup session` and `/headsup history` open the dialog on those tabs. All three are registered with stable command ids (`headsup.toggle`, `headsup.panel`, `headsup.details`), so they can be remapped from your own @@ -86,15 +88,17 @@ one figure rather than becoming a bare label: ## Details -A dialog with the last turn on the left and the session on the right. On a -terminal narrower than 110 columns it shows one at a time; `tab` switches. -It scrolls with the wheel, `↑` `↓` and page up/down; `esc` closes it. - -**Last turn** -- **Where the time went**, in seconds and as shares that add up to the - turn's total: waiting for the first token, generating, tools, sub-agents, - compaction, and the rest. A moment is counted once, so a tool running - beside a sub-agent is not counted twice. +A dialog with three tabs -- **Turn**, **Session** and **History** -- +switched with `tab` (`shift+tab` goes back). It is as tall as its content, +up to most of the screen, and scrolls beyond that with the wheel, `↑` `↓` +and page up/down; `esc` closes it. + +**Turn** +- **Where the time went**, as a bar and in seconds and shares that add up + to the turn's total: waiting for the first token, generating, tools, + sub-agents, compaction, and the rest. A moment is counted once, so a tool + running beside a sub-agent is not counted twice. +- **Timeline**: each step's wait, generation and tools on one time scale. - **Steps**: tokens, tok/s and time to first token per step, each tool call and how long it ran, retries, and a step that waited on a compaction. - **Tokens**: output, reasoning, fresh input, cache read and cache write; @@ -110,11 +114,18 @@ It scrolls with the wheel, `↑` `↓` and page up/down; `esc` closes it. - Speed as an average and a spread (min, median, p90, max), a trend, and time to first token (median, p90, max). - Where the time went, in seconds, across the session. -- Tools by time, retries by reason, tokens in all five kinds. +- Tools by time as bars, retries by reason, tokens in all five kinds. - **Coverage**: how many turns had the engine's own figures, and why the rest did not (first turn, compaction, overlapping requests, no engine telemetry, ...). +**History** +- One row per turn in fixed columns, never wrapped: time, tok/s, tokens, + time to first token, total, tool calls, and `◆` where the engine's own + figures were used. `s` switches between this session and every session, + where a model column appears. Cost and cache columns appear when some + turn has them. + Everything unmarked is OpenCode's own data; `◆` marks the engine's. ## Configuration diff --git a/detail.ts b/detail.ts index c636340..f2a9744 100644 --- a/detail.ts +++ b/detail.ts @@ -49,6 +49,10 @@ export interface StepDetail { retryReason?: string /** Seconds of this step's wait for its first token spent on compaction. */ compactionS?: number + /** Epoch ms: the step's request, its first token and its last, for the timeline. */ + createdAt: number + firstAt?: number + lastAt?: number error?: string } @@ -193,6 +197,9 @@ export function buildTurnDetail( retries: Math.max(0, (t?.attempts ?? 1) - 1), retryReason: m.retry?.error?.message, error: m.error?.message, + createdAt: m.time.created, + firstAt: t?.firstAt, + lastAt: t?.lastAt, } if (t?.firstAt !== undefined && t.firstAt > m.time.created) { s.ttftS = (t.firstAt - m.time.created) / 1000 @@ -303,15 +310,6 @@ export function wrap(text: string, width: number): string[] { return out } -/** A titled block of the dialog: labelled rows, or preformatted lines. */ -export interface Section { - title: string - rows?: Row[] - lines?: string[] -} - -const secs = (s: number): string => (s >= 60 ? `${Math.floor(s / 60)}m ${String(Math.round(s % 60)).padStart(2, "0")}s` : `${s.toFixed(2)}s`) -const n0 = (v: number): string => Math.round(v).toLocaleString("en-US") /** * Largest-remainder rounding, so shares of a whole add up to exactly 100. @@ -331,102 +329,3 @@ export function percents(parts: readonly number[]): number[] { } return floor } - -/** The turn column's sections. Widths fit a 46-cell column. */ -export function turnSections(d: TurnDetail): Section[] { - const out: Section[] = [] - if (d.time && d.totalS !== undefined) { - const parts: Array<[string, number]> = [ - ["waiting", d.time.waiting], - ["generating", d.time.generating], - ["tools", d.time.tools], - ["sub-agents", d.time.subagents], - ["compaction", d.time.compaction], - ["other", d.time.other], - ] - const shown = parts.filter(([label, v]) => v > 0 || label === "waiting" || label === "generating") - const pct = percents(shown.map(([, v]) => v)) - out.push({ - title: `Where the time went · ${secs(d.totalS)}`, - rows: shown.map(([label, v], i) => [label, `${secs(v).padStart(8)} ${String(pct[i]).padStart(3)}%`] as const), - }) - } - if (d.steps.length > 0) { - const lines = ["# tokens tok/s ttft tool"] - d.steps.forEach((s, i) => { - const tok = s.output + s.reasoning - const rate = s.streamS && s.streamS > 0 ? (tok / s.streamS).toFixed(1) : "—" - const ttft = s.ttftS !== undefined ? `${s.ttftS.toFixed(2)}s` : "—" - const head = `${String(i + 1).padEnd(2)} ${n0(tok).padStart(6)} ${rate.padStart(5)} ${ttft.padStart(6)} ` - const tools = s.tools.length > 0 ? s.tools : [undefined] - tools.forEach((t, j) => { - const tail = t - ? `${t.name.slice(0, 9).padEnd(9)} ${t.seconds !== undefined ? secs(t.seconds) : t.status}` - : s.finish && s.finish !== "tool-calls" - ? `— ${s.finish}` - : "" - lines.push((j === 0 ? head : " ".repeat(head.length)) + tail) - }) - if (s.retries > 0) lines.push(`${" ".repeat(3)}${s.retries} ${s.retries === 1 ? "retry" : "retries"}`) - if (s.compactionS !== undefined && s.compactionS > 0) lines.push(`${" ".repeat(3)}waited on compaction ${secs(s.compactionS)}`) - }) - out.push({ title: `Steps · ${d.steps.length}`, lines }) - // The reasons in full, wrapped: the table only has room for a count. - const reasons = d.steps.flatMap((s, i) => - [s.retryReason ? `step ${i + 1}: ${s.retryReason}` : "", s.error ? `step ${i + 1} failed: ${s.error}` : ""].filter(Boolean) - ) - if (reasons.length > 0) out.push({ title: "Retries and errors", lines: reasons.flatMap((r) => wrap(r, 46)) }) - } - const t = d.tokens - const rows: Row[] = [ - ["output", n0(t.output)], - ...(t.reasoning > 0 - ? ([["reasoning", `${n0(t.reasoning)} (${Math.round((t.reasoning / Math.max(1, t.output + t.reasoning)) * 100)}% of output)`]] as Row[]) - : []), - ["input", `${n0(t.input)} fresh`], - ["", `${n0(t.cacheRead)} cache read`], - ...(t.cacheWrite > 0 ? ([["", `${n0(t.cacheWrite)} cache write`]] as Row[]) : []), - ] - if (d.context) { - rows.push([ - "context", - d.context.limit ? `${n0(d.context.used)} / ${n0(d.context.limit)} ${Math.round((d.context.used / d.context.limit) * 100)}%` : n0(d.context.used), - ]) - } - if (d.cost !== undefined) rows.push(["cost", `$${d.cost.toFixed(4)}`]) - out.push({ title: "Tokens", rows }) - if (d.engineRows.length > 0) { - const perStep = (d.stepEngine ?? []).flatMap((e, i) => - e && (e.decodeTokS !== undefined || e.prefillTokS !== undefined) - ? [ - `step ${String(i + 1).padEnd(2)} ${e.decodeTokS !== undefined ? `${e.decodeTokS.toFixed(1)} tok/s` : ""}${ - e.prefillTokS !== undefined ? ` prefill ${Math.round(e.prefillTokS)} tok/s` : "" - }`, - ] - : [] - ) - const lines = [ - ...(perStep.length > 1 ? ["", "per step", ...perStep] : []), - ...(d.compactionEngine ? ["", "compaction, taken out of the above", ...d.compactionEngine] : []), - ] - out.push({ title: `${ENGINE_MARK} Engine · ${d.engine}`, rows: d.engineRows, lines: lines.length > 0 ? lines : undefined }) - } else if (d.engineNote && d.engineNote.length > 0) { - // The engine reported; its figures were not used. Say which and why, - // rather than reading as though it had been silent. - out.push({ title: `Engine · ${d.engine}`, lines: [`${d.engine}'s figures were left out:`, ...d.engineNote] }) - } else { - out.push({ title: `Engine · ${d.engine}`, lines: ["no engine telemetry for this provider"] }) - } - if (d.subagents) { - out.push({ - title: "Sub-agents", - rows: [ - ["count", String(d.subagents.count)], - ["tokens", n0(d.subagents.tokens)], - ["time", secs(d.subagents.spanS)], - ...(d.subagents.cost !== undefined ? ([["cost", `$${d.subagents.cost.toFixed(4)}`]] as Row[]) : []), - ], - }) - } - return out -} diff --git a/dialog.ts b/dialog.ts new file mode 100644 index 0000000..05cd3a5 --- /dev/null +++ b/dialog.ts @@ -0,0 +1,453 @@ +// The details dialog's content: three tabs laid out as styled lines. +// +// Pure, like the other shared modules: the entry file only draws what this +// returns, one `` per line with a colour per segment. Every line fits +// `width` cells, so nothing wraps and the dialog can be sized to its content. +// +// The layout follows the approved mockup (option A): one column, a rule under +// each section title, bars for shares, bold values beside dim labels, aligned +// tables. A figure whose data is absent is left out, never shown as 0. + +import type { TurnDetail } from "./detail" +import { percents, ENGINE_MARK, wrap } from "./detail" +import { quantile, sparkline, SKIP_LABEL, type SessionFigures } from "./session" +import type { TurnRecord } from "./history" + +export type Style = "" | "dim" | "bold" | "accent" | "engine" | "gen" | "wait" | "tool" | "sub" | "comp" | "rule" | "tab" +export type Seg = readonly [text: string, style: Style] +export type Line = Seg[] + +export type Tab = "turn" | "session" | "history" +export const TABS: readonly Tab[] = ["turn", "session", "history"] +export type Scope = "session" | "all" + +/** The content width, in cells. The dialog adds its own padding around it. */ +export const CONTENT_WIDTH = 72 +const LABEL = 12 + +export const width = (l: Line): number => l.reduce((n, [t]) => n + t.length, 0) + +// ---- figures ------------------------------------------------------------------- + +const n0 = (v: number): string => Math.round(v).toLocaleString("en-US") +const n1 = (v: number): string => (Math.round(v * 10) / 10).toFixed(1) +/** Seconds as `42.75s`, or `7m 57s` from a minute up. */ +export const dur = (s: number): string => + s >= 60 ? `${Math.floor(s / 60)}m ${String(Math.round(s % 60)).padStart(2, "0")}s` : `${s.toFixed(2)}s` +const clock = (ms: number): string => { + const d = new Date(ms) + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` +} + +// ---- building blocks ---------------------------------------------------------- + +/** A section title, a rule to the width, and an optional note at the right. */ +export function heading(title: string, w: number, right = ""): Line { + const fill = w - title.length - 1 - (right ? right.length + 1 : 0) + const line: Line = [[title, "bold"], [" ", ""], ["─".repeat(Math.max(0, fill)), "rule"]] + if (right) line.push([` ${right}`, "dim"]) + return line +} + +/** `label` dim in its column, `value` bold, `tail` dim. */ +export function row(label: string, value: string, tail = ""): Line { + const line: Line = [[label.padEnd(LABEL), "dim"], [value, "bold"]] + if (tail) line.push([tail, "dim"]) + return line +} + +/** + * A stacked bar of `cells` cells: each part's share of the whole, rounded so + * the parts fill the bar exactly. A part with any time gets at least a cell, + * so a small but real share does not vanish. + */ +export function bar(parts: ReadonlyArray, cells: number): Line { + const total = parts.reduce((a, [v]) => a + Math.max(0, v), 0) + if (total <= 0 || cells <= 0) return [[" ".repeat(Math.max(0, cells)), ""]] + const raw = parts.map(([v]) => (Math.max(0, v) / total) * cells) + const n = raw.map((r) => (r > 0 && r < 1 ? 1 : Math.floor(r))) + let left = cells - n.reduce((a, b) => a + b, 0) + const order = raw.map((r, i) => [r - Math.floor(r), i] as const).sort((a, b) => b[0] - a[0]) + for (const [, i] of order) { + if (left <= 0) break + if (raw[i] as number >= 1) { + n[i] = (n[i] as number) + 1 + left-- + } + } + // Rounding a part up can overshoot; take it back from the largest. + while (left < 0) { + const i = n.indexOf(Math.max(...n)) + n[i] = (n[i] as number) - 1 + left++ + } + return parts.flatMap(([, style, glyph], i) => ((n[i] as number) > 0 ? [[glyph.repeat(n[i] as number), style] as const] : [])) +} + +const GLYPH = { waiting: "░", generating: "█", tools: "▒", subagents: "▓", compaction: "╳", other: "·" } as const +const STYLE: Record = { + waiting: "wait", + generating: "gen", + tools: "tool", + subagents: "sub", + compaction: "comp", + other: "dim", +} +const NAME: Record = { + waiting: "waiting", + generating: "generating", + tools: "tools", + subagents: "sub-agents", + compaction: "compaction", + other: "other", +} + +/** Where the time went: a bar, then a legend in two columns with seconds and shares. */ +function timeSplit( + t: { waiting: number; generating: number; tools: number; subagents: number; compaction: number; other: number }, + total: number, + w: number +): Line[] { + const keys = (Object.keys(GLYPH) as Array).filter( + (k) => t[k] > 0 || k === "waiting" || k === "generating" + ) + const pct = percents(keys.map((k) => t[k])) + const out: Line[] = [heading("Where the time went", w, dur(total))] + out.push([[" ", ""], ...bar(keys.map((k) => [t[k], STYLE[k], GLYPH[k]] as const), w - 4)]) + const cell = (k: keyof typeof GLYPH, i: number): Line => [ + [GLYPH[k], STYLE[k]], + [` ${NAME[k].padEnd(11)}`, "dim"], + [dur(t[k]).padStart(8), "bold"], + [`${String(pct[i]).padStart(4)}%`, "dim"], + ] + const half = Math.ceil(keys.length / 2) + for (let r = 0; r < half; r++) { + const left = keys[r] as keyof typeof GLYPH + const right = keys[r + half] + const line: Line = [[" ", ""], ...cell(left, r)] + if (right) line.push([" ", ""], ...cell(right, r + half)) + out.push(line) + } + return out +} + +// ---- the tabs line and the footer ------------------------------------------------- + +const TAB_NAME: Record = { turn: "Turn", session: "Session", history: "History" } + +export function tabsLine(sel: Tab, right: string, w = CONTENT_WIDTH): Line { + const line: Line = [["Heads Up", "bold"], [" ", ""]] + for (const t of TABS) line.push([` ${TAB_NAME[t]} `, t === sel ? "tab" : "dim"], [" ", ""]) + const used = width(line) + if (right && used + right.length + 1 <= w) line.push([" ".repeat(w - used - right.length), ""], [right, "dim"]) + return line +} + +export function footLine(tab: Tab, w = CONTENT_WIDTH): Line { + const keys: Line = [ + ["tab", "bold"], + [" view ", "dim"], + ["↑↓", "bold"], + [" scroll ", "dim"], + ] + if (tab === "history") keys.push(["s", "bold"], [" this session / all ", "dim"]) + keys.push(["esc", "bold"], [" close", "dim"]) + const used = width(keys) + const mark = `${ENGINE_MARK} engine` + if (used + mark.length + 1 <= w) keys.push([" ".repeat(w - used - mark.length), ""], [mark, "engine"]) + return keys +} + +// ---- Turn ------------------------------------------------------------------------ + +/** The Turn tab. */ +export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] { + if (!d) return [[["No turn yet in this run. Details start with the next turn.", "dim"]]] + const out: Line[] = [] + if (d.outcome) out.push([[`This reply ${d.outcome === "interrupted" ? "was interrupted" : "failed"}; figures run to where it stopped.`, "dim"]], []) + + if (d.time && d.totalS !== undefined) out.push(...timeSplit(d.time, d.totalS, w), []) + + // Timeline: each step's wait, generation and tools on one time scale. + const start = d.steps[0]?.createdAt + const end = start !== undefined && d.totalS !== undefined ? start + d.totalS * 1000 : undefined + if (start !== undefined && end !== undefined && end > start && d.steps.length > 0) { + const cells = w - 8 + const at = (ms: number): number => Math.max(0, Math.min(cells, Math.round(((ms - start) / (end - start)) * cells))) + out.push(heading("Timeline", w, "per step")) + d.steps.forEach((s, i) => { + const spans: Array<[number, number, Style, string]> = [] + if (s.firstAt !== undefined) spans.push([s.createdAt, s.firstAt, "wait", GLYPH.waiting]) + if (s.firstAt !== undefined && s.lastAt !== undefined) spans.push([s.firstAt, s.lastAt, "gen", GLYPH.generating]) + for (const t of s.tools) { + if (t.start !== undefined && t.end !== undefined) { + const sub = t.name === "subagent" || t.name === "task" || t.name === "agent" + spans.push([t.start, t.end, sub ? "sub" : "tool", sub ? GLYPH.subagents : GLYPH.tools]) + } + } + const cellsOf: Array<[Style, string]> = Array.from({ length: cells }, () => ["", " "]) + for (const [a, b, style, glyph] of spans) { + const from = at(a) + const to = Math.max(from + 1, at(b)) + for (let c = from; c < Math.min(cells, to); c++) cellsOf[c] = [style, glyph] + } + const line: Line = [[` ${String(i + 1).padStart(2)} `, "dim"]] + for (const [style, glyph] of cellsOf) { + const last = line[line.length - 1] as Seg + if (last[1] === style && line.length > 1) line[line.length - 1] = [last[0] + glyph, style] + else line.push([glyph, style]) + } + out.push(line) + }) + out.push([[" 0s", "dim"], [" ".repeat(Math.max(1, cells - dur(d.totalS as number).length - 2)), ""], [dur(d.totalS as number), "dim"]]) + out.push([]) + } + + // Steps: OpenCode's rate, and the engine's own where it read each step. + if (d.steps.length > 0) { + const eng = d.stepEngine?.some((e) => e?.decodeTokS !== undefined) === true + out.push(heading("Steps", w, String(d.steps.length))) + out.push([ + [" # tokens ", "dim"], + ...(eng ? ([[ENGINE_MARK, "engine"], ["tok/s ", "dim"]] as Line) : []), + [" tok/s ttft tool", "dim"], + ]) + d.steps.forEach((s, i) => { + const tok = s.output + s.reasoning + const rate = s.streamS && s.streamS > 0 ? n1(tok / s.streamS) : "—" + const e = d.stepEngine?.[i]?.decodeTokS + const head: Line = [ + [` ${String(i + 1).padStart(2)} ${n0(tok).padStart(6)} `, ""], + ...(eng ? ([[(e !== undefined ? n1(e) : "—").padStart(6), "engine"], [" ", ""]] as Line) : []), + [`${rate.padStart(6)} ${(s.ttftS !== undefined ? `${s.ttftS.toFixed(2)}s` : "—").padStart(7)} `, ""], + ] + const pad = " ".repeat(width(head)) + const tools = s.tools.length > 0 ? s.tools : [undefined] + tools.forEach((t, j) => { + const tail: Line = t + ? [[t.name.slice(0, 10).padEnd(11), ""], [t.seconds !== undefined ? dur(t.seconds) : t.status, "bold"]] + : s.finish && s.finish !== "tool-calls" + ? [[`— ${s.finish}`, "dim"]] + : [] + out.push(j === 0 ? [...head, ...tail] : [[pad, ""], ...tail]) + }) + const notes = [ + s.retries > 0 ? `${s.retries} ${s.retries === 1 ? "retry" : "retries"}` : "", + s.compactionS ? `waited on compaction ${dur(s.compactionS)}` : "", + ].filter(Boolean) + if (notes.length > 0) out.push([[` ${notes.join(" · ")}`, "dim"]]) + }) + const reasons = d.steps.flatMap((s, i) => + [s.retryReason ? `step ${i + 1}: ${s.retryReason}` : "", s.error ? `step ${i + 1} failed: ${s.error}` : ""].filter(Boolean) + ) + for (const r of reasons) for (const l of wrap(r, w - 4)) out.push([[` ${l}`, "dim"]]) + out.push([]) + } + + // Tokens: the five kinds, with the cache's share and the context used as bars. + const t = d.tokens + out.push(heading("Tokens", w)) + const produced = t.output + t.reasoning + out.push(row("output", n0(t.output), t.reasoning > 0 ? ` +${n0(t.reasoning)} reasoning (${Math.round((t.reasoning / Math.max(1, produced)) * 100)}%)` : "")) + const prompt = t.input + t.cacheRead + if (prompt > 0) { + out.push([ + ...row("input", n0(t.input), " fresh "), + ...bar([[t.input, "gen", "█"], [t.cacheRead, "wait", "░"]], 20), + [` ${n0(t.cacheRead)} cached (${Math.round((t.cacheRead / prompt) * 100)}%)`, "dim"], + ]) + } + if (t.cacheWrite > 0) out.push(row("", n0(t.cacheWrite), " written to cache")) + if (d.context) { + const tail: Line = d.context.limit + ? [ + [` of ${n0(d.context.limit)} `, "dim"], + ...bar([[d.context.used, "gen", "█"], [Math.max(0, d.context.limit - d.context.used), "wait", "░"]], 16), + [` ${Math.round((d.context.used / d.context.limit) * 100)}%`, "dim"], + ] + : [] + out.push([...row("context", n0(d.context.used)), ...tail]) + } + if (d.cost !== undefined) out.push(row("cost", `$${d.cost.toFixed(4)}`)) + out.push([]) + + // Engine: its own figures only, or which were left out and why. + if (d.engineRows.length > 0) { + const title = `${ENGINE_MARK} ${d.engine}` + const note = " measured by the engine" + out.push([[title, "engine"], [" ", ""], ["─".repeat(Math.max(0, w - title.length - 1 - note.length)), "rule"], [note, "dim"]]) + for (const [l, v] of d.engineRows) out.push(row(l, v)) + if (d.compactionEngine) for (const c of d.compactionEngine) out.push(row("compaction", c, " taken out")) + } else { + out.push(heading(d.engine, w)) + const note = d.engineNote && d.engineNote.length > 0 ? `${d.engine}'s figures were left out: ${d.engineNote.join(" ")}` : "no engine telemetry for this provider" + for (const l of wrap(note, w)) out.push([[l, "dim"]]) + } + + if (d.subagents) { + out.push([], heading("Sub-agents", w)) + out.push(row("count", n0(d.subagents.count), ` ${n0(d.subagents.tokens)} tok · ${dur(d.subagents.spanS)}`)) + if (d.subagents.cost !== undefined) out.push(row("cost", `$${d.subagents.cost.toFixed(4)}`)) + } + return out +} + +// ---- Session ------------------------------------------------------------------------ + +/** The Session tab. */ +export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): Line[] { + if (!f) return [[["No turns yet in this session.", "dim"]]] + const out: Line[] = [] + const s = f.summary + + out.push(heading("Speed", w, "tok/s, generation only")) + if (s.genTokS !== undefined) { + const spread = + f.rates.length > 1 + ? ` min ${n1(quantile(f.rates, 0) as number)} · median ${n1(quantile(f.rates, 0.5) as number)} · p90 ${n1(quantile(f.rates, 0.9) as number)} · max ${n1(quantile(f.rates, 1) as number)}` + : "" + out.push(row("average", n1(s.genTokS), spread)) + } + const spark = sparkline(f.rates.slice(0, 24).reverse()) + if (spark) out.push([...row("trend", ""), [spark, "accent"], [` last ${Math.min(24, f.rates.length)} turns`, "dim"]]) + if (f.ttfts.length > 0) { + const more = f.ttfts.length > 1 ? ` median · p90 ${(quantile(f.ttfts, 0.9) as number).toFixed(2)}s · max ${(quantile(f.ttfts, 1) as number).toFixed(2)}s` : "" + out.push(row("ttft", `${(quantile(f.ttfts, 0.5) as number).toFixed(2)}s`, more)) + } + out.push([]) + + if (f.time) out.push(...timeSplit(f.time, f.time.total, w), []) + + if (f.tools.length > 0) { + const shown = f.tools.slice(0, 8) + const most = (shown[0] as { s: number }).s + out.push(heading("Tools by time", w, `${n0(f.toolCalls)} calls`)) + for (const t of shown) { + out.push([ + [` ${t.name.slice(0, 10).padEnd(11)}`, "dim"], + ...bar([[t.s, "tool", "▒"], [Math.max(0, most - t.s), "", " "]], 28), + [` ${dur(t.s).padStart(7)}`, "bold"], + [` ${n0(t.n)} ${t.n === 1 ? "call" : "calls"}`, "dim"], + ]) + } + if (f.tools.length > shown.length) out.push([[` and ${f.tools.length - shown.length} more`, "dim"]]) + out.push([]) + } + + out.push(heading("Coverage", w, "turns with the engine's own figures")) + out.push([ + ...row("engine", `${n0(f.coverage.engine)} of ${n0(f.coverage.total)}`, " "), + ...bar([[f.coverage.engine, "gen", "█"], [f.coverage.total - f.coverage.engine, "wait", "░"]], 20), + ]) + f.coverage.without.forEach(({ label, n }, i) => out.push(row(i === 0 ? "without" : "", "", `${n0(n)} ${label}`))) + out.push([]) + + out.push(heading("Tokens", w)) + out.push(row("output", n0(f.tokens.output), f.tokens.reasoning > 0 ? ` +${n0(f.tokens.reasoning)} reasoning` : "")) + if (f.tokens.input !== undefined || f.tokens.cacheRead !== undefined) { + const hit = s.cacheHit !== undefined ? ` (${Math.round(s.cacheHit * 100)}% hit)` : "" + out.push(row("input", n0(f.tokens.input ?? 0), ` fresh ${n0(f.tokens.cacheRead ?? 0)} cached${hit}`)) + } + if (f.tokens.cacheWrite > 0) out.push(row("", n0(f.tokens.cacheWrite), " written to cache")) + out.push(row("totals", `${n0(f.turns)} turns`, ` ${n0(f.steps)} steps · ${dur(f.elapsedS)}${f.cost !== undefined ? ` · $${f.cost.toFixed(4)}` : ""}`)) + + if (f.retryReasons.length > 0) { + out.push([], heading("Retries", w, String(s.retries))) + for (const { reason, n } of f.retryReasons) for (const l of wrap(`${n}× ${reason}`, w - 2)) out.push([[` ${l}`, "dim"]]) + } + + if (s.engine && (s.engine.mtpX !== undefined || s.engine.draftAccept !== undefined || s.engine.prefillTokS !== undefined)) { + out.push([], [[`${ENGINE_MARK} Engine averages`, "engine"], [" ", ""], ["─".repeat(Math.max(0, w - 18)), "rule"]]) + if (s.engine.mtpX !== undefined) out.push(row("MTP", `${s.engine.mtpX.toFixed(2)}x`)) + if (s.engine.draftAccept !== undefined) out.push(row("draft", `${Math.round(s.engine.draftAccept * 100)}%`, " accepted")) + if (s.engine.prefillTokS !== undefined) out.push(row("prefill", `${n0(s.engine.prefillTokS)} tok/s`)) + } + if (s.subagents) { + out.push([], heading("Sub-agents", w)) + out.push(row("count", n0(s.subagents.count), ` ${n0(s.subagents.tokens)} tok${s.subagents.cost !== undefined ? ` · $${s.subagents.cost.toFixed(4)}` : ""}`)) + } + return out +} + +// ---- History ------------------------------------------------------------------------ + +/** + * The History tab: one row per turn in fixed columns, never wrapped. Scope is + * this session or every session; with every session, a model column appears, + * since that is when the model varies. Cost and cache columns appear only + * when some turn in view has them. + */ +export function historyTabLines( + turns: readonly TurnRecord[], + sessionID: string | undefined, + scope: Scope, + w = CONTENT_WIDTH +): Line[] { + const rows = scope === "all" ? turns : turns.filter((t) => t.sessionID === sessionID) + if (rows.length === 0) return [[[scope === "all" ? "No turns recorded yet." : "No turns in this session yet.", "dim"]]] + const showModel = scope === "all" + const showCost = rows.some((t) => typeof t.cost === "number" && t.cost > 0) + // Across every session the model column needs the room; cache and tool + // counts stay in the session view, where the model is one. + const showCache = !showModel && rows.some((t) => (t.cached ?? 0) > 0) + const showTools = !showModel + + // Headline: the scope's totals, and its generation speed on the newest model. + const tokens = rows.reduce((a, t) => a + t.tokens, 0) + const newest = rows[0] as TurnRecord + let genTok = 0 + let genS = 0 + for (const t of rows) { + if (t.model !== newest.model || t.outcome) continue + const s = t.streamS ?? (t.rate && t.rate > 0 && t.rateWindow !== "whole" ? t.tokens / t.rate : undefined) + if (s === undefined || s <= 0) continue + genTok += t.tokens + genS += s + } + const cost = rows.reduce((a, t) => a + (t.cost ?? 0), 0) + const out: Line[] = [ + [ + [" ", ""], + [`${n0(rows.length)} turns`, "bold"], + [" · ", "dim"], + [`${n0(tokens)} tok`, "bold"], + ...(genS > 0 ? ([[" · ", "dim"], [`${n1(genTok / genS)} tok/s`, "bold"], [" avg", "dim"]] as Line) : []), + ...(cost > 0 ? ([[" · ", "dim"], [`$${cost.toFixed(4)}`, "bold"]] as Line) : []), + ], + [], + ] + + // Columns, fitted to the width: the model column takes what is left. + const fixed = 2 + 6 + 8 + 8 + 9 + 9 + (showTools ? 6 : 0) + 3 + (showCost ? 9 : 0) + (showCache ? 9 : 0) + const modelW = showModel ? Math.max(8, Math.min(22, w - fixed)) : 0 + const header: Line = [ + [ + ` ${"time".padEnd(6)}${showModel ? "model".padEnd(modelW) : ""}${"tok/s".padStart(8)}${"tokens".padStart(8)}${"ttft".padStart(9)}${"total".padStart(9)}${showTools ? "tools".padStart(6) : ""}${showCost ? "cost".padStart(9) : ""}${showCache ? "cached".padStart(9) : ""} `, + "dim", + ], + [ENGINE_MARK, "engine"], + ] + out.push(header, [["─".repeat(Math.min(w, width(header))), "rule"]]) + const notes: string[] = [] + for (const t of rows.slice(0, 200)) { + const calls = Object.values(t.tools ?? {}).reduce((a, x) => a + x.n, 0) + const model = t.model.length > modelW - 1 ? `${t.model.slice(0, modelW - 2)}…` : t.model + out.push([ + [` ${clock(t.at).padEnd(6)}`, ""], + ...(showModel ? ([[model.padEnd(modelW), "dim"]] as Line) : []), + [(t.rate !== undefined && t.rateWindow !== "whole" && !t.outcome ? n1(t.rate) : "—").padStart(8), "bold"], + [`${(t.tokens > 0 ? n0(t.tokens) : "—").padStart(8)}${(t.ttft !== undefined ? `${t.ttft.toFixed(2)}s` : "—").padStart(9)}${(t.totalS !== undefined ? dur(t.totalS) : "—").padStart(9)}${showTools ? (calls > 0 ? String(calls) : "").padStart(6) : ""}`, ""], + ...(showCost ? ([[(t.cost ? `$${t.cost.toFixed(4)}` : "").padStart(9), ""]] as Line) : []), + ...(showCache ? ([[(t.cached ? n0(t.cached) : "").padStart(9), "dim"]] as Line) : []), + [" ", ""], + t.source === "engine" ? [ENGINE_MARK, "engine"] : ["·", "dim"], + ]) + if (t.outcome) notes.push(`${clock(t.at)} ${t.outcome}`) + else if (t.source !== "engine" && t.skip && t.skip !== "no-adapter") notes.push(`${clock(t.at)} ${SKIP_LABEL[t.skip]}`) + } + if (notes.length > 0) { + out.push([]) + for (const l of wrap(notes.slice(0, 6).join(" · "), w - 2)) out.push([[` ${l}`, "dim"]]) + } + return out +} diff --git a/package.json b/package.json index 32c044e..4a5fce7 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "files": [ "tui.tsx", "detail.ts", + "dialog.ts", "counters.ts", "format.ts", "http.ts", diff --git a/session.ts b/session.ts index 5793ecf..00a9b3e 100644 --- a/session.ts +++ b/session.ts @@ -17,7 +17,6 @@ import { nn, ni, short, money } from "./format" import { streamOf, type TurnRecord } from "./history" import { rowsOf, nt, type Row, type TurnView } from "./rows" -import { percents, wrap, ENGINE_MARK, type Section } from "./detail" /** Recent turns shown in the generation trend. */ export const TREND_TURNS = 8 @@ -229,7 +228,6 @@ export function sparkline(values: readonly number[]): string { } const pct = (v: number): string => `${ni(v * 100)}%` -const pct1 = pct /** * The section as a view, laid out like the per-turn box: a heading, then @@ -280,19 +278,37 @@ export function sessionView(s: SessionSummary): TurnView { return { engine, rows, notes: [], key: s.genTokS !== undefined ? `${nn(s.genTokS)} tok/s` : undefined } } -// ---- the details dialog's session column ------------------------------------- +// ---- the details dialog's session tab ------------------------------------------- -/** The value at fraction `q` of the sorted values (nearest rank). */ -function quantile(xs: readonly number[], q: number): number | undefined { +/** The session's figures for the details dialog, as data; dialog.ts lays them out. */ +export interface SessionFigures { + summary: SessionSummary + turns: number + steps: number + toolCalls: number + elapsedS: number + cost?: number + /** Per-turn generation rates, newest first. */ + rates: number[] + ttfts: number[] + /** Seconds, over the turns that recorded every part. */ + time?: { waiting: number; generating: number; tools: number; subagents: number; compaction: number; other: number; total: number } + /** Per tool name, most time first. */ + tools: Array<{ name: string; s: number; n: number }> + tokens: { output: number; reasoning: number; input?: number; cacheRead?: number; cacheWrite: number } + retryReasons: Array<{ reason: string; n: number }> + coverage: { engine: number; total: number; without: Array<{ label: string; n: number }> } +} + +/** The value at fraction `q` of the values (nearest rank). */ +export function quantile(xs: readonly number[], q: number): number | undefined { if (xs.length === 0) return undefined const s = [...xs].sort((a, b) => a - b) return s[Math.min(s.length - 1, Math.max(0, Math.ceil(q * s.length) - 1))] } -const dur = (s: number): string => - s >= 60 ? `${Math.floor(s / 60)}m ${String(Math.round(s % 60)).padStart(2, "0")}s` : `${s.toFixed(2)}s` - -const SKIP_LABEL: Record, string> = { +/** Why a turn has no engine figures, in words. */ +export const SKIP_LABEL: Record, string> = { baseline: "first turn, no baseline", overlap: "overlapping requests", compaction: "compaction", @@ -302,58 +318,17 @@ const SKIP_LABEL: Record, string> = { } /** - * The session column: the Session box's figures spread out, the time in - * seconds, tools by time, retries by reason, and how many turns had the - * engine's own figures. Only the current model's turns, like the box. - * Figures from fields an older history row lacks are left out, not guessed. + * The session's figures: the Session box's spread out, the time in seconds, + * tools by time, retries by reason, and how many turns had the engine's own + * figures. Only the current model's turns, like the box. A figure from a + * field older history rows lack is left out, not guessed. */ -export function sessionSections(history: readonly TurnRecord[], sessionID: string | undefined): Section[] { - const s = summariseSession(history, sessionID) - if (!s) return [{ title: "No turns yet", lines: ["The session's figures start with its first turn."] }] - const turns = history.filter((t) => t.sessionID === sessionID && t.provider === s.provider && t.model === s.model) - const out: Section[] = [] - - // Totals - const elapsed = turns.reduce((a, t) => a + (t.totalS ?? 0), 0) - const steps = turns.reduce((a, t) => a + (t.steps ?? 1), 0) - const calls = turns.reduce((a, t) => a + Object.values(t.tools ?? {}).reduce((b, x) => b + x.n, 0), 0) - const cost = turns.reduce((a, t) => a + (t.cost ?? 0), 0) - out.push({ - title: "Totals", - rows: [ - ["turns", ni(turns.length)], - ["steps", ni(steps)], - ...rowsOf("tool calls", [calls > 0 ? ni(calls) : ""]), - ["elapsed", dur(elapsed)], - ...rowsOf("cost", [cost > 0 ? money(cost) : ""]), - ], - }) - - // Speed and first-token spread - const rates = turns.flatMap((t) => { - const w = streamOf(t) - return w !== undefined ? [t.tokens / w] : [] - }) - const ttfts = turns.flatMap((t) => (t.ttft !== undefined ? [t.ttft] : [])) - const spread: Row[] = [] - if (s.genTokS !== undefined) spread.push(["speed", `${nn(s.genTokS)} tok/s avg`]) - if (rates.length > 1) { - spread.push(["", `${nn(quantile(rates, 0) as number)} min · ${nn(quantile(rates, 0.5) as number)} median`]) - spread.push(["", `${nn(quantile(rates, 0.9) as number)} p90 · ${nn(quantile(rates, 1) as number)} max`]) - const spark = sparkline(rates.slice(0, 24).reverse()) - if (spark) spread.push(["trend", spark]) - } - if (ttfts.length > 0) { - spread.push( - ...rowsOf("ttft", [ - `${nn(quantile(ttfts, 0.5) as number, 2)}s median`, - ttfts.length > 1 ? `${nn(quantile(ttfts, 0.9) as number, 2)}s p90 · ${nn(quantile(ttfts, 1) as number, 2)}s max` : "", - ]) - ) - } - if (spread.length > 0) out.push({ title: "Speed", rows: spread }) +export function sessionFigures(history: readonly TurnRecord[], sessionID: string | undefined): SessionFigures | undefined { + const summary = summariseSession(history, sessionID) + if (!summary) return undefined + const turns = history.filter((t) => t.sessionID === sessionID && t.provider === summary.provider && t.model === summary.model) + const sum = (f: (t: TurnRecord) => number | undefined): number => turns.reduce((a, t) => a + (f(t) ?? 0), 0) - // Where the time went, in seconds, over the turns that recorded the parts let gen = 0 let wait = 0 let tools = 0 @@ -370,24 +345,7 @@ export function sessionSections(history: readonly TurnRecord[], sessionID: strin comp += t.compactionS ?? 0 total += t.totalS } - if (total > 0) { - const parts: Array<[string, number]> = [ - ["waiting", wait], - ["generating", gen], - ["tools", tools], - ["sub-agents", sub], - ["compaction", comp], - ["other", Math.max(0, total - wait - gen - tools - sub - comp)], - ] - const shown = parts.filter(([label, v]) => v > 0 || label === "waiting" || label === "generating") - const pct = percents(shown.map(([, v]) => v)) - out.push({ - title: `Where the time went · ${dur(total)}`, - rows: shown.map(([label, v], i) => [label, `${dur(v).padStart(8)} ${String(pct[i]).padStart(3)}%`] as const), - }) - } - // Tools by time const byTool = new Map() for (const t of turns) { for (const [name, x] of Object.entries(t.tools ?? {})) { @@ -397,74 +355,46 @@ export function sessionSections(history: readonly TurnRecord[], sessionID: strin byTool.set(name, cur) } } - if (byTool.size > 0) { - const sorted = [...byTool.entries()].sort((a, b) => b[1].s - a[1].s) - const shownTools = sorted.slice(0, 8) - const rest = sorted.slice(8) - out.push({ - title: "Tools by time", - rows: [ - ...shownTools.map(([name, x]) => [name.slice(0, 11), `${dur(x.s)} · ${ni(x.n)} ${x.n === 1 ? "call" : "calls"}`] as const), - ...rowsOf("others", [ - rest.length > 0 ? `${dur(rest.reduce((a, [, x]) => a + x.s, 0))} · ${ni(rest.length)} tools` : "", - ]), - ], - }) - } - - // Tokens - const sum = (f: (t: TurnRecord) => number | undefined): number => turns.reduce((a, t) => a + (f(t) ?? 0), 0) - const reasoning = sum((t) => t.reasoning) - const tokenRows: Row[] = [ - ["output", nt(sum((t) => t.tokens) - reasoning)], - ...rowsOf("reasoning", [reasoning > 0 ? nt(reasoning) : ""]), - ...rowsOf("input", [ - turns.some((t) => t.promptTokens !== undefined) ? `${nt(sum((t) => t.promptTokens))} fresh` : "", - turns.some((t) => t.cached !== undefined) ? `${nt(sum((t) => t.cached))} cache read` : "", - sum((t) => t.cacheWrite) > 0 ? `${nt(sum((t) => t.cacheWrite))} cache write` : "", - ]), - ...rowsOf("cache", [s.cacheHit !== undefined ? `${pct1(s.cacheHit)} hit` : ""]), - ] - out.push({ title: "Tokens", rows: tokenRows }) - - // Retries by reason const reasons = new Map() for (const t of turns) for (const r of t.retryReasons ?? []) reasons.set(r, (reasons.get(r) ?? 0) + 1) - if (s.retries > 0) { - const lines = [...reasons.entries()] - .sort((a, b) => b[1] - a[1]) - .flatMap(([r, n]) => wrap(`${n}× ${r}`, 46)) - out.push({ title: `Retries · ${ni(s.retries)}`, lines: lines.length > 0 ? lines : ["reasons not recorded"] }) - } - - // Coverage: which turns had the engine's own figures, and why not the rest - const engineTurns = turns.filter((t) => t.source === "engine").length const why = new Map() for (const t of turns) { if (t.source === "engine") continue const label = t.skip ? SKIP_LABEL[t.skip] : "reason not recorded" why.set(label, (why.get(label) ?? 0) + 1) } - out.push({ - title: "Coverage", - rows: [ - ["engine", `${ni(engineTurns)} of ${ni(turns.length)} turns`], - ...[...why.entries()].map(([label, n], i) => [i === 0 ? "without" : "", `${ni(n)} ${label}`] as const), - ], - }) + const reasoning = sum((t) => t.reasoning) + const cost = sum((t) => t.cost) - // Engine averages and sub-agents, as in the box - const box = sessionView(s).rows.filter(([l]) => l === "MTP" || l === "draft" || l === "prefill") - if (box.length > 0) out.push({ title: `${ENGINE_MARK} Engine averages`, rows: box }) - if (s.subagents) { - out.push({ - title: "Sub-agents", - rows: [ - ["count", ni(s.subagents.count)], - ["tokens", nt(s.subagents.tokens)], - ...rowsOf("cost", [money(s.subagents.cost)]), - ], - }) + return { + summary, + turns: turns.length, + steps: sum((t) => t.steps ?? 1), + toolCalls: [...byTool.values()].reduce((a, x) => a + x.n, 0), + elapsedS: sum((t) => t.totalS), + cost: cost > 0 ? cost : undefined, + rates: turns.flatMap((t) => { + const w = streamOf(t) + return w !== undefined ? [t.tokens / w] : [] + }), + ttfts: turns.flatMap((t) => (t.ttft !== undefined ? [t.ttft] : [])), + time: + total > 0 + ? { waiting: wait, generating: gen, tools, subagents: sub, compaction: comp, other: Math.max(0, total - wait - gen - tools - sub - comp), total } + : undefined, + tools: [...byTool.entries()].map(([name, x]) => ({ name, ...x })).sort((a, b) => b.s - a.s), + tokens: { + output: sum((t) => t.tokens) - reasoning, + reasoning, + input: turns.some((t) => t.promptTokens !== undefined) ? sum((t) => t.promptTokens) : undefined, + cacheRead: turns.some((t) => t.cached !== undefined) ? sum((t) => t.cached) : undefined, + cacheWrite: sum((t) => t.cacheWrite), + }, + retryReasons: [...reasons.entries()].map(([reason, n]) => ({ reason, n })).sort((a, b) => b.n - a.n), + coverage: { + engine: turns.filter((t) => t.source === "engine").length, + total: turns.length, + without: [...why.entries()].map(([label, n]) => ({ label, n })), + }, } - return out } diff --git a/test/detail.test.mjs b/test/detail.test.mjs index 51b0272..71f95bf 100644 --- a/test/detail.test.mjs +++ b/test/detail.test.mjs @@ -1,7 +1,7 @@ // Validates detail.ts -- a turn's full detail, for the details dialog. // Run with: bun test/detail.test.mjs import { strict as assert } from "node:assert" -import { buildTurnDetail, unionSeconds, percents, turnSections } from "../detail.ts" +import { buildTurnDetail, unionSeconds, percents } from "../detail.ts" let passed = 0 function test(name, fn) { @@ -86,50 +86,6 @@ test("no total, no split: nothing is made up", () => { assert.equal(buildTurnDetail(steps, marks, { ...base, totalS: undefined }).time, undefined) }) -test("sections fit a 46-cell column", () => { - const d = buildTurnDetail(steps, marks, base) - for (const s of turnSections(d)) { - for (const [l, v] of s.rows ?? []) assert.ok(12 + v.length <= 46, `${l}: ${v}`) - for (const line of s.lines ?? []) assert.ok(line.length <= 46, line) - } -}) - -test("the steps table lists each tool call on its own line", () => { - const steps_ = turnSections(buildTurnDetail(steps, marks, base)).find((s) => s.title.startsWith("Steps")) - assert.ok(steps_.lines.some((l) => l.includes("task") && l.includes("10.00s")), steps_.lines.join("\n")) - assert.ok(steps_.lines.some((l) => l.includes("— stop")), steps_.lines.join("\n")) - assert.ok(steps_.lines.some((l) => l.includes("1 retry")), steps_.lines.join("\n")) -}) - -test("the engine section is marked, and says why when there are no engine figures", () => { - const withEngine = turnSections(buildTurnDetail(steps, marks, { ...base, engineRows: [["speed", "36.1 tok/s"]] })) - assert.ok(withEngine.some((s) => s.title === "◆ Engine · MTPLX"), withEngine.map((s) => s.title).join(", ")) - const skipped = turnSections(buildTurnDetail(steps, marks, { ...base, engineNote: ["engine data skipped:", "overlapping requests"] })) - const e = skipped.find((s) => s.title === "Engine · MTPLX") - assert.deepEqual(e.lines, ["MTPLX's figures were left out:", "engine data skipped:", "overlapping requests"]) - const none = turnSections(buildTurnDetail(steps, marks, { ...base, engine: "openai" })).find((s) => s.title === "Engine · openai") - assert.deepEqual(none.lines, ["no engine telemetry for this provider"]) -}) - -test("per-step engine rates are listed when the engine read each step", () => { - const d = buildTurnDetail(steps, marks, { - ...base, - engineRows: [["speed", "36.1 tok/s"]], - stepEngine: [{ decodeTokS: 35.2, prefillTokS: 452 }, { decodeTokS: 36.9 }, undefined], - }) - const e = turnSections(d).find((s) => s.title.startsWith("◆ Engine")) - assert.ok(e.lines.includes("step 1 35.2 tok/s prefill 452 tok/s"), e.lines.join("\n")) - assert.ok(e.lines.includes("step 2 36.9 tok/s"), e.lines.join("\n")) -}) - -test("retry reasons are listed in full, wrapped to the column", () => { - const long = "Rate limited by the provider; retrying after the backoff window elapses" - const retried = [steps[0], { ...steps[1], retry: { attempt: 2, at: 0, error: { type: "x", message: long } } }, steps[2]] - const sec = turnSections(buildTurnDetail(retried, marks, base)).find((s) => s.title === "Retries and errors") - assert.equal(sec.lines.join(" "), `step 2: ${long}`) - sec.lines.forEach((l) => assert.ok(l.length <= 46, l)) -}) - test("a compaction gets its own share, and the step it delayed says so", () => { // OpenCode compacts from 17.5s to 18.4s; step c was created at 18s and // waited until 18.5s for its first token, 0.4s of it on the compaction. @@ -139,8 +95,6 @@ test("a compaction gets its own share, and the step it delayed says so", () => { const sum = d.time.waiting + d.time.generating + d.time.tools + d.time.subagents + d.time.compaction + d.time.other assert.ok(Math.abs(sum - 20) < 1e-9, String(sum)) assert.ok(Math.abs(d.steps[2].compactionS - 0.4) < 1e-9, String(d.steps[2].compactionS)) - const lines = turnSections(d).find((s) => s.title.startsWith("Steps")).lines - assert.ok(lines.some((l) => l.includes("waited on compaction 0.40s")), lines.join("\n")) }) console.log(`\n${passed} passed`) diff --git a/tui.tsx b/tui.tsx index ddce647..a9df56d 100644 --- a/tui.tsx +++ b/tui.tsx @@ -33,9 +33,23 @@ import { universalView, turnRate, turnSteps, turnUserAt, lastModel, aggregateTur import { record, historyLines, type History, type TurnRecord } from "./history" import { emptyPanels, lineFor, keyFor, setLine, LatestPerKey, PLACEHOLDER, type Panels } from "./panels" import { encodeView, decodeView, LABEL_WIDTH, type TurnView } from "./rows" -import { summariseSession, sessionView, sessionSections, rollupSubagents, subagentRows } from "./session" +import { summariseSession, sessionView, sessionFigures, rollupSubagents, subagentRows } from "./session" import { shiftBaseline, counterDelta } from "./counters" -import { buildTurnDetail, turnSections, ENGINE_MARK, SUBAGENT_TOOLS, type TurnDetail, type Section } from "./detail" +import { buildTurnDetail, SUBAGENT_TOOLS, type TurnDetail } from "./detail" +import { + turnLines, + sessionLines, + historyTabLines, + tabsLine, + footLine, + dur, + TABS, + CONTENT_WIDTH, + type Line, + type Style, + type Tab, + type Scope, +} from "./dialog" import { fetchMtplxLatest, mtplxView, combineMtplxSteps, type MtplxLatest } from "./adapters/mtplx" import { fetchOmlxSample, omlxView, omlxIsThisTurn, type OmlxSample } from "./adapters/omlx" @@ -310,123 +324,161 @@ export default Plugin.define({ // built: ui.dialog.show draws at xlarge, 116 cells on a 214-column // terminal; a scrollbox inside scrolls by wheel and by keys; a keymap // layer inside the dialog takes tab without the prompt seeing it. - const [details, setDetails] = ctx.storage.memory<{ tab: "turn" | "session"; cols: number; rows: number }>( + const [details, setDetails] = ctx.storage.memory<{ tab: Tab; scope: Scope; cols: number; rows: number }>( "details", - { initial: { tab: "turn", cols: 0, rows: 0 } } + { initial: { tab: "turn", scope: "session", cols: 0, rows: 0 } } + ) + // The dialog's colours, from the theme's hue scales (measured on 2.0.12: + // hue.{gray,blue,cyan,purple,orange,red}.{100..900}), each with a fallback. + const styleColor = (st: Style): Color | undefined => { + switch (st) { + case "dim": + case "wait": + return subduedColor() + case "gen": + case "accent": + return themeColor("hue.blue.500", "text.action.primary") as Color | undefined + case "tool": + return themeColor("hue.cyan.500", "hue.green.500") as Color | undefined + case "sub": + return themeColor("hue.purple.500") as Color | undefined + case "comp": + return themeColor("hue.red.400", "hue.red.500") as Color | undefined + case "engine": + return themeColor("hue.orange.400", "hue.yellow.500") as Color | undefined + case "rule": + return themeColor("hue.gray.700", "border.base") as Color | undefined + default: + return undefined + } + } + /** One line of the dialog: a `` with a span per segment. */ + const drawLine = (line: Line) => ( + + {line.length === 0 + ? " " + : line.map(([t, st]) => + st === "bold" ? ( + {t} + ) : st === "tab" ? ( + + {t} + + ) : ( + {t} + ) + )} + ) - /** Below this many terminal columns the two columns are one, switched by tab. */ - const TWO_COLUMN_MIN = 110 - const DETAIL_COL = 46 // Whether our dialog is the one showing. The keybind toggles it: pressed // again while it was open, it re-opened the dialog over itself (a blink). let detailsOpen = false - const openDetails = (sessionID: string | undefined): void => { + /** + * Opens the dialog on `tab`. Asked again for the tab already showing, it + * closes; asked for another tab while open, it switches. + */ + const openDetails = (sessionID: string | undefined, tab: Tab = "turn"): void => { if (detailsOpen) { - dbg("details: toggle closed") - ctx.ui.dialog.clear() + if (details.tab === tab) { + dbg("details: toggle closed") + ctx.ui.dialog.clear() + } else { + setDetails((d) => { + d.tab = tab + }) + } return } - dbg(`details: open for ${sessionID ?? "no session"}; terminal ${ctx.renderer.terminalWidth}x${ctx.renderer.terminalHeight}`) - const stored = sessionID ? lineFor(panel, sessionID) : PLACEHOLDER - const turnView: TurnView = - stored === PLACEHOLDER ? { engine: "last turn", rows: [], notes: ["no turn yet"] } : decodeView(stored) - const summary = summariseSession(history.turns, sessionID) - const sessView: TurnView = summary ? sessionView(summary) : { engine: "Session", rows: [], notes: ["no turns yet"] } - let scroll: { scrollBy?: (d: number) => void; width?: number; height?: number; focus?: () => void } | undefined - let root: { width?: number; height?: number } | undefined - // The terminal's size, kept current while the dialog is open, so a - // resize re-lays it out (one column or two, and the body's height). - const sized = (cols: number, rows: number): void => { + dbg(`details: open ${tab} for ${sessionID ?? "no session"}; terminal ${ctx.renderer.terminalWidth}x${ctx.renderer.terminalHeight}`) + setDetails((d) => { + d.tab = tab + d.cols = ctx.renderer.terminalWidth + d.rows = ctx.renderer.terminalHeight + }) + // A resize re-lays the dialog out: its height follows the terminal's. + const onResize = (cols: number, rows: number): void => { setDetails((d) => { d.cols = cols d.rows = rows }) } - sized(ctx.renderer.terminalWidth, ctx.renderer.terminalHeight) - const onResize = (cols: number, rows: number): void => { - dbg(`details: resize ${cols}x${rows}`) - sized(cols, rows) - } ctx.renderer.on("resize", onResize) + let scroll: { scrollBy?: (d: number) => void; focus?: () => void; width?: number; height?: number } | undefined + let root: { width?: number; height?: number } | undefined detailsOpen = true ctx.ui.dialog.show( () => { - const subdued = subduedColor() - const wide = (): boolean => details.cols >= TWO_COLUMN_MIN - // Title, blank, blank, footer, the dialog's own padding, and room - // above and below it on screen. - const pageRows = (): number => Math.max(4, details.rows - 16) + // The current tab's lines, reactive on the tab, the scope and the + // stored turn and history. + const lines = (): Line[] => { + if (details.tab === "turn") return turnLines(sessionID ? turnDetail.bySession[sessionID] : undefined) + if (details.tab === "session") return sessionLines(sessionFigures(history.turns, sessionID)) + return historyTabLines(history.turns, sessionID, details.scope) + } + const note = (): string => { + if (details.tab === "turn") { + const d = sessionID ? turnDetail.bySession[sessionID] : undefined + return d ? `${d.engine}${d.totalS !== undefined ? ` · ${dur(d.totalS)}` : ""}` : "" + } + if (details.tab === "session") { + const f = sessionFigures(history.turns, sessionID) + return f ? `${f.turns} ${f.turns === 1 ? "turn" : "turns"} · ${dur(f.elapsedS)}` : "" + } + return details.scope === "all" ? "all sessions" : "this session" + } + // Sized to the content, up to 70% of the screen less the tabs and + // footer; it scrolls only beyond that. + const bodyRows = (): number => Math.max(4, Math.min(lines().length, Math.floor(details.rows * 0.7) - 4)) ctx.keymap.layer(() => ({ mode: "global", priority: 100, commands: [ { - title: "Switch turn / session", + title: "Next view", bind: "tab", + run: () => + setDetails((d) => { + d.tab = TABS[(TABS.indexOf(d.tab) + 1) % TABS.length] as Tab + }), + }, + { + title: "Previous view", + bind: "shift+tab", + run: () => + setDetails((d) => { + d.tab = TABS[(TABS.indexOf(d.tab) + TABS.length - 1) % TABS.length] as Tab + }), + }, + { + title: "This session / all sessions", + bind: "s", run: () => { + if (details.tab !== "history") return false setDetails((d) => { - d.tab = d.tab === "turn" ? "session" : "turn" + d.scope = d.scope === "session" ? "all" : "session" }) - dbg(`details: tab -> ${details.tab}`) }, }, { title: "Scroll down", bind: "down", run: () => scroll?.scrollBy?.(1) }, { title: "Scroll up", bind: "up", run: () => scroll?.scrollBy?.(-1) }, - { title: "Page down", bind: "pagedown", run: () => scroll?.scrollBy?.(pageRows()) }, - { title: "Page up", bind: "pageup", run: () => scroll?.scrollBy?.(-pageRows()) }, + { title: "Page down", bind: "pagedown", run: () => scroll?.scrollBy?.(bodyRows()) }, + { title: "Page up", bind: "pageup", run: () => scroll?.scrollBy?.(-bodyRows()) }, ], })) - const column = (title: string, sections: Section[]) => ( - - - {title} - - {sections.map((sec) => ( - - - {sec.title} - - {(sec.rows ?? []).map(([label, value]) => ( - - {label.padEnd(LABEL_WIDTH)} - {value} - - ))} - {(sec.lines ?? []).map((l, i) => ( - - {l || " "} - - ))} - - ))} - - ) - const detail = sessionID ? turnDetail.bySession[sessionID] : undefined - const turnTitle = `Last turn · ${detail?.engine ?? turnView.engine}${detail?.outcome ? ` · ${detail.outcome}` : ""}` - const turnCol = (): Section[] => - detail ? turnSections(detail) : [{ title: "No turn yet in this run", lines: ["Details start with the next turn."] }] - const sessCol = (): Section[] => sessionSections(history.turns, sessionID) setTimeout(() => { - dbg( - `details: wide ${wide()}; dialog ${root?.width ?? "?"}x${root?.height ?? "?"}; ` + - `scrollbox ${scroll?.width ?? "?"}x${scroll?.height ?? "?"}` - ) - }, 300) + // `large` first; if it is narrower than the content needs, `xlarge`. + const need = CONTENT_WIDTH + 4 + if (root?.width !== undefined && root.width < need) ctx.ui.dialog.set({ size: "xlarge", centered: true }) + dbg(`details: dialog ${root?.width ?? "?"}x${root?.height ?? "?"}; body ${scroll?.width ?? "?"}x${scroll?.height ?? "?"}; ${lines().length} lines`) + }, 200) return ( (root = r as typeof root)} > - - Heads Up - - {wide() ? "" : ` · ${details.tab === "turn" ? "[turn] session" : "turn [session]"} tab switches`} - - + {drawLine(tabsLine(details.tab, note()))} { @@ -434,23 +486,12 @@ export default Plugin.define({ scroll?.focus?.() }} scrollY - height={pageRows()} + height={bodyRows()} > - {wide() ? ( - - {column(turnTitle, turnCol())} - {column(sessView.engine, sessCol())} - - ) : details.tab === "turn" ? ( - column(turnTitle, turnCol()) - ) : ( - column(sessView.engine, sessCol()) - )} + {lines().map((l) => drawLine(l))} - - {`${ENGINE_MARK} measured by the engine; the rest is OpenCode's · ↑↓ pgup pgdn · esc`} - + {drawLine(footLine(details.tab))} ) }, @@ -460,7 +501,7 @@ export default Plugin.define({ dbg("details: closed") } ) - ctx.ui.dialog.set({ size: "xlarge", centered: true }) + ctx.ui.dialog.set({ size: "large", centered: true }) } const currentSession = (): string | undefined => { const r = ctx.ui.router.current() @@ -1700,9 +1741,14 @@ export default Plugin.define({ group: "opencode-headsup", bind: "ctrl+shift+d", palette: true, - slash: { name: "headsup" }, - run: () => { - openDetails(currentSession()) + // `/headsup`, `/headsup session`, `/headsup history`: the rest of the + // line picks the tab (arguments: true passes it through). + slash: { name: "headsup", arguments: true }, + run: (input) => { + const arg = (input ?? "").trim().toLowerCase() + const tab: Tab = arg.startsWith("s") ? "session" : arg.startsWith("h") ? "history" : "turn" + if (arg) dbg(`details: /headsup ${arg} -> ${tab}`) + openDetails(currentSession(), tab) }, }, { @@ -1712,14 +1758,12 @@ export default Plugin.define({ group: "opencode-headsup", bind: "ctrl+shift+h", palette: true, + // The history panel is retired in favour of the dialog's History + // tab, which knows its width and never wraps a row. The panel's + // code stays (see the session.panel slot) for one release, in case + // the tab is missing something; this key now opens the tab. run: () => { - // A snapshot read, not a reactive one: this decides once. - // `current()` is per-plugin ("This plugin's active panel"), so - // it never sees another plugin's panel. - const open = ctx.ui.panel.current()?.name === PANEL_NAME - dbg(`panel toggle -> ${open ? "close" : "open"}`) - if (open) ctx.ui.panel.close() - else ctx.ui.panel.open(PANEL_NAME) + openDetails(currentSession(), "history") }, }, ], @@ -1774,7 +1818,7 @@ export default Plugin.define({ took the release as a click outside and closed it at once (measured: open and close 1ms apart). */} openDetails(input.sessionID)}> - details › + details › ) From 6b2fc125166e0cdffffab54b9b06ec7a29b90eb9 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 13:25:17 -0700 Subject: [PATCH 02/16] Lay the dialog out at its measured width; draw rules in the theme's border colour --- tui.tsx | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/tui.tsx b/tui.tsx index a9df56d..fb223ed 100644 --- a/tui.tsx +++ b/tui.tsx @@ -324,9 +324,9 @@ export default Plugin.define({ // built: ui.dialog.show draws at xlarge, 116 cells on a 214-column // terminal; a scrollbox inside scrolls by wheel and by keys; a keymap // layer inside the dialog takes tab without the prompt seeing it. - const [details, setDetails] = ctx.storage.memory<{ tab: Tab; scope: Scope; cols: number; rows: number }>( + const [details, setDetails] = ctx.storage.memory<{ tab: Tab; scope: Scope; cols: number; rows: number; w: number }>( "details", - { initial: { tab: "turn", scope: "session", cols: 0, rows: 0 } } + { initial: { tab: "turn", scope: "session", cols: 0, rows: 0, w: CONTENT_WIDTH } } ) // The dialog's colours, from the theme's hue scales (measured on 2.0.12: // hue.{gray,blue,cyan,purple,orange,red}.{100..900}), each with a fallback. @@ -347,7 +347,8 @@ export default Plugin.define({ case "engine": return themeColor("hue.orange.400", "hue.yellow.500") as Color | undefined case "rule": - return themeColor("hue.gray.700", "border.base") as Color | undefined + // hue.gray.700 was invisible on the dialog's background (measured). + return themeColor("border.base", "hue.gray.500") as Color | undefined default: return undefined } @@ -411,9 +412,9 @@ export default Plugin.define({ // The current tab's lines, reactive on the tab, the scope and the // stored turn and history. const lines = (): Line[] => { - if (details.tab === "turn") return turnLines(sessionID ? turnDetail.bySession[sessionID] : undefined) - if (details.tab === "session") return sessionLines(sessionFigures(history.turns, sessionID)) - return historyTabLines(history.turns, sessionID, details.scope) + if (details.tab === "turn") return turnLines(sessionID ? turnDetail.bySession[sessionID] : undefined, details.w) + if (details.tab === "session") return sessionLines(sessionFigures(history.turns, sessionID), details.w) + return historyTabLines(history.turns, sessionID, details.scope, details.w) } const note = (): string => { if (details.tab === "turn") { @@ -465,12 +466,27 @@ export default Plugin.define({ { title: "Page up", bind: "pageup", run: () => scroll?.scrollBy?.(-bodyRows()) }, ], })) - setTimeout(() => { - // `large` first; if it is narrower than the content needs, `xlarge`. - const need = CONTENT_WIDTH + 4 - if (root?.width !== undefined && root.width < need) ctx.ui.dialog.set({ size: "xlarge", centered: true }) - dbg(`details: dialog ${root?.width ?? "?"}x${root?.height ?? "?"}; body ${scroll?.width ?? "?"}x${scroll?.height ?? "?"}; ${lines().length} lines`) - }, 200) + // The content takes the dialog's measured width, less its padding: + // `large` measured 88 cells on a 241-column terminal, and a fixed + // 72-cell layout left an empty strip on the right. Narrower than + // the layout needs, the dialog goes to `xlarge` and is measured again. + const fit = (tries: number): void => { + setTimeout(() => { + const inner = root?.width !== undefined ? root.width - 4 : undefined + if (inner !== undefined && inner < CONTENT_WIDTH && tries > 0) { + ctx.ui.dialog.set({ size: "xlarge", centered: true }) + fit(tries - 1) + return + } + if (inner !== undefined) { + setDetails((d) => { + d.w = Math.max(CONTENT_WIDTH, Math.min(110, inner)) + }) + } + dbg(`details: dialog ${root?.width ?? "?"}x${root?.height ?? "?"}; content ${details.w} wide; ${lines().length} lines`) + }, 60) + } + fit(1) return ( (root = r as typeof root)} > - {drawLine(tabsLine(details.tab, note()))} + {drawLine(tabsLine(details.tab, note(), details.w))} { @@ -491,7 +507,7 @@ export default Plugin.define({ {lines().map((l) => drawLine(l))} - {drawLine(footLine(details.tab))} + {drawLine(footLine(details.tab, details.w))} ) }, From 2fe0583822eeee9dec607b1bf34990bf18e838d2 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 13:32:03 -0700 Subject: [PATCH 03/16] Pick dialog colours by contrast with the theme's background; pack the engine figures; keep lines clear of the scrollbar --- dialog.ts | 40 +++++++++++++++++++++++++++++-- tui.tsx | 71 ++++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/dialog.ts b/dialog.ts index 05cd3a5..5366632 100644 --- a/dialog.ts +++ b/dialog.ts @@ -271,12 +271,13 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] if (d.cost !== undefined) out.push(row("cost", `$${d.cost.toFixed(4)}`)) out.push([]) - // Engine: its own figures only, or which were left out and why. + // Engine: its own figures only, packed a few to a line, or which were + // left out and why. if (d.engineRows.length > 0) { const title = `${ENGINE_MARK} ${d.engine}` const note = " measured by the engine" out.push([[title, "engine"], [" ", ""], ["─".repeat(Math.max(0, w - title.length - 1 - note.length)), "rule"], [note, "dim"]]) - for (const [l, v] of d.engineRows) out.push(row(l, v)) + out.push(...packRows(d.engineRows, w)) if (d.compactionEngine) for (const c of d.compactionEngine) out.push(row("compaction", c, " taken out")) } else { out.push(heading(d.engine, w)) @@ -292,6 +293,41 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] return out } +/** + * Label/value rows packed several to a line: `speed 41.2 tok/s prefill 475 + * tok/s ttft 17.37s`. A row with an empty label continues the one above; + * acceptance by depth (`93% at depth 1`, ...) folds into `93/87/82% by depth`. + */ +export function packRows(rows: ReadonlyArray, w: number): Line[] { + const groups: Array<{ label: string; values: string[] }> = [] + for (const [label, value] of rows) { + if (label || groups.length === 0) groups.push({ label, values: [value] }) + else (groups[groups.length - 1] as { values: string[] }).values.push(value) + } + const segs = groups.map(({ label, values }): Line => { + const depths = values.map((v) => /^(\d+)% at depth \d+$/.exec(v)?.[1]) + if (depths.length > 1 && depths.every((x) => x !== undefined)) { + return [[label, "dim"], [" ", ""], [`${depths.join("/")}%`, "bold"], [" by depth", "dim"]] + } + const [first, ...rest] = values + return [[label, "dim"], [" ", ""], [first ?? "", "bold"], ...(rest.length > 0 ? ([[` ${rest.join(" ")}`, "dim"]] as Line) : [])] + }) + const out: Line[] = [] + let cur: Line = [] + for (const g of segs) { + const [labelSeg, , ...value] = g + const lead: Line = cur.length === 0 ? [[(labelSeg as Seg)[0].padEnd(LABEL), "dim"], ...value] : [[" ", ""], ...g] + if (cur.length > 0 && width(cur) + width(lead) > w) { + out.push(cur) + cur = [[(labelSeg as Seg)[0].padEnd(LABEL), "dim"], ...value] + } else { + cur = [...cur, ...lead] + } + } + if (cur.length > 0) out.push(cur) + return out +} + // ---- Session ------------------------------------------------------------------------ /** The Session tab. */ diff --git a/tui.tsx b/tui.tsx index fb223ed..0959e4c 100644 --- a/tui.tsx +++ b/tui.tsx @@ -330,28 +330,66 @@ export default Plugin.define({ ) // The dialog's colours, from the theme's hue scales (measured on 2.0.12: // hue.{gray,blue,cyan,purple,orange,red}.{100..900}), each with a fallback. + // Each hue's shade is picked by contrast against the theme's background, + // not by a fixed step: a fixed 500 came out dark navy on the dark theme + // and the rules' gray matched the dialog's background (both measured). + type Rgb = { toInts?: () => [number, number, number, number] } + const lum = (c: Rgb): number | undefined => { + const i = c.toInts?.() + if (!i) return undefined + const ch = (v: number): number => { + const x = v / 255 + return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * ch(i[0]) + 0.7152 * ch(i[1]) + 0.0722 * ch(i[2]) + } + const shade = (hue: string, contrast: number): Color | undefined => { + const bg = themeColor("background.raised.high", "background.base") as Rgb | undefined + const lb = bg ? lum(bg) : undefined + let best: { c: unknown; d: number } | undefined + for (const step of [100, 200, 300, 400, 500, 600, 700, 800, 900]) { + const c = themeColor(`hue.${hue}.${step}`) as Rgb | undefined + const l = c ? lum(c) : undefined + if (c === undefined || l === undefined || lb === undefined) continue + const ratio = (Math.max(l, lb) + 0.05) / (Math.min(l, lb) + 0.05) + const d = Math.abs(ratio - contrast) + if (!best || d < best.d) best = { c, d } + } + return best?.c as Color | undefined + } + const palette = new Map() const styleColor = (st: Style): Color | undefined => { + if (palette.has(st)) return palette.get(st) + let c: Color | undefined switch (st) { case "dim": + c = subduedColor() + break case "wait": - return subduedColor() + c = shade("gray", 3) ?? subduedColor() + break case "gen": case "accent": - return themeColor("hue.blue.500", "text.action.primary") as Color | undefined + c = shade("blue", 6) ?? (themeColor("text.action.primary") as Color | undefined) + break case "tool": - return themeColor("hue.cyan.500", "hue.green.500") as Color | undefined + c = shade("cyan", 6) ?? shade("green", 6) + break case "sub": - return themeColor("hue.purple.500") as Color | undefined + c = shade("purple", 6) + break case "comp": - return themeColor("hue.red.400", "hue.red.500") as Color | undefined + c = shade("red", 5) + break case "engine": - return themeColor("hue.orange.400", "hue.yellow.500") as Color | undefined + c = shade("orange", 7) ?? shade("yellow", 7) + break case "rule": - // hue.gray.700 was invisible on the dialog's background (measured). - return themeColor("border.base", "hue.gray.500") as Color | undefined - default: - return undefined + c = shade("gray", 1.8) ?? (themeColor("border.base") as Color | undefined) + break } + palette.set(st, c) + return c } /** One line of the dialog: a `` with a span per segment. */ const drawLine = (line: Line) => ( @@ -472,7 +510,10 @@ export default Plugin.define({ // the layout needs, the dialog goes to `xlarge` and is measured again. const fit = (tries: number): void => { setTimeout(() => { - const inner = root?.width !== undefined ? root.width - 4 : undefined + // Less the side padding (4) and the scrollbar with a gap beside + // it (2): a full-width line that overflowed wrapped onto a second + // line, which read as a blank row under every heading (measured). + const inner = root?.width !== undefined ? root.width - 6 : undefined if (inner !== undefined && inner < CONTENT_WIDTH && tries > 0) { ctx.ui.dialog.set({ size: "xlarge", centered: true }) fit(tries - 1) @@ -484,6 +525,14 @@ export default Plugin.define({ }) } dbg(`details: dialog ${root?.width ?? "?"}x${root?.height ?? "?"}; content ${details.w} wide; ${lines().length} lines`) + if (HUD_DEBUG) { + const hex = (c: unknown): string => { + const i = (c as { toInts?: () => number[] } | undefined)?.toInts?.() + return i ? `#${i.slice(0, 3).map((v) => v.toString(16).padStart(2, "0")).join("")}` : "none" + } + const picks = (["gen", "wait", "tool", "sub", "engine", "rule"] as Style[]).map((st) => `${st} ${hex(styleColor(st))}`) + dbg(`details: colours ${picks.join(", ")}; background ${hex(themeColor("background.raised.high", "background.base"))}`) + } }, 60) } fit(1) From cdb0f99974a0b4c2e3e65fded34a77164206cc71 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 13:45:31 -0700 Subject: [PATCH 04/16] Make the dialog's figures agree across sections: generated tokens, true shares, units; tidy the history table --- dialog.ts | 65 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/dialog.ts b/dialog.ts index 5366632..9e42602 100644 --- a/dialog.ts +++ b/dialog.ts @@ -39,6 +39,15 @@ const clock = (ms: number): string => { return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` } +/** A share as a percent that never rounds a real part to 0 or the rest to 100. */ +export const share = (part: number, whole: number): string => { + if (whole <= 0) return "0%" + const p = (part / whole) * 100 + if (p > 0 && p < 1) return "<1%" + if (p < 100 && p > 99) return `${p.toFixed(1)}%` + return `${Math.round(p)}%` +} + // ---- building blocks ---------------------------------------------------------- /** A section title, a rule to the width, and an optional note at the right. */ @@ -118,7 +127,7 @@ function timeSplit( [GLYPH[k], STYLE[k]], [` ${NAME[k].padEnd(11)}`, "dim"], [dur(t[k]).padStart(8), "bold"], - [`${String(pct[i]).padStart(4)}%`, "dim"], + [(pct[i] === 0 && t[k] > 0 ? "<1%" : `${pct[i]}%`).padStart(5), "dim"], ] const half = Math.ceil(keys.length / 2) for (let r = 0; r < half; r++) { @@ -146,7 +155,7 @@ export function tabsLine(sel: Tab, right: string, w = CONTENT_WIDTH): Line { export function footLine(tab: Tab, w = CONTENT_WIDTH): Line { const keys: Line = [ ["tab", "bold"], - [" view ", "dim"], + [" switch view ", "dim"], ["↑↓", "bold"], [" scroll ", "dim"], ] @@ -247,26 +256,37 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] // Tokens: the five kinds, with the cache's share and the context used as bars. const t = d.tokens out.push(heading("Tokens", w)) + // Generated first, as the steps and the engine count it; then its split. const produced = t.output + t.reasoning - out.push(row("output", n0(t.output), t.reasoning > 0 ? ` +${n0(t.reasoning)} reasoning (${Math.round((t.reasoning / Math.max(1, produced)) * 100)}%)` : "")) + out.push( + row( + "generated", + n0(produced), + t.reasoning > 0 ? ` ${n0(t.output)} answer · ${n0(t.reasoning)} reasoning (${share(t.reasoning, produced)})` : "" + ) + ) const prompt = t.input + t.cacheRead + const BAR_AT = 24 if (prompt > 0) { + const head = row("prompt", n0(prompt)) out.push([ - ...row("input", n0(t.input), " fresh "), + ...head, + [" ".repeat(Math.max(1, BAR_AT - width(head))), ""], ...bar([[t.input, "gen", "█"], [t.cacheRead, "wait", "░"]], 20), - [` ${n0(t.cacheRead)} cached (${Math.round((t.cacheRead / prompt) * 100)}%)`, "dim"], + [` ${n0(t.input)} fresh · ${n0(t.cacheRead)} cached (${share(t.cacheRead, prompt)})`, "dim"], ]) } if (t.cacheWrite > 0) out.push(row("", n0(t.cacheWrite), " written to cache")) if (d.context) { + const head = row("context", n0(d.context.used)) const tail: Line = d.context.limit ? [ - [` of ${n0(d.context.limit)} `, "dim"], - ...bar([[d.context.used, "gen", "█"], [Math.max(0, d.context.limit - d.context.used), "wait", "░"]], 16), - [` ${Math.round((d.context.used / d.context.limit) * 100)}%`, "dim"], + [" ".repeat(Math.max(1, BAR_AT - width(head))), ""], + ...bar([[d.context.used, "gen", "█"], [Math.max(0, d.context.limit - d.context.used), "wait", "░"]], 20), + [` ${share(d.context.used, d.context.limit)} of ${n0(d.context.limit)}`, "dim"], ] : [] - out.push([...row("context", n0(d.context.used)), ...tail]) + out.push([...head, ...tail]) } if (d.cost !== undefined) out.push(row("cost", `$${d.cost.toFixed(4)}`)) out.push([]) @@ -336,13 +356,13 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): const out: Line[] = [] const s = f.summary - out.push(heading("Speed", w, "tok/s, generation only")) + out.push(heading("Speed", w, "generation only")) if (s.genTokS !== undefined) { const spread = f.rates.length > 1 ? ` min ${n1(quantile(f.rates, 0) as number)} · median ${n1(quantile(f.rates, 0.5) as number)} · p90 ${n1(quantile(f.rates, 0.9) as number)} · max ${n1(quantile(f.rates, 1) as number)}` : "" - out.push(row("average", n1(s.genTokS), spread)) + out.push(row("average", `${n1(s.genTokS)} tok/s`, spread)) } const spark = sparkline(f.rates.slice(0, 24).reverse()) if (spark) out.push([...row("trend", ""), [spark, "accent"], [` last ${Math.min(24, f.rates.length)} turns`, "dim"]]) @@ -372,17 +392,21 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): out.push(heading("Coverage", w, "turns with the engine's own figures")) out.push([ - ...row("engine", `${n0(f.coverage.engine)} of ${n0(f.coverage.total)}`, " "), + ...row("engine", `${n0(f.coverage.engine)} of ${n0(f.coverage.total)}`, " ".repeat(Math.max(1, 12 - `${n0(f.coverage.engine)} of ${n0(f.coverage.total)}`.length))), ...bar([[f.coverage.engine, "gen", "█"], [f.coverage.total - f.coverage.engine, "wait", "░"]], 20), ]) f.coverage.without.forEach(({ label, n }, i) => out.push(row(i === 0 ? "without" : "", "", `${n0(n)} ${label}`))) out.push([]) out.push(heading("Tokens", w)) - out.push(row("output", n0(f.tokens.output), f.tokens.reasoning > 0 ? ` +${n0(f.tokens.reasoning)} reasoning` : "")) + const gen = f.tokens.output + f.tokens.reasoning + out.push( + row("generated", n0(gen), f.tokens.reasoning > 0 ? ` ${n0(f.tokens.output)} answer · ${n0(f.tokens.reasoning)} reasoning (${share(f.tokens.reasoning, gen)})` : "") + ) if (f.tokens.input !== undefined || f.tokens.cacheRead !== undefined) { - const hit = s.cacheHit !== undefined ? ` (${Math.round(s.cacheHit * 100)}% hit)` : "" - out.push(row("input", n0(f.tokens.input ?? 0), ` fresh ${n0(f.tokens.cacheRead ?? 0)} cached${hit}`)) + const fresh = f.tokens.input ?? 0 + const cached = f.tokens.cacheRead ?? 0 + out.push(row("prompt", n0(fresh + cached), ` ${n0(fresh)} fresh · ${n0(cached)} cached (${share(cached, fresh + cached)})`)) } if (f.tokens.cacheWrite > 0) out.push(row("", n0(f.tokens.cacheWrite), " written to cache")) out.push(row("totals", `${n0(f.turns)} turns`, ` ${n0(f.steps)} steps · ${dur(f.elapsedS)}${f.cost !== undefined ? ` · $${f.cost.toFixed(4)}` : ""}`)) @@ -426,7 +450,7 @@ export function historyTabLines( // Across every session the model column needs the room; cache and tool // counts stay in the session view, where the model is one. const showCache = !showModel && rows.some((t) => (t.cached ?? 0) > 0) - const showTools = !showModel + const showTools = !showModel && rows.some((t) => Object.values(t.tools ?? {}).some((x) => x.n > 0)) // Headline: the scope's totals, and its generation speed on the newest model. const tokens = rows.reduce((a, t) => a + t.tokens, 0) @@ -463,18 +487,21 @@ export function historyTabLines( ], [ENGINE_MARK, "engine"], ] - out.push(header, [["─".repeat(Math.min(w, width(header))), "rule"]]) + out.push(header, [["─".repeat(w), "rule"]]) const notes: string[] = [] for (const t of rows.slice(0, 200)) { const calls = Object.values(t.tools ?? {}).reduce((a, x) => a + x.n, 0) - const model = t.model.length > modelW - 1 ? `${t.model.slice(0, modelW - 2)}…` : t.model + // Cut from the middle: a model's distinguishing part is often its end. + const keep = modelW - 2 + const model = + t.model.length > modelW - 1 ? `${t.model.slice(0, Math.ceil(keep / 3))}…${t.model.slice(t.model.length - Math.floor((keep * 2) / 3))}` : t.model out.push([ [` ${clock(t.at).padEnd(6)}`, ""], ...(showModel ? ([[model.padEnd(modelW), "dim"]] as Line) : []), [(t.rate !== undefined && t.rateWindow !== "whole" && !t.outcome ? n1(t.rate) : "—").padStart(8), "bold"], [`${(t.tokens > 0 ? n0(t.tokens) : "—").padStart(8)}${(t.ttft !== undefined ? `${t.ttft.toFixed(2)}s` : "—").padStart(9)}${(t.totalS !== undefined ? dur(t.totalS) : "—").padStart(9)}${showTools ? (calls > 0 ? String(calls) : "").padStart(6) : ""}`, ""], ...(showCost ? ([[(t.cost ? `$${t.cost.toFixed(4)}` : "").padStart(9), ""]] as Line) : []), - ...(showCache ? ([[(t.cached ? n0(t.cached) : "").padStart(9), "dim"]] as Line) : []), + ...(showCache ? ([[(t.cached ? n0(t.cached) : "").padStart(9), ""]] as Line) : []), [" ", ""], t.source === "engine" ? [ENGINE_MARK, "engine"] : ["·", "dim"], ]) From 877eb34f2d3178437ba09e5b806049344f7072ac Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 14:03:18 -0700 Subject: [PATCH 05/16] Dialog spacing and grid: a blank row under every heading, thin bars, one value column, wider padding at xlarge --- dialog.ts | 181 +++++++++++++++++++++++++++++++++++++----------------- tui.tsx | 22 ++++--- 2 files changed, 138 insertions(+), 65 deletions(-) diff --git a/dialog.ts b/dialog.ts index 9e42602..53297ef 100644 --- a/dialog.ts +++ b/dialog.ts @@ -58,6 +58,29 @@ export function heading(title: string, w: number, right = ""): Line { return line } +/** Width of the value column, right-aligned, where a row's value lines up with others. */ +const VALUE = 9 +/** Where a row's bar (or its note) starts: after the label and value columns, and a gap. */ +const BAR_AT = LABEL + VALUE + 3 + +/** `label` dim, then `value` bold and right-aligned in the value column. */ +export function valueRow(label: string, value: string): Line { + return [[label.padEnd(LABEL), "dim"], [value.padStart(VALUE), "bold"]] +} + +/** + * The grid every figure row uses: label, value right-aligned in the value + * column, and what follows (a unit, a note, a bar) from a fixed column. + */ +export function gridRow(label: string, value: string, tail: Line | string = []): Line { + const head = valueRow(label, value) + const rest: Line = typeof tail === "string" ? (tail ? [[tail, "dim"]] : []) : tail + return rest.length > 0 ? [...head, [" ".repeat(BAR_AT - width(head)), ""], ...rest] : head +} + +/** A heading and the blank row under it: every section opens this way. */ +export const titled = (title: string, w: number, right = ""): Line[] => [heading(title, w, right), []] + /** `label` dim in its column, `value` bold, `tail` dim. */ export function row(label: string, value: string, tail = ""): Line { const line: Line = [[label.padEnd(LABEL), "dim"], [value, "bold"]] @@ -93,7 +116,11 @@ export function bar(parts: ReadonlyArray ((n[i] as number) > 0 ? [[glyph.repeat(n[i] as number), style] as const] : [])) } -const GLYPH = { waiting: "░", generating: "█", tools: "▒", subagents: "▓", compaction: "╳", other: "·" } as const +// Bars are drawn with a line glyph, not full blocks: a full block fills its +// whole row and touched the text above and below (measured: the dialog read +// as cramped). Parts are told apart by colour; the legend marks each with ■. +const BAR = "━" +const GLYPH = { waiting: BAR, generating: BAR, tools: BAR, subagents: BAR, compaction: BAR, other: BAR } as const const STYLE: Record = { waiting: "wait", generating: "gen", @@ -121,20 +148,22 @@ function timeSplit( (k) => t[k] > 0 || k === "waiting" || k === "generating" ) const pct = percents(keys.map((k) => t[k])) - const out: Line[] = [heading("Where the time went", w, dur(total))] - out.push([[" ", ""], ...bar(keys.map((k) => [t[k], STYLE[k], GLYPH[k]] as const), w - 4)]) + const out: Line[] = [...titled("Where the time went", w, dur(total))] + // The bar and legend start at the label column, and each legend value ends + // where every other row's value does. + out.push(bar(keys.map((k) => [t[k], STYLE[k], GLYPH[k]] as const), w)) const cell = (k: keyof typeof GLYPH, i: number): Line => [ - [GLYPH[k], STYLE[k]], - [` ${NAME[k].padEnd(11)}`, "dim"], - [dur(t[k]).padStart(8), "bold"], + ["■", STYLE[k]], + [` ${NAME[k].padEnd(LABEL - 2)}`, "dim"], + [dur(t[k]).padStart(VALUE), "bold"], [(pct[i] === 0 && t[k] > 0 ? "<1%" : `${pct[i]}%`).padStart(5), "dim"], ] const half = Math.ceil(keys.length / 2) for (let r = 0; r < half; r++) { const left = keys[r] as keyof typeof GLYPH const right = keys[r + half] - const line: Line = [[" ", ""], ...cell(left, r)] - if (right) line.push([" ", ""], ...cell(right, r + half)) + const line: Line = [...cell(left, r)] + if (right) line.push([" ".repeat(Math.max(2, Math.floor(w / 2) - width(line))), ""], ...cell(right, r + half)) out.push(line) } return out @@ -183,7 +212,7 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] if (start !== undefined && end !== undefined && end > start && d.steps.length > 0) { const cells = w - 8 const at = (ms: number): number => Math.max(0, Math.min(cells, Math.round(((ms - start) / (end - start)) * cells))) - out.push(heading("Timeline", w, "per step")) + out.push(...titled("Timeline", w, "per step")) d.steps.forEach((s, i) => { const spans: Array<[number, number, Style, string]> = [] if (s.firstAt !== undefined) spans.push([s.createdAt, s.firstAt, "wait", GLYPH.waiting]) @@ -215,7 +244,7 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] // Steps: OpenCode's rate, and the engine's own where it read each step. if (d.steps.length > 0) { const eng = d.stepEngine?.some((e) => e?.decodeTokS !== undefined) === true - out.push(heading("Steps", w, String(d.steps.length))) + out.push(...titled("Steps", w, String(d.steps.length))) out.push([ [" # tokens ", "dim"], ...(eng ? ([[ENGINE_MARK, "engine"], ["tok/s ", "dim"]] as Line) : []), @@ -255,40 +284,35 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] // Tokens: the five kinds, with the cache's share and the context used as bars. const t = d.tokens - out.push(heading("Tokens", w)) + out.push(...titled("Tokens", w)) // Generated first, as the steps and the engine count it; then its split. const produced = t.output + t.reasoning out.push( - row( - "generated", - n0(produced), - t.reasoning > 0 ? ` ${n0(t.output)} answer · ${n0(t.reasoning)} reasoning (${share(t.reasoning, produced)})` : "" - ) + gridRow("generated", n0(produced), t.reasoning > 0 ? `${n0(t.output)} answer · ${n0(t.reasoning)} reasoning (${share(t.reasoning, produced)})` : "") ) const prompt = t.input + t.cacheRead - const BAR_AT = 24 if (prompt > 0) { - const head = row("prompt", n0(prompt)) + const head = valueRow("prompt", n0(prompt)) out.push([ ...head, [" ".repeat(Math.max(1, BAR_AT - width(head))), ""], - ...bar([[t.input, "gen", "█"], [t.cacheRead, "wait", "░"]], 20), + ...bar([[t.input, "gen", BAR], [t.cacheRead, "wait", BAR]], 20), [` ${n0(t.input)} fresh · ${n0(t.cacheRead)} cached (${share(t.cacheRead, prompt)})`, "dim"], ]) } - if (t.cacheWrite > 0) out.push(row("", n0(t.cacheWrite), " written to cache")) + if (t.cacheWrite > 0) out.push(gridRow("", n0(t.cacheWrite), "written to cache")) if (d.context) { - const head = row("context", n0(d.context.used)) + const head = valueRow("context", n0(d.context.used)) const tail: Line = d.context.limit ? [ [" ".repeat(Math.max(1, BAR_AT - width(head))), ""], - ...bar([[d.context.used, "gen", "█"], [Math.max(0, d.context.limit - d.context.used), "wait", "░"]], 20), + ...bar([[d.context.used, "gen", BAR], [Math.max(0, d.context.limit - d.context.used), "wait", BAR]], 20), [` ${share(d.context.used, d.context.limit)} of ${n0(d.context.limit)}`, "dim"], ] : [] out.push([...head, ...tail]) } - if (d.cost !== undefined) out.push(row("cost", `$${d.cost.toFixed(4)}`)) + if (d.cost !== undefined) out.push(gridRow("cost", `$${d.cost.toFixed(4)}`)) out.push([]) // Engine: its own figures only, packed a few to a line, or which were @@ -296,19 +320,57 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] if (d.engineRows.length > 0) { const title = `${ENGINE_MARK} ${d.engine}` const note = " measured by the engine" - out.push([[title, "engine"], [" ", ""], ["─".repeat(Math.max(0, w - title.length - 1 - note.length)), "rule"], [note, "dim"]]) - out.push(...packRows(d.engineRows, w)) - if (d.compactionEngine) for (const c of d.compactionEngine) out.push(row("compaction", c, " taken out")) + out.push([[title, "engine"], [" ", ""], ["─".repeat(Math.max(0, w - title.length - 1 - note.length)), "rule"], [note, "dim"]], []) + out.push(...gridRows(d.engineRows, w)) + if (d.compactionEngine) for (const c of d.compactionEngine) out.push(gridRow("compaction", "", `${c}, taken out of the above`)) } else { - out.push(heading(d.engine, w)) + out.push(...titled(d.engine, w)) const note = d.engineNote && d.engineNote.length > 0 ? `${d.engine}'s figures were left out: ${d.engineNote.join(" ")}` : "no engine telemetry for this provider" for (const l of wrap(note, w)) out.push([[l, "dim"]]) } if (d.subagents) { - out.push([], heading("Sub-agents", w)) - out.push(row("count", n0(d.subagents.count), ` ${n0(d.subagents.tokens)} tok · ${dur(d.subagents.spanS)}`)) - if (d.subagents.cost !== undefined) out.push(row("cost", `$${d.subagents.cost.toFixed(4)}`)) + out.push([], ...titled("Sub-agents", w)) + out.push(gridRow("count", n0(d.subagents.count), `${n0(d.subagents.tokens)} tok · ${dur(d.subagents.spanS)}`)) + if (d.subagents.cost !== undefined) out.push(gridRow("cost", `$${d.subagents.cost.toFixed(4)}`)) + } + return out +} + +/** + * Label/value rows in a two-column grid: each pair's label in a fixed column, + * its value beside it, the second column starting at half the width. A row + * with an empty label continues the one above; acceptance by depth folds + * into `93/87/82% by depth`. + */ +export function gridRows(rows: ReadonlyArray, w: number): Line[] { + const groups: Array<{ label: string; value: string; rest: string }> = [] + let cur: { label: string; values: string[] } | undefined + const flush = (): void => { + if (!cur) return + const depths = cur.values.map((v) => /^(\d+)% at depth \d+$/.exec(v)?.[1]) + if (depths.length > 1 && depths.every((x) => x !== undefined)) groups.push({ label: cur.label, value: `${depths.join("/")}%`, rest: "by depth" }) + else groups.push({ label: cur.label, value: cur.values[0] ?? "", rest: cur.values.slice(1).join(" ") }) + } + for (const [label, value] of rows) { + if (label || !cur) { + flush() + cur = { label, values: [value] } + } else cur.values.push(value) + } + flush() + const half = Math.floor(w / 2) + const cell = (g: { label: string; value: string; rest: string }, cw: number): Line => { + const line: Line = [[g.label.padEnd(LABEL), "dim"], [g.value, "bold"]] + if (g.rest) line.push([` ${g.rest}`, "dim"]) + const used = width(line) + return used < cw ? [...line, [" ".repeat(cw - used), ""]] : line + } + const out: Line[] = [] + for (let i = 0; i < groups.length; i += 2) { + const a = groups[i] as { label: string; value: string; rest: string } + const b = groups[i + 1] + out.push(b ? [...cell(a, half), ...cell(b, 0)] : cell(a, 0)) } return out } @@ -356,19 +418,19 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): const out: Line[] = [] const s = f.summary - out.push(heading("Speed", w, "generation only")) + out.push(...titled("Speed", w, "generation only")) if (s.genTokS !== undefined) { const spread = f.rates.length > 1 - ? ` min ${n1(quantile(f.rates, 0) as number)} · median ${n1(quantile(f.rates, 0.5) as number)} · p90 ${n1(quantile(f.rates, 0.9) as number)} · max ${n1(quantile(f.rates, 1) as number)}` + ? ` · min ${n1(quantile(f.rates, 0) as number)} · median ${n1(quantile(f.rates, 0.5) as number)} · p90 ${n1(quantile(f.rates, 0.9) as number)} · max ${n1(quantile(f.rates, 1) as number)}` : "" - out.push(row("average", `${n1(s.genTokS)} tok/s`, spread)) + out.push(gridRow("average", n1(s.genTokS), `tok/s${spread}`)) } const spark = sparkline(f.rates.slice(0, 24).reverse()) - if (spark) out.push([...row("trend", ""), [spark, "accent"], [` last ${Math.min(24, f.rates.length)} turns`, "dim"]]) + if (spark) out.push(gridRow("trend", "", [[spark, "accent"], [` last ${Math.min(24, f.rates.length)} turns`, "dim"]])) if (f.ttfts.length > 0) { - const more = f.ttfts.length > 1 ? ` median · p90 ${(quantile(f.ttfts, 0.9) as number).toFixed(2)}s · max ${(quantile(f.ttfts, 1) as number).toFixed(2)}s` : "" - out.push(row("ttft", `${(quantile(f.ttfts, 0.5) as number).toFixed(2)}s`, more)) + const more = f.ttfts.length > 1 ? `median · p90 ${(quantile(f.ttfts, 0.9) as number).toFixed(2)}s · max ${(quantile(f.ttfts, 1) as number).toFixed(2)}s` : "" + out.push(gridRow("ttft", `${(quantile(f.ttfts, 0.5) as number).toFixed(2)}s`, more.trim())) } out.push([]) @@ -377,11 +439,11 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): if (f.tools.length > 0) { const shown = f.tools.slice(0, 8) const most = (shown[0] as { s: number }).s - out.push(heading("Tools by time", w, `${n0(f.toolCalls)} calls`)) + out.push(...titled("Tools by time", w, `${n0(f.toolCalls)} calls`)) for (const t of shown) { out.push([ [` ${t.name.slice(0, 10).padEnd(11)}`, "dim"], - ...bar([[t.s, "tool", "▒"], [Math.max(0, most - t.s), "", " "]], 28), + ...bar([[t.s, "tool", BAR], [Math.max(0, most - t.s), "", " "]], 28), [` ${dur(t.s).padStart(7)}`, "bold"], [` ${n0(t.n)} ${t.n === 1 ? "call" : "calls"}`, "dim"], ]) @@ -390,41 +452,48 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): out.push([]) } - out.push(heading("Coverage", w, "turns with the engine's own figures")) - out.push([ - ...row("engine", `${n0(f.coverage.engine)} of ${n0(f.coverage.total)}`, " ".repeat(Math.max(1, 12 - `${n0(f.coverage.engine)} of ${n0(f.coverage.total)}`.length))), - ...bar([[f.coverage.engine, "gen", "█"], [f.coverage.total - f.coverage.engine, "wait", "░"]], 20), - ]) - f.coverage.without.forEach(({ label, n }, i) => out.push(row(i === 0 ? "without" : "", "", `${n0(n)} ${label}`))) + out.push(...titled("Coverage", w, "turns with the engine's own figures")) + out.push( + gridRow("engine", `${n0(f.coverage.engine)}/${n0(f.coverage.total)}`, [ + ...bar([[f.coverage.engine, "gen", BAR], [f.coverage.total - f.coverage.engine, "wait", BAR]], 20), + [` ${share(f.coverage.engine, f.coverage.total)} of turns`, "dim"], + ]) + ) + f.coverage.without.forEach(({ label, n }, i) => out.push(gridRow(i === 0 ? "without" : "", n0(n), label))) out.push([]) - out.push(heading("Tokens", w)) + out.push(...titled("Tokens", w)) const gen = f.tokens.output + f.tokens.reasoning out.push( - row("generated", n0(gen), f.tokens.reasoning > 0 ? ` ${n0(f.tokens.output)} answer · ${n0(f.tokens.reasoning)} reasoning (${share(f.tokens.reasoning, gen)})` : "") + gridRow("generated", n0(gen), f.tokens.reasoning > 0 ? `${n0(f.tokens.output)} answer · ${n0(f.tokens.reasoning)} reasoning (${share(f.tokens.reasoning, gen)})` : "") ) if (f.tokens.input !== undefined || f.tokens.cacheRead !== undefined) { const fresh = f.tokens.input ?? 0 const cached = f.tokens.cacheRead ?? 0 - out.push(row("prompt", n0(fresh + cached), ` ${n0(fresh)} fresh · ${n0(cached)} cached (${share(cached, fresh + cached)})`)) + out.push( + gridRow("prompt", n0(fresh + cached), [ + ...bar([[fresh, "gen", BAR], [cached, "wait", BAR]], 20), + [` ${n0(fresh)} fresh · ${n0(cached)} cached (${share(cached, fresh + cached)})`, "dim"], + ]) + ) } - if (f.tokens.cacheWrite > 0) out.push(row("", n0(f.tokens.cacheWrite), " written to cache")) - out.push(row("totals", `${n0(f.turns)} turns`, ` ${n0(f.steps)} steps · ${dur(f.elapsedS)}${f.cost !== undefined ? ` · $${f.cost.toFixed(4)}` : ""}`)) + if (f.tokens.cacheWrite > 0) out.push(gridRow("", n0(f.tokens.cacheWrite), "written to cache")) + out.push(gridRow("turns", n0(f.turns), `${n0(f.steps)} steps · ${dur(f.elapsedS)}${f.cost !== undefined ? ` · $${f.cost.toFixed(4)}` : ""}`)) if (f.retryReasons.length > 0) { - out.push([], heading("Retries", w, String(s.retries))) + out.push([], ...titled("Retries", w, String(s.retries))) for (const { reason, n } of f.retryReasons) for (const l of wrap(`${n}× ${reason}`, w - 2)) out.push([[` ${l}`, "dim"]]) } if (s.engine && (s.engine.mtpX !== undefined || s.engine.draftAccept !== undefined || s.engine.prefillTokS !== undefined)) { - out.push([], [[`${ENGINE_MARK} Engine averages`, "engine"], [" ", ""], ["─".repeat(Math.max(0, w - 18)), "rule"]]) - if (s.engine.mtpX !== undefined) out.push(row("MTP", `${s.engine.mtpX.toFixed(2)}x`)) - if (s.engine.draftAccept !== undefined) out.push(row("draft", `${Math.round(s.engine.draftAccept * 100)}%`, " accepted")) - if (s.engine.prefillTokS !== undefined) out.push(row("prefill", `${n0(s.engine.prefillTokS)} tok/s`)) + out.push([], [[`${ENGINE_MARK} Engine averages`, "engine"], [" ", ""], ["─".repeat(Math.max(0, w - 18)), "rule"]], []) + if (s.engine.mtpX !== undefined) out.push(gridRow("MTP", `${s.engine.mtpX.toFixed(2)}x`, "average")) + if (s.engine.draftAccept !== undefined) out.push(gridRow("draft", `${Math.round(s.engine.draftAccept * 100)}%`, "accepted, average")) + if (s.engine.prefillTokS !== undefined) out.push(gridRow("prefill", n0(s.engine.prefillTokS), "tok/s average")) } if (s.subagents) { - out.push([], heading("Sub-agents", w)) - out.push(row("count", n0(s.subagents.count), ` ${n0(s.subagents.tokens)} tok${s.subagents.cost !== undefined ? ` · $${s.subagents.cost.toFixed(4)}` : ""}`)) + out.push([], ...titled("Sub-agents", w)) + out.push(gridRow("count", n0(s.subagents.count), `${n0(s.subagents.tokens)} tok${s.subagents.cost !== undefined ? ` · $${s.subagents.cost.toFixed(4)}` : ""}`)) } return out } diff --git a/tui.tsx b/tui.tsx index 0959e4c..02afbda 100644 --- a/tui.tsx +++ b/tui.tsx @@ -467,7 +467,7 @@ export default Plugin.define({ } // Sized to the content, up to 70% of the screen less the tabs and // footer; it scrolls only beyond that. - const bodyRows = (): number => Math.max(4, Math.min(lines().length, Math.floor(details.rows * 0.7) - 4)) + const bodyRows = (): number => Math.max(4, Math.min(lines().length, Math.floor(details.rows * 0.75) - 6)) ctx.keymap.layer(() => ({ mode: "global", priority: 100, @@ -510,10 +510,10 @@ export default Plugin.define({ // the layout needs, the dialog goes to `xlarge` and is measured again. const fit = (tries: number): void => { setTimeout(() => { - // Less the side padding (4) and the scrollbar with a gap beside - // it (2): a full-width line that overflowed wrapped onto a second - // line, which read as a blank row under every heading (measured). - const inner = root?.width !== undefined ? root.width - 6 : undefined + // Less the side padding (2 x 4) and the scrollbar with a gap + // beside it (2): a full-width line that overflowed wrapped onto + // a second line, which read as a blank row under every heading. + const inner = root?.width !== undefined ? root.width - 10 : undefined if (inner !== undefined && inner < CONTENT_WIDTH && tries > 0) { ctx.ui.dialog.set({ size: "xlarge", centered: true }) fit(tries - 1) @@ -521,7 +521,7 @@ export default Plugin.define({ } if (inner !== undefined) { setDetails((d) => { - d.w = Math.max(CONTENT_WIDTH, Math.min(110, inner)) + d.w = Math.max(CONTENT_WIDTH, Math.min(96, inner)) }) } dbg(`details: dialog ${root?.width ?? "?"}x${root?.height ?? "?"}; content ${details.w} wide; ${lines().length} lines`) @@ -539,8 +539,10 @@ export default Plugin.define({ return ( (root = r as typeof root)} > {drawLine(tabsLine(details.tab, note(), details.w))} @@ -566,7 +568,9 @@ export default Plugin.define({ dbg("details: closed") } ) - ctx.ui.dialog.set({ size: "large", centered: true }) + // xlarge (116 cells on a 214-column terminal, measured) leaves room for + // wider margins around a 72-96 cell layout. + ctx.ui.dialog.set({ size: "xlarge", centered: true }) } const currentSession = (): string | undefined => { const r = ctx.ui.router.current() From 8f5a351cc6d645fba6b869652408d28c16633c06 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 14:07:39 -0700 Subject: [PATCH 06/16] Fill the dialog's width; half-block bars that grow with it --- dialog.ts | 22 +++++++++++++--------- tui.tsx | 4 +++- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/dialog.ts b/dialog.ts index 53297ef..9489d5a 100644 --- a/dialog.ts +++ b/dialog.ts @@ -78,6 +78,9 @@ export function gridRow(label: string, value: string, tail: Line | string = []): return rest.length > 0 ? [...head, [" ".repeat(BAR_AT - width(head)), ""], ...rest] : head } +/** A row's bar grows with the width: about 40% of what follows the value column. */ +const rowBar = (w: number): number => Math.max(20, Math.floor((w - BAR_AT) * 0.4)) + /** A heading and the blank row under it: every section opens this way. */ export const titled = (title: string, w: number, right = ""): Line[] => [heading(title, w, right), []] @@ -116,10 +119,11 @@ export function bar(parts: ReadonlyArray ((n[i] as number) > 0 ? [[glyph.repeat(n[i] as number), style] as const] : [])) } -// Bars are drawn with a line glyph, not full blocks: a full block fills its -// whole row and touched the text above and below (measured: the dialog read -// as cramped). Parts are told apart by colour; the legend marks each with ■. -const BAR = "━" +// Bars are a lower half block: a full block filled its whole row and touched +// the text above and below (the dialog read as cramped), and a line (━) read +// as a hairline in the terminal's font. Parts are told apart by colour; the +// legend marks each with ■. +const BAR = "▄" const GLYPH = { waiting: BAR, generating: BAR, tools: BAR, subagents: BAR, compaction: BAR, other: BAR } as const const STYLE: Record = { waiting: "wait", @@ -296,7 +300,7 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] out.push([ ...head, [" ".repeat(Math.max(1, BAR_AT - width(head))), ""], - ...bar([[t.input, "gen", BAR], [t.cacheRead, "wait", BAR]], 20), + ...bar([[t.input, "gen", BAR], [t.cacheRead, "wait", BAR]],, rowBar(w)), [` ${n0(t.input)} fresh · ${n0(t.cacheRead)} cached (${share(t.cacheRead, prompt)})`, "dim"], ]) } @@ -306,7 +310,7 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] const tail: Line = d.context.limit ? [ [" ".repeat(Math.max(1, BAR_AT - width(head))), ""], - ...bar([[d.context.used, "gen", BAR], [Math.max(0, d.context.limit - d.context.used), "wait", BAR]], 20), + ...bar([[d.context.used, "gen", BAR], [Math.max(0, d.context.limit - d.context.used), "wait", BAR]],, rowBar(w)), [` ${share(d.context.used, d.context.limit)} of ${n0(d.context.limit)}`, "dim"], ] : [] @@ -443,7 +447,7 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): for (const t of shown) { out.push([ [` ${t.name.slice(0, 10).padEnd(11)}`, "dim"], - ...bar([[t.s, "tool", BAR], [Math.max(0, most - t.s), "", " "]], 28), + ...bar([[t.s, "tool", BAR], [Math.max(0, most - t.s), "", " "]],, rowBar(w)), [` ${dur(t.s).padStart(7)}`, "bold"], [` ${n0(t.n)} ${t.n === 1 ? "call" : "calls"}`, "dim"], ]) @@ -455,7 +459,7 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): out.push(...titled("Coverage", w, "turns with the engine's own figures")) out.push( gridRow("engine", `${n0(f.coverage.engine)}/${n0(f.coverage.total)}`, [ - ...bar([[f.coverage.engine, "gen", BAR], [f.coverage.total - f.coverage.engine, "wait", BAR]], 20), + ...bar([[f.coverage.engine, "gen", BAR], [f.coverage.total - f.coverage.engine, "wait", BAR]],, rowBar(w)), [` ${share(f.coverage.engine, f.coverage.total)} of turns`, "dim"], ]) ) @@ -472,7 +476,7 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): const cached = f.tokens.cacheRead ?? 0 out.push( gridRow("prompt", n0(fresh + cached), [ - ...bar([[fresh, "gen", BAR], [cached, "wait", BAR]], 20), + ...bar([[fresh, "gen", BAR], [cached, "wait", BAR]],, rowBar(w)), [` ${n0(fresh)} fresh · ${n0(cached)} cached (${share(cached, fresh + cached)})`, "dim"], ]) ) diff --git a/tui.tsx b/tui.tsx index 02afbda..d9aea4d 100644 --- a/tui.tsx +++ b/tui.tsx @@ -521,7 +521,9 @@ export default Plugin.define({ } if (inner !== undefined) { setDetails((d) => { - d.w = Math.max(CONTENT_WIDTH, Math.min(96, inner)) + // The whole of the dialog's width: a cap left a wide empty + // strip on the right of an xlarge dialog (measured). + d.w = Math.max(CONTENT_WIDTH, Math.min(160, inner)) }) } dbg(`details: dialog ${root?.width ?? "?"}x${root?.height ?? "?"}; content ${details.w} wide; ${lines().length} lines`) From f3564348d1bc5120090738ad5b788530b89de2cd Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 14:08:03 -0700 Subject: [PATCH 07/16] Fix a stray comma in the row bars' width --- dialog.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dialog.ts b/dialog.ts index 9489d5a..5332c51 100644 --- a/dialog.ts +++ b/dialog.ts @@ -300,7 +300,7 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] out.push([ ...head, [" ".repeat(Math.max(1, BAR_AT - width(head))), ""], - ...bar([[t.input, "gen", BAR], [t.cacheRead, "wait", BAR]],, rowBar(w)), + ...bar([[t.input, "gen", BAR], [t.cacheRead, "wait", BAR]], rowBar(w)), [` ${n0(t.input)} fresh · ${n0(t.cacheRead)} cached (${share(t.cacheRead, prompt)})`, "dim"], ]) } @@ -310,7 +310,7 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] const tail: Line = d.context.limit ? [ [" ".repeat(Math.max(1, BAR_AT - width(head))), ""], - ...bar([[d.context.used, "gen", BAR], [Math.max(0, d.context.limit - d.context.used), "wait", BAR]],, rowBar(w)), + ...bar([[d.context.used, "gen", BAR], [Math.max(0, d.context.limit - d.context.used), "wait", BAR]], rowBar(w)), [` ${share(d.context.used, d.context.limit)} of ${n0(d.context.limit)}`, "dim"], ] : [] @@ -447,7 +447,7 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): for (const t of shown) { out.push([ [` ${t.name.slice(0, 10).padEnd(11)}`, "dim"], - ...bar([[t.s, "tool", BAR], [Math.max(0, most - t.s), "", " "]],, rowBar(w)), + ...bar([[t.s, "tool", BAR], [Math.max(0, most - t.s), "", " "]], rowBar(w)), [` ${dur(t.s).padStart(7)}`, "bold"], [` ${n0(t.n)} ${t.n === 1 ? "call" : "calls"}`, "dim"], ]) @@ -459,7 +459,7 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): out.push(...titled("Coverage", w, "turns with the engine's own figures")) out.push( gridRow("engine", `${n0(f.coverage.engine)}/${n0(f.coverage.total)}`, [ - ...bar([[f.coverage.engine, "gen", BAR], [f.coverage.total - f.coverage.engine, "wait", BAR]],, rowBar(w)), + ...bar([[f.coverage.engine, "gen", BAR], [f.coverage.total - f.coverage.engine, "wait", BAR]], rowBar(w)), [` ${share(f.coverage.engine, f.coverage.total)} of turns`, "dim"], ]) ) @@ -476,7 +476,7 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): const cached = f.tokens.cacheRead ?? 0 out.push( gridRow("prompt", n0(fresh + cached), [ - ...bar([[fresh, "gen", BAR], [cached, "wait", BAR]],, rowBar(w)), + ...bar([[fresh, "gen", BAR], [cached, "wait", BAR]], rowBar(w)), [` ${n0(fresh)} fresh · ${n0(cached)} cached (${share(cached, fresh + cached)})`, "dim"], ]) ) From 50eea62d74198b5c630b6671057025910324489d Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 14:16:30 -0700 Subject: [PATCH 08/16] Match the approved dialog mockup: its layout, glyphs, legends, columns, colours and padding; spread the history columns --- dialog.ts | 444 +++++++++++++++++++++++++----------------------------- tui.tsx | 42 ++++-- 2 files changed, 240 insertions(+), 246 deletions(-) diff --git a/dialog.ts b/dialog.ts index 5332c51..a4dc9e3 100644 --- a/dialog.ts +++ b/dialog.ts @@ -58,32 +58,6 @@ export function heading(title: string, w: number, right = ""): Line { return line } -/** Width of the value column, right-aligned, where a row's value lines up with others. */ -const VALUE = 9 -/** Where a row's bar (or its note) starts: after the label and value columns, and a gap. */ -const BAR_AT = LABEL + VALUE + 3 - -/** `label` dim, then `value` bold and right-aligned in the value column. */ -export function valueRow(label: string, value: string): Line { - return [[label.padEnd(LABEL), "dim"], [value.padStart(VALUE), "bold"]] -} - -/** - * The grid every figure row uses: label, value right-aligned in the value - * column, and what follows (a unit, a note, a bar) from a fixed column. - */ -export function gridRow(label: string, value: string, tail: Line | string = []): Line { - const head = valueRow(label, value) - const rest: Line = typeof tail === "string" ? (tail ? [[tail, "dim"]] : []) : tail - return rest.length > 0 ? [...head, [" ".repeat(BAR_AT - width(head)), ""], ...rest] : head -} - -/** A row's bar grows with the width: about 40% of what follows the value column. */ -const rowBar = (w: number): number => Math.max(20, Math.floor((w - BAR_AT) * 0.4)) - -/** A heading and the blank row under it: every section opens this way. */ -export const titled = (title: string, w: number, right = ""): Line[] => [heading(title, w, right), []] - /** `label` dim in its column, `value` bold, `tail` dim. */ export function row(label: string, value: string, tail = ""): Line { const line: Line = [[label.padEnd(LABEL), "dim"], [value, "bold"]] @@ -119,12 +93,9 @@ export function bar(parts: ReadonlyArray ((n[i] as number) > 0 ? [[glyph.repeat(n[i] as number), style] as const] : [])) } -// Bars are a lower half block: a full block filled its whole row and touched -// the text above and below (the dialog read as cramped), and a line (━) read -// as a hairline in the terminal's font. Parts are told apart by colour; the -// legend marks each with ■. -const BAR = "▄" -const GLYPH = { waiting: BAR, generating: BAR, tools: BAR, subagents: BAR, compaction: BAR, other: BAR } as const +// The mockup's glyphs: parts of a bar differ by shade as well as colour, so +// they still read apart where colour does not. +const GLYPH = { waiting: "░", generating: "█", tools: "▒", subagents: "▓", compaction: "╳", other: "·" } as const const STYLE: Record = { waiting: "wait", generating: "gen", @@ -142,37 +113,6 @@ const NAME: Record = { other: "other", } -/** Where the time went: a bar, then a legend in two columns with seconds and shares. */ -function timeSplit( - t: { waiting: number; generating: number; tools: number; subagents: number; compaction: number; other: number }, - total: number, - w: number -): Line[] { - const keys = (Object.keys(GLYPH) as Array).filter( - (k) => t[k] > 0 || k === "waiting" || k === "generating" - ) - const pct = percents(keys.map((k) => t[k])) - const out: Line[] = [...titled("Where the time went", w, dur(total))] - // The bar and legend start at the label column, and each legend value ends - // where every other row's value does. - out.push(bar(keys.map((k) => [t[k], STYLE[k], GLYPH[k]] as const), w)) - const cell = (k: keyof typeof GLYPH, i: number): Line => [ - ["■", STYLE[k]], - [` ${NAME[k].padEnd(LABEL - 2)}`, "dim"], - [dur(t[k]).padStart(VALUE), "bold"], - [(pct[i] === 0 && t[k] > 0 ? "<1%" : `${pct[i]}%`).padStart(5), "dim"], - ] - const half = Math.ceil(keys.length / 2) - for (let r = 0; r < half; r++) { - const left = keys[r] as keyof typeof GLYPH - const right = keys[r + half] - const line: Line = [...cell(left, r)] - if (right) line.push([" ".repeat(Math.max(2, Math.floor(w / 2) - width(line))), ""], ...cell(right, r + half)) - out.push(line) - } - return out -} - // ---- the tabs line and the footer ------------------------------------------------- const TAB_NAME: Record = { turn: "Turn", session: "Session", history: "History" } @@ -200,32 +140,89 @@ export function footLine(tab: Tab, w = CONTENT_WIDTH): Line { return keys } +// ---- scale ---------------------------------------------------------------------- + +/** The mockup was drawn 72 cells wide; its fixed sizes scale with the width. */ +const MOCK = 72 +const sc = (cells: number, w: number): number => Math.max(1, Math.round((cells * w) / MOCK)) + +type Split = { waiting: number; generating: number; tools: number; subagents: number; compaction: number; other: number } +const KEYS = ["waiting", "generating", "tools", "subagents", "compaction", "other"] as const + +/** The time bar: indented 2, 60 of the mockup's 72 cells. */ +function timeBar(t: Split, w: number): Line { + const keys = KEYS.filter((k) => t[k] > 0) + return [[" ", ""], ...bar(keys.map((k) => [t[k], STYLE[k], GLYPH[k]] as const), w - 12)] +} + +/** The Turn tab's legend: two columns, name, seconds and share (mockup). */ +function legendColumns(t: Split): Line[] { + const keys = KEYS.filter((k) => t[k] > 0 || k === "waiting" || k === "generating") + const pct = percents(keys.map((k) => t[k])) + const half = Math.ceil(keys.length / 2) + const left = keys.slice(0, half) + const right = keys.slice(half) + const rw = Math.max(7, ...right.map((k) => NAME[k].length)) + const cell = (k: (typeof KEYS)[number], i: number, nameW: number, valW: number): Line => [ + [GLYPH[k], STYLE[k]], + [` ${NAME[k].padEnd(nameW)}`, "dim"], + [`${dur(t[k]).padStart(valW)}`, "bold"], + [(pct[i] === 0 && t[k] > 0 ? "<1%" : `${pct[i]}%`).padStart(4), "dim"], + ] + const out: Line[] = [] + for (let r = 0; r < half; r++) { + const line: Line = [[" ", ""], ...cell(left[r] as (typeof KEYS)[number], r, 11, 8)] + const k = right[r] + if (k) line.push([" ", ""], ...cell(k, half + r, rw, 7)) + out.push(line) + } + return out +} + +/** The Session tab's legend: name and time, flowing across lines (mockup). */ +function legendFlow(t: Split, w: number): Line[] { + const keys = KEYS.filter((k) => t[k] > 0 || k === "waiting" || k === "generating") + const out: Line[] = [] + let cur: Line = [[" ", ""]] + for (const k of keys) { + const item: Line = [[GLYPH[k], STYLE[k]], [` ${NAME[k]} `, "dim"], [dur(t[k]), "bold"]] + const sep: Line = width(cur) > 2 ? [[" ", ""]] : [] + if (width(cur) + width(sep) + width(item) > w && width(cur) > 2) { + out.push(cur) + cur = [[" ", ""], ...item] + } else cur = [...cur, ...sep, ...item] + } + if (width(cur) > 2) out.push(cur) + return out +} + // ---- Turn ------------------------------------------------------------------------ -/** The Turn tab. */ +/** The Turn tab, laid out as the mockup. */ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] { if (!d) return [[["No turn yet in this run. Details start with the next turn.", "dim"]]] const out: Line[] = [] if (d.outcome) out.push([[`This reply ${d.outcome === "interrupted" ? "was interrupted" : "failed"}; figures run to where it stopped.`, "dim"]], []) - if (d.time && d.totalS !== undefined) out.push(...timeSplit(d.time, d.totalS, w), []) + if (d.time && d.totalS !== undefined) { + out.push(heading("Where the time went", w, dur(d.totalS)), timeBar(d.time, w), ...legendColumns(d.time), []) + } // Timeline: each step's wait, generation and tools on one time scale. const start = d.steps[0]?.createdAt const end = start !== undefined && d.totalS !== undefined ? start + d.totalS * 1000 : undefined if (start !== undefined && end !== undefined && end > start && d.steps.length > 0) { - const cells = w - 8 + const cells = w - 12 const at = (ms: number): number => Math.max(0, Math.min(cells, Math.round(((ms - start) / (end - start)) * cells))) - out.push(...titled("Timeline", w, "per step")) + out.push(heading("Timeline", w, "per step")) d.steps.forEach((s, i) => { const spans: Array<[number, number, Style, string]> = [] if (s.firstAt !== undefined) spans.push([s.createdAt, s.firstAt, "wait", GLYPH.waiting]) if (s.firstAt !== undefined && s.lastAt !== undefined) spans.push([s.firstAt, s.lastAt, "gen", GLYPH.generating]) for (const t of s.tools) { - if (t.start !== undefined && t.end !== undefined) { - const sub = t.name === "subagent" || t.name === "task" || t.name === "agent" - spans.push([t.start, t.end, sub ? "sub" : "tool", sub ? GLYPH.subagents : GLYPH.tools]) - } + if (t.start === undefined || t.end === undefined) continue + const sub = t.name === "subagent" || t.name === "task" || t.name === "agent" + spans.push([t.start, t.end, sub ? "sub" : "tool", sub ? GLYPH.subagents : GLYPH.tools]) } const cellsOf: Array<[Style, string]> = Array.from({ length: cells }, () => ["", " "]) for (const [a, b, style, glyph] of spans) { @@ -233,7 +230,9 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] const to = Math.max(from + 1, at(b)) for (let c = from; c < Math.min(cells, to); c++) cellsOf[c] = [style, glyph] } - const line: Line = [[` ${String(i + 1).padStart(2)} `, "dim"]] + // Trailing blanks trimmed, so the line ends where the step does. + while (cellsOf.length > 0 && (cellsOf[cellsOf.length - 1] as [Style, string])[1] === " ") cellsOf.pop() + const line: Line = [[` ${String(i + 1)} `.padEnd(5), "dim"]] for (const [style, glyph] of cellsOf) { const last = line[line.length - 1] as Seg if (last[1] === style && line.length > 1) line[line.length - 1] = [last[0] + glyph, style] @@ -241,33 +240,35 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] } out.push(line) }) - out.push([[" 0s", "dim"], [" ".repeat(Math.max(1, cells - dur(d.totalS as number).length - 2)), ""], [dur(d.totalS as number), "dim"]]) + const total = dur(d.totalS as number) + out.push([[" 0s", "dim"], [" ".repeat(Math.max(1, cells - 2 - total.length + 5 - 5)), ""], [total, "dim"]]) out.push([]) } - // Steps: OpenCode's rate, and the engine's own where it read each step. + // Steps: the mockup's columns. The engine's own per-step rate appears + // where the engine was read per step. if (d.steps.length > 0) { const eng = d.stepEngine?.some((e) => e?.decodeTokS !== undefined) === true - out.push(...titled("Steps", w, String(d.steps.length))) + out.push(heading("Steps", w, String(d.steps.length))) out.push([ - [" # tokens ", "dim"], - ...(eng ? ([[ENGINE_MARK, "engine"], ["tok/s ", "dim"]] as Line) : []), - [" tok/s ttft tool", "dim"], + [" # tokens ", "dim"], + ...(eng ? ([[ENGINE_MARK, "engine"], ["tok/s", "dim"]] as Line) : []), + [`${eng ? " " : ""}tok/s ttft tool`, "dim"], ]) d.steps.forEach((s, i) => { const tok = s.output + s.reasoning const rate = s.streamS && s.streamS > 0 ? n1(tok / s.streamS) : "—" const e = d.stepEngine?.[i]?.decodeTokS const head: Line = [ - [` ${String(i + 1).padStart(2)} ${n0(tok).padStart(6)} `, ""], - ...(eng ? ([[(e !== undefined ? n1(e) : "—").padStart(6), "engine"], [" ", ""]] as Line) : []), - [`${rate.padStart(6)} ${(s.ttftS !== undefined ? `${s.ttftS.toFixed(2)}s` : "—").padStart(7)} `, ""], + [` ${String(i + 1)}${n0(tok).padStart(12 - 2 - String(i + 1).length)}`, ""], + ...(eng ? ([[" ", ""], [(e !== undefined ? n1(e) : "—").padStart(4), "engine"]] as Line) : []), + [`${rate.padStart(eng ? 8 : 10)}${(s.ttftS !== undefined ? `${s.ttftS.toFixed(2)}s` : "—").padStart(9)} `, ""], ] const pad = " ".repeat(width(head)) const tools = s.tools.length > 0 ? s.tools : [undefined] tools.forEach((t, j) => { const tail: Line = t - ? [[t.name.slice(0, 10).padEnd(11), ""], [t.seconds !== undefined ? dur(t.seconds) : t.status, "bold"]] + ? [[`${t.name.slice(0, 10).padEnd(10)} ${t.seconds !== undefined ? dur(t.seconds) : t.status}`, ""]] : s.finish && s.finish !== "tool-calls" ? [[`— ${s.finish}`, "dim"]] : [] @@ -277,138 +278,101 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] s.retries > 0 ? `${s.retries} ${s.retries === 1 ? "retry" : "retries"}` : "", s.compactionS ? `waited on compaction ${dur(s.compactionS)}` : "", ].filter(Boolean) - if (notes.length > 0) out.push([[` ${notes.join(" · ")}`, "dim"]]) + if (notes.length > 0) out.push([[` ${notes.join(" · ")}`, "dim"]]) }) const reasons = d.steps.flatMap((s, i) => [s.retryReason ? `step ${i + 1}: ${s.retryReason}` : "", s.error ? `step ${i + 1} failed: ${s.error}` : ""].filter(Boolean) ) - for (const r of reasons) for (const l of wrap(r, w - 4)) out.push([[` ${l}`, "dim"]]) + for (const r of reasons) for (const l of wrap(r, w - 2)) out.push([[` ${l}`, "dim"]]) out.push([]) } - // Tokens: the five kinds, with the cache's share and the context used as bars. + // Tokens: generated with its split; prompt and context with a bar each. const t = d.tokens - out.push(...titled("Tokens", w)) - // Generated first, as the steps and the engine count it; then its split. const produced = t.output + t.reasoning + out.push(heading("Tokens", w)) out.push( - gridRow("generated", n0(produced), t.reasoning > 0 ? `${n0(t.output)} answer · ${n0(t.reasoning)} reasoning (${share(t.reasoning, produced)})` : "") + row("generated", n0(produced), t.reasoning > 0 ? ` ${n0(t.output)} answer · ${n0(t.reasoning)} reasoning (${share(t.reasoning, produced)})` : "") ) const prompt = t.input + t.cacheRead if (prompt > 0) { - const head = valueRow("prompt", n0(prompt)) out.push([ - ...head, - [" ".repeat(Math.max(1, BAR_AT - width(head))), ""], - ...bar([[t.input, "gen", BAR], [t.cacheRead, "wait", BAR]], rowBar(w)), - [` ${n0(t.input)} fresh · ${n0(t.cacheRead)} cached (${share(t.cacheRead, prompt)})`, "dim"], + ...row("input", n0(t.input), " fresh "), + ...bar([[t.input, "gen", GLYPH.generating], [t.cacheRead, "wait", GLYPH.waiting]], sc(24, w)), + [` ${n0(t.cacheRead)} cached (${share(t.cacheRead, prompt)})`, "dim"], ]) } - if (t.cacheWrite > 0) out.push(gridRow("", n0(t.cacheWrite), "written to cache")) + if (t.cacheWrite > 0) out.push(row("", n0(t.cacheWrite), " written to cache")) if (d.context) { - const head = valueRow("context", n0(d.context.used)) const tail: Line = d.context.limit ? [ - [" ".repeat(Math.max(1, BAR_AT - width(head))), ""], - ...bar([[d.context.used, "gen", BAR], [Math.max(0, d.context.limit - d.context.used), "wait", BAR]], rowBar(w)), - [` ${share(d.context.used, d.context.limit)} of ${n0(d.context.limit)}`, "dim"], + [` of ${n0(d.context.limit)} `, "dim"], + ...bar([[d.context.used, "gen", GLYPH.generating], [Math.max(0, d.context.limit - d.context.used), "wait", GLYPH.waiting]], sc(24, w)), + [` ${share(d.context.used, d.context.limit)}`, "dim"], ] : [] - out.push([...head, ...tail]) + out.push([...row("context", n0(d.context.used)), ...tail]) } - if (d.cost !== undefined) out.push(gridRow("cost", `$${d.cost.toFixed(4)}`)) + if (d.cost !== undefined) out.push(row("cost", `$${d.cost.toFixed(4)}`)) out.push([]) - // Engine: its own figures only, packed a few to a line, or which were - // left out and why. + // Engine: its own figures, a few to a line; or which were left out and why. if (d.engineRows.length > 0) { - const title = `${ENGINE_MARK} ${d.engine}` - const note = " measured by the engine" - out.push([[title, "engine"], [" ", ""], ["─".repeat(Math.max(0, w - title.length - 1 - note.length)), "rule"], [note, "dim"]], []) - out.push(...gridRows(d.engineRows, w)) - if (d.compactionEngine) for (const c of d.compactionEngine) out.push(gridRow("compaction", "", `${c}, taken out of the above`)) + out.push(heading(`${ENGINE_MARK} ${d.engine}`, w, "measured by the engine")) + out.push(...packRows(d.engineRows, w)) + if (d.compactionEngine) for (const c of d.compactionEngine) out.push(row("compaction", c, " taken out of the above")) } else { - out.push(...titled(d.engine, w)) + out.push(heading(d.engine, w)) const note = d.engineNote && d.engineNote.length > 0 ? `${d.engine}'s figures were left out: ${d.engineNote.join(" ")}` : "no engine telemetry for this provider" for (const l of wrap(note, w)) out.push([[l, "dim"]]) } if (d.subagents) { - out.push([], ...titled("Sub-agents", w)) - out.push(gridRow("count", n0(d.subagents.count), `${n0(d.subagents.tokens)} tok · ${dur(d.subagents.spanS)}`)) - if (d.subagents.cost !== undefined) out.push(gridRow("cost", `$${d.subagents.cost.toFixed(4)}`)) - } - return out -} - -/** - * Label/value rows in a two-column grid: each pair's label in a fixed column, - * its value beside it, the second column starting at half the width. A row - * with an empty label continues the one above; acceptance by depth folds - * into `93/87/82% by depth`. - */ -export function gridRows(rows: ReadonlyArray, w: number): Line[] { - const groups: Array<{ label: string; value: string; rest: string }> = [] - let cur: { label: string; values: string[] } | undefined - const flush = (): void => { - if (!cur) return - const depths = cur.values.map((v) => /^(\d+)% at depth \d+$/.exec(v)?.[1]) - if (depths.length > 1 && depths.every((x) => x !== undefined)) groups.push({ label: cur.label, value: `${depths.join("/")}%`, rest: "by depth" }) - else groups.push({ label: cur.label, value: cur.values[0] ?? "", rest: cur.values.slice(1).join(" ") }) - } - for (const [label, value] of rows) { - if (label || !cur) { - flush() - cur = { label, values: [value] } - } else cur.values.push(value) - } - flush() - const half = Math.floor(w / 2) - const cell = (g: { label: string; value: string; rest: string }, cw: number): Line => { - const line: Line = [[g.label.padEnd(LABEL), "dim"], [g.value, "bold"]] - if (g.rest) line.push([` ${g.rest}`, "dim"]) - const used = width(line) - return used < cw ? [...line, [" ".repeat(cw - used), ""]] : line - } - const out: Line[] = [] - for (let i = 0; i < groups.length; i += 2) { - const a = groups[i] as { label: string; value: string; rest: string } - const b = groups[i + 1] - out.push(b ? [...cell(a, half), ...cell(b, 0)] : cell(a, 0)) + out.push([], heading("Sub-agents", w)) + out.push(row("count", n0(d.subagents.count), ` ${n0(d.subagents.tokens)} tok · ${dur(d.subagents.spanS)}`)) + if (d.subagents.cost !== undefined) out.push(row("cost", `$${d.subagents.cost.toFixed(4)}`)) } return out } /** - * Label/value rows packed several to a line: `speed 41.2 tok/s prefill 475 - * tok/s ttft 17.37s`. A row with an empty label continues the one above; - * acceptance by depth (`93% at depth 1`, ...) folds into `93/87/82% by depth`. + * Label/value rows packed several to a line, as the mockup's engine section: + * `speed 41.2 tok/s prefill 475 tok/s ttft 17.37s`. A row with an + * empty label continues the one above; acceptance by depth folds into + * `93/87/82% by depth`. */ export function packRows(rows: ReadonlyArray, w: number): Line[] { - const groups: Array<{ label: string; values: string[] }> = [] + const all: Array<{ label: string; values: string[] }> = [] for (const [label, value] of rows) { - if (label || groups.length === 0) groups.push({ label, values: [value] }) - else (groups[groups.length - 1] as { values: string[] }).values.push(value) + if (label || all.length === 0) all.push({ label, values: [value] }) + else (all[all.length - 1] as { values: string[] }).values.push(value) } - const segs = groups.map(({ label, values }): Line => { + // The mockup's order: rates first, then speculative decoding. The token + // count is left out: the Tokens section already has it. + const ORDER = ["speed", "prefill", "ttft", "MTP", "accepted", "draft"] + const rank = (l: string): number => (ORDER.includes(l) ? ORDER.indexOf(l) : ORDER.length) + const groups = all.filter((g) => g.label !== "tokens").sort((a, b) => rank(a.label) - rank(b.label)) + const segs = groups.map(({ label, values }): { label: string; value: Line } => { const depths = values.map((v) => /^(\d+)% at depth \d+$/.exec(v)?.[1]) if (depths.length > 1 && depths.every((x) => x !== undefined)) { - return [[label, "dim"], [" ", ""], [`${depths.join("/")}%`, "bold"], [" by depth", "dim"]] + return { label, value: [[`${depths.join("/")}%`, "bold"], [" by depth", "dim"]] } } const [first, ...rest] = values - return [[label, "dim"], [" ", ""], [first ?? "", "bold"], ...(rest.length > 0 ? ([[` ${rest.join(" ")}`, "dim"]] as Line) : [])] + return { label, value: [[first ?? "", "bold"], ...(rest.length > 0 ? ([[` ${rest.join(" ")}`, "dim"]] as Line) : [])] } }) const out: Line[] = [] let cur: Line = [] + let prev = "" for (const g of segs) { - const [labelSeg, , ...value] = g - const lead: Line = cur.length === 0 ? [[(labelSeg as Seg)[0].padEnd(LABEL), "dim"], ...value] : [[" ", ""], ...g] - if (cur.length > 0 && width(cur) + width(lead) > w) { + const first: Line = [[g.label.padEnd(LABEL), "dim"], ...g.value] + const next: Line = [[" ", ""], [`${g.label} `, "dim"], ...g.value] + const newGroup = prev !== "" && rank(prev) <= 2 && rank(g.label) > 2 + prev = g.label + if (cur.length === 0) cur = first + else if (newGroup || width(cur) + width(next) > w) { out.push(cur) - cur = [[(labelSeg as Seg)[0].padEnd(LABEL), "dim"], ...value] - } else { - cur = [...cur, ...lead] - } + cur = first + } else cur = [...cur, ...next] } if (cur.length > 0) out.push(cur) return out @@ -416,38 +380,38 @@ export function packRows(rows: ReadonlyArray, w: numb // ---- Session ------------------------------------------------------------------------ -/** The Session tab. */ +/** The Session tab, laid out as the mockup. */ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): Line[] { if (!f) return [[["No turns yet in this session.", "dim"]]] const out: Line[] = [] const s = f.summary - out.push(...titled("Speed", w, "generation only")) + out.push(heading("Speed", w, "tok/s")) if (s.genTokS !== undefined) { const spread = f.rates.length > 1 - ? ` · min ${n1(quantile(f.rates, 0) as number)} · median ${n1(quantile(f.rates, 0.5) as number)} · p90 ${n1(quantile(f.rates, 0.9) as number)} · max ${n1(quantile(f.rates, 1) as number)}` + ? ` min ${n1(quantile(f.rates, 0) as number)} · median ${n1(quantile(f.rates, 0.5) as number)} · p90 ${n1(quantile(f.rates, 0.9) as number)} · max ${n1(quantile(f.rates, 1) as number)}` : "" - out.push(gridRow("average", n1(s.genTokS), `tok/s${spread}`)) + out.push(row("average", n1(s.genTokS), spread)) } const spark = sparkline(f.rates.slice(0, 24).reverse()) - if (spark) out.push(gridRow("trend", "", [[spark, "accent"], [` last ${Math.min(24, f.rates.length)} turns`, "dim"]])) + if (spark) out.push([["trend".padEnd(LABEL), "dim"], [spark, "accent"], [` last ${Math.min(24, f.rates.length)} turns`, "dim"]]) if (f.ttfts.length > 0) { - const more = f.ttfts.length > 1 ? `median · p90 ${(quantile(f.ttfts, 0.9) as number).toFixed(2)}s · max ${(quantile(f.ttfts, 1) as number).toFixed(2)}s` : "" - out.push(gridRow("ttft", `${(quantile(f.ttfts, 0.5) as number).toFixed(2)}s`, more.trim())) + const more = f.ttfts.length > 1 ? ` median · p90 ${(quantile(f.ttfts, 0.9) as number).toFixed(2)}s · max ${(quantile(f.ttfts, 1) as number).toFixed(2)}s` : "" + out.push(row("ttft", `${(quantile(f.ttfts, 0.5) as number).toFixed(2)}s`, more)) } out.push([]) - if (f.time) out.push(...timeSplit(f.time, f.time.total, w), []) + if (f.time) out.push(heading("Where the time went", w, dur(f.time.total)), timeBar(f.time, w), ...legendFlow(f.time, w), []) if (f.tools.length > 0) { const shown = f.tools.slice(0, 8) const most = (shown[0] as { s: number }).s - out.push(...titled("Tools by time", w, `${n0(f.toolCalls)} calls`)) + out.push(heading("Tools by time", w, `${n0(f.toolCalls)} calls`)) for (const t of shown) { out.push([ - [` ${t.name.slice(0, 10).padEnd(11)}`, "dim"], - ...bar([[t.s, "tool", BAR], [Math.max(0, most - t.s), "", " "]], rowBar(w)), + [` ${t.name.slice(0, 10).padEnd(10)}`, "dim"], + ...bar([[t.s, "tool", GLYPH.tools], [Math.max(0, most - t.s), "", " "]], sc(30, w)), [` ${dur(t.s).padStart(7)}`, "bold"], [` ${n0(t.n)} ${t.n === 1 ? "call" : "calls"}`, "dim"], ]) @@ -456,48 +420,39 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): out.push([]) } - out.push(...titled("Coverage", w, "turns with the engine's own figures")) - out.push( - gridRow("engine", `${n0(f.coverage.engine)}/${n0(f.coverage.total)}`, [ - ...bar([[f.coverage.engine, "gen", BAR], [f.coverage.total - f.coverage.engine, "wait", BAR]], rowBar(w)), - [` ${share(f.coverage.engine, f.coverage.total)} of turns`, "dim"], - ]) - ) - f.coverage.without.forEach(({ label, n }, i) => out.push(gridRow(i === 0 ? "without" : "", n0(n), label))) + out.push(heading("Coverage", w)) + out.push([ + ...row("engine", `${n0(f.coverage.engine)} of ${n0(f.coverage.total)} turns`, " "), + ...bar([[f.coverage.engine, "gen", GLYPH.generating], [f.coverage.total - f.coverage.engine, "wait", GLYPH.waiting]], sc(20, w)), + ]) + f.coverage.without.forEach(({ label, n }, i) => out.push([[(i === 0 ? "without" : "").padEnd(LABEL), "dim"], [`${n0(n)} ${label}`, "dim"]])) out.push([]) - out.push(...titled("Tokens", w)) + out.push(heading("Tokens", w)) const gen = f.tokens.output + f.tokens.reasoning - out.push( - gridRow("generated", n0(gen), f.tokens.reasoning > 0 ? `${n0(f.tokens.output)} answer · ${n0(f.tokens.reasoning)} reasoning (${share(f.tokens.reasoning, gen)})` : "") - ) + out.push(row("generated", n0(gen), f.tokens.reasoning > 0 ? ` ${n0(f.tokens.output)} answer · ${n0(f.tokens.reasoning)} reasoning` : "")) if (f.tokens.input !== undefined || f.tokens.cacheRead !== undefined) { const fresh = f.tokens.input ?? 0 const cached = f.tokens.cacheRead ?? 0 - out.push( - gridRow("prompt", n0(fresh + cached), [ - ...bar([[fresh, "gen", BAR], [cached, "wait", BAR]], rowBar(w)), - [` ${n0(fresh)} fresh · ${n0(cached)} cached (${share(cached, fresh + cached)})`, "dim"], - ]) - ) + out.push(row("input", n0(fresh), ` fresh ${n0(cached)} cached (${share(cached, fresh + cached)} hit)`)) } - if (f.tokens.cacheWrite > 0) out.push(gridRow("", n0(f.tokens.cacheWrite), "written to cache")) - out.push(gridRow("turns", n0(f.turns), `${n0(f.steps)} steps · ${dur(f.elapsedS)}${f.cost !== undefined ? ` · $${f.cost.toFixed(4)}` : ""}`)) + if (f.tokens.cacheWrite > 0) out.push(row("", n0(f.tokens.cacheWrite), " written to cache")) if (f.retryReasons.length > 0) { - out.push([], ...titled("Retries", w, String(s.retries))) + out.push([], heading("Retries", w, String(s.retries))) for (const { reason, n } of f.retryReasons) for (const l of wrap(`${n}× ${reason}`, w - 2)) out.push([[` ${l}`, "dim"]]) } - if (s.engine && (s.engine.mtpX !== undefined || s.engine.draftAccept !== undefined || s.engine.prefillTokS !== undefined)) { - out.push([], [[`${ENGINE_MARK} Engine averages`, "engine"], [" ", ""], ["─".repeat(Math.max(0, w - 18)), "rule"]], []) - if (s.engine.mtpX !== undefined) out.push(gridRow("MTP", `${s.engine.mtpX.toFixed(2)}x`, "average")) - if (s.engine.draftAccept !== undefined) out.push(gridRow("draft", `${Math.round(s.engine.draftAccept * 100)}%`, "accepted, average")) - if (s.engine.prefillTokS !== undefined) out.push(gridRow("prefill", n0(s.engine.prefillTokS), "tok/s average")) + out.push([], heading(`${ENGINE_MARK} Engine averages`, w)) + const rows: Array<[string, string]> = [] + if (s.engine.mtpX !== undefined) rows.push(["MTP", `${s.engine.mtpX.toFixed(2)}x`]) + if (s.engine.draftAccept !== undefined) rows.push(["draft", `${Math.round(s.engine.draftAccept * 100)}% accepted`]) + if (s.engine.prefillTokS !== undefined) rows.push(["prefill", `${n0(s.engine.prefillTokS)} tok/s`]) + out.push(...packRows(rows, w)) } if (s.subagents) { - out.push([], ...titled("Sub-agents", w)) - out.push(gridRow("count", n0(s.subagents.count), `${n0(s.subagents.tokens)} tok${s.subagents.cost !== undefined ? ` · $${s.subagents.cost.toFixed(4)}` : ""}`)) + out.push([], heading("Sub-agents", w)) + out.push(row("count", n0(s.subagents.count), ` ${n0(s.subagents.tokens)} tok${s.subagents.cost !== undefined ? ` · $${s.subagents.cost.toFixed(4)}` : ""}`)) } return out } @@ -505,10 +460,10 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): // ---- History ------------------------------------------------------------------------ /** - * The History tab: one row per turn in fixed columns, never wrapped. Scope is - * this session or every session; with every session, a model column appears, - * since that is when the model varies. Cost and cache columns appear only - * when some turn in view has them. + * The History tab: one row per turn in fixed columns, spread across the + * width, never wrapped. Scope is this session or every session; with every + * session, a model column appears. Cost and cache columns appear only when + * some turn in view has them, tools only when some turn used one. */ export function historyTabLines( turns: readonly TurnRecord[], @@ -520,9 +475,9 @@ export function historyTabLines( if (rows.length === 0) return [[[scope === "all" ? "No turns recorded yet." : "No turns in this session yet.", "dim"]]] const showModel = scope === "all" const showCost = rows.some((t) => typeof t.cost === "number" && t.cost > 0) - // Across every session the model column needs the room; cache and tool - // counts stay in the session view, where the model is one. const showCache = !showModel && rows.some((t) => (t.cached ?? 0) > 0) + // Across every session the model column needs the room; tool counts stay + // in the session view. const showTools = !showModel && rows.some((t) => Object.values(t.tools ?? {}).some((x) => x.n > 0)) // Headline: the scope's totals, and its generation speed on the newest model. @@ -550,34 +505,46 @@ export function historyTabLines( [], ] - // Columns, fitted to the width: the model column takes what is left. - const fixed = 2 + 6 + 8 + 8 + 9 + 9 + (showTools ? 6 : 0) + 3 + (showCost ? 9 : 0) + (showCache ? 9 : 0) - const modelW = showModel ? Math.max(8, Math.min(22, w - fixed)) : 0 - const header: Line = [ - [ - ` ${"time".padEnd(6)}${showModel ? "model".padEnd(modelW) : ""}${"tok/s".padStart(8)}${"tokens".padStart(8)}${"ttft".padStart(9)}${"total".padStart(9)}${showTools ? "tools".padStart(6) : ""}${showCost ? "cost".padStart(9) : ""}${showCache ? "cached".padStart(9) : ""} `, - "dim", - ], - [ENGINE_MARK, "engine"], + // Columns: name, width, right-aligned, style. The spare width is shared + // out between them, so the table spans the dialog. + type Col = { name: string; w: number; right: boolean; cell: (t: TurnRecord) => Seg } + // The model column takes what the others leave, at two cells between each. + const others = 2 + 5 + 6 + 7 + 7 + 8 + 1 + (showTools ? 5 : 0) + (showCost ? 8 : 0) + (showCache ? 7 : 0) + const nCols = 6 + (showModel ? 1 : 0) + (showTools ? 1 : 0) + (showCost ? 1 : 0) + (showCache ? 1 : 0) + const modelW = Math.max(8, Math.min(24, w - others - 2 * (nCols - 1))) + const cols: Col[] = [ + { name: "time", w: 5, right: false, cell: (t) => [clock(t.at), ""] }, + ...(showModel + ? ([{ name: "model", w: modelW, right: false, cell: (t: TurnRecord) => [midCut(t.model, modelW), "dim"] as Seg }] as Col[]) + : []), + { name: "tok/s", w: 6, right: true, cell: (t) => [t.rate !== undefined && t.rateWindow !== "whole" && !t.outcome ? n1(t.rate) : "—", "bold"] }, + { name: "tokens", w: 7, right: true, cell: (t) => [t.tokens > 0 ? n0(t.tokens) : "—", ""] }, + { name: "ttft", w: 7, right: true, cell: (t) => [t.ttft !== undefined ? `${t.ttft.toFixed(2)}s` : "—", ""] }, + { name: "total", w: 8, right: true, cell: (t) => [t.totalS !== undefined ? dur(t.totalS) : "—", ""] }, + ...(showTools + ? ([{ name: "tools", w: 5, right: true, cell: (t: TurnRecord) => [String(Object.values(t.tools ?? {}).reduce((a, x) => a + x.n, 0) || ""), ""] as Seg }] as Col[]) + : []), + ...(showCost ? ([{ name: "cost", w: 8, right: true, cell: (t: TurnRecord) => [t.cost ? `$${t.cost.toFixed(4)}` : "", ""] as Seg }] as Col[]) : []), + ...(showCache ? ([{ name: "cached", w: 7, right: true, cell: (t: TurnRecord) => [t.cached ? n0(t.cached) : "", ""] as Seg }] as Col[]) : []), + { name: ENGINE_MARK, w: 1, right: true, cell: (t) => (t.source === "engine" ? [ENGINE_MARK, "engine"] : ["·", "dim"]) }, ] - out.push(header, [["─".repeat(w), "rule"]]) + const base = 2 + cols.reduce((a, c) => a + c.w, 0) + const gap = Math.max(2, Math.floor((w - base) / (cols.length - 1))) + const cell = (c: Col, text: string, i: number): string => { + const t = c.right ? text.padStart(c.w) : text.padEnd(c.w) + return i === 0 ? t : " ".repeat(gap) + t + } + out.push([ + [" ", ""], + ...cols.map((c, i): Seg => [cell(c, c.name, i), c.name === ENGINE_MARK ? "engine" : "dim"]), + ]) + out.push([["─".repeat(w), "rule"]]) const notes: string[] = [] for (const t of rows.slice(0, 200)) { - const calls = Object.values(t.tools ?? {}).reduce((a, x) => a + x.n, 0) - // Cut from the middle: a model's distinguishing part is often its end. - const keep = modelW - 2 - const model = - t.model.length > modelW - 1 ? `${t.model.slice(0, Math.ceil(keep / 3))}…${t.model.slice(t.model.length - Math.floor((keep * 2) / 3))}` : t.model - out.push([ - [` ${clock(t.at).padEnd(6)}`, ""], - ...(showModel ? ([[model.padEnd(modelW), "dim"]] as Line) : []), - [(t.rate !== undefined && t.rateWindow !== "whole" && !t.outcome ? n1(t.rate) : "—").padStart(8), "bold"], - [`${(t.tokens > 0 ? n0(t.tokens) : "—").padStart(8)}${(t.ttft !== undefined ? `${t.ttft.toFixed(2)}s` : "—").padStart(9)}${(t.totalS !== undefined ? dur(t.totalS) : "—").padStart(9)}${showTools ? (calls > 0 ? String(calls) : "").padStart(6) : ""}`, ""], - ...(showCost ? ([[(t.cost ? `$${t.cost.toFixed(4)}` : "").padStart(9), ""]] as Line) : []), - ...(showCache ? ([[(t.cached ? n0(t.cached) : "").padStart(9), ""]] as Line) : []), - [" ", ""], - t.source === "engine" ? [ENGINE_MARK, "engine"] : ["·", "dim"], - ]) + out.push([[" ", ""], ...cols.map((c, i): Seg => { + const [text, style] = c.cell(t) + return [cell(c, text, i), style] + })]) if (t.outcome) notes.push(`${clock(t.at)} ${t.outcome}`) else if (t.source !== "engine" && t.skip && t.skip !== "no-adapter") notes.push(`${clock(t.at)} ${SKIP_LABEL[t.skip]}`) } @@ -587,3 +554,10 @@ export function historyTabLines( } return out } + +/** A long name cut in the middle: a model's distinguishing part is often its end. */ +function midCut(s: string, n: number): string { + if (s.length <= n) return s + const keep = n - 1 + return `${s.slice(0, Math.ceil(keep / 3))}…${s.slice(s.length - Math.floor((keep * 2) / 3))}` +} diff --git a/tui.tsx b/tui.tsx index d9aea4d..916f195 100644 --- a/tui.tsx +++ b/tui.tsx @@ -357,8 +357,22 @@ export default Plugin.define({ } return best?.c as Color | undefined } + // On a dark theme the dialog uses the approved mockup's colours exactly; + // on a light one, shades picked from the theme by contrast (below). + const MOCKUP: Partial> = { + dim: "#8a8a8a", + wait: "#5a5a5a", + gen: "#6b9bff", + accent: "#6b9bff", + tool: "#5fb3a8", + sub: "#b48ae0", + comp: "#8a8a8a", + engine: "#e0a95a", + rule: "#4a4a4a", + } const palette = new Map() const styleColor = (st: Style): Color | undefined => { + if (ctx.themeMode === "dark" && MOCKUP[st]) return MOCKUP[st] as unknown as Color if (palette.has(st)) return palette.get(st) let c: Color | undefined switch (st) { @@ -400,8 +414,14 @@ export default Plugin.define({ st === "bold" ? ( {t} ) : st === "tab" ? ( - - {t} + + {t} ) : ( {t} @@ -510,10 +530,10 @@ export default Plugin.define({ // the layout needs, the dialog goes to `xlarge` and is measured again. const fit = (tries: number): void => { setTimeout(() => { - // Less the side padding (2 x 4) and the scrollbar with a gap - // beside it (2): a full-width line that overflowed wrapped onto - // a second line, which read as a blank row under every heading. - const inner = root?.width !== undefined ? root.width - 10 : undefined + // Less the side padding (2 x 2, as the mockup) and the scrollbar + // with a gap beside it (2): a full-width line that overflowed + // wrapped onto a second line, a blank-looking row (measured). + const inner = root?.width !== undefined ? root.width - 6 : undefined if (inner !== undefined && inner < CONTENT_WIDTH && tries > 0) { ctx.ui.dialog.set({ size: "xlarge", centered: true }) fit(tries - 1) @@ -541,8 +561,8 @@ export default Plugin.define({ return ( (root = r as typeof root)} @@ -570,9 +590,9 @@ export default Plugin.define({ dbg("details: closed") } ) - // xlarge (116 cells on a 214-column terminal, measured) leaves room for - // wider margins around a 72-96 cell layout. - ctx.ui.dialog.set({ size: "xlarge", centered: true }) + // large (88 cells, measured) is nearest the mockup's 76-cell dialog; + // the content fills it. + ctx.ui.dialog.set({ size: "large", centered: true }) } const currentSession = (): string | undefined => { const r = ctx.ui.router.current() From 331b5d5ca1f01226f1b57498eaf14f956d9b933e Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 14:23:02 -0700 Subject: [PATCH 09/16] Dialog: spacing options (blank row under headings, or under bars); balance top padding; reserve the scrollbar only when scrolling --- dialog.ts | 29 +++++++++++++++++++++++++++++ tui.tsx | 32 ++++++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/dialog.ts b/dialog.ts index a4dc9e3..df8eef8 100644 --- a/dialog.ts +++ b/dialog.ts @@ -561,3 +561,32 @@ function midCut(s: string, n: number): string { const keep = n - 1 return `${s.slice(0, Math.ceil(keep / 3))}…${s.slice(s.length - Math.floor((keep * 2) / 3))}` } + +// ---- vertical spacing ------------------------------------------------------------------ + +/** + * A terminal row has one height and a plugin cannot change it, so space + * between lines comes only in whole blank rows. Two ways to spend them: + * + * - "headings": a blank row under each section heading; + * - "bars": a blank row under a bar, so its blocks -- which fill their whole + * row -- do not touch the legend or keys below. Consecutive bar rows (the + * timeline, tools by time) stay together. + */ +export type Spacing = "tight" | "headings" | "bars" + +const isBarLine = (l: Line): boolean => l.some(([t]) => t.length >= 2 && /^[░█▒▓╳]+$/.test(t)) +const isHeading = (l: Line): boolean => l.some(([t, st]) => st === "rule" && t.startsWith("─")) && (l[0]?.[1] === "bold" || l[0]?.[1] === "engine") + +export function spaced(lines: readonly Line[], spacing: Spacing): Line[] { + if (spacing === "tight") return [...lines] + const out: Line[] = [] + lines.forEach((l, i) => { + out.push(l) + const next = lines[i + 1] + const blankNext = next === undefined || next.length === 0 + if (spacing === "headings" && isHeading(l) && !blankNext) out.push([]) + if (spacing === "bars" && isBarLine(l) && !blankNext && !isBarLine(next as Line)) out.push([]) + }) + return out +} diff --git a/tui.tsx b/tui.tsx index 916f195..c220cf9 100644 --- a/tui.tsx +++ b/tui.tsx @@ -45,6 +45,8 @@ import { dur, TABS, CONTENT_WIDTH, + spaced, + type Spacing, type Line, type Style, type Tab, @@ -177,6 +179,12 @@ interface UiState { // ---- entry ------------------------------------------------------------------ +/** + * Blank rows in the details dialog: "tight" as the approved mockup, or a row + * under each heading, or under each bar (see dialog.ts). + */ +const DIALOG_SPACING: Spacing = "tight" + /** Identifies this plugin's panel among any others contributed to the slot. */ const PANEL_NAME = "headsup.history" @@ -470,9 +478,13 @@ export default Plugin.define({ // The current tab's lines, reactive on the tab, the scope and the // stored turn and history. const lines = (): Line[] => { - if (details.tab === "turn") return turnLines(sessionID ? turnDetail.bySession[sessionID] : undefined, details.w) - if (details.tab === "session") return sessionLines(sessionFigures(history.turns, sessionID), details.w) - return historyTabLines(history.turns, sessionID, details.scope, details.w) + const raw = + details.tab === "turn" + ? turnLines(sessionID ? turnDetail.bySession[sessionID] : undefined, details.w) + : details.tab === "session" + ? sessionLines(sessionFigures(history.turns, sessionID), details.w) + : historyTabLines(history.turns, sessionID, details.scope, details.w) + return spaced(raw, DIALOG_SPACING) } const note = (): string => { if (details.tab === "turn") { @@ -530,10 +542,12 @@ export default Plugin.define({ // the layout needs, the dialog goes to `xlarge` and is measured again. const fit = (tries: number): void => { setTimeout(() => { - // Less the side padding (2 x 2, as the mockup) and the scrollbar - // with a gap beside it (2): a full-width line that overflowed - // wrapped onto a second line, a blank-looking row (measured). - const inner = root?.width !== undefined ? root.width - 6 : undefined + // Less the side padding (2 x 2, as the mockup), and the + // scrollbar's column with a gap only when the content scrolls: + // reserved always, it left the right margin wider than the left + // (measured). A full-width line that overflowed would wrap. + const scrolls = lines().length > bodyRows() + const inner = root?.width !== undefined ? root.width - 4 - (scrolls ? 2 : 0) : undefined if (inner !== undefined && inner < CONTENT_WIDTH && tries > 0) { ctx.ui.dialog.set({ size: "xlarge", centered: true }) fit(tries - 1) @@ -561,9 +575,11 @@ export default Plugin.define({ return ( (root = r as typeof root)} > From 9f5566db5353a13e880795a33a187373a0b62cee Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 14:34:34 -0700 Subject: [PATCH 10/16] Dialog spacing as the user laid it out: a row under headings, around bars, charts kept together; show tool use or why not --- dialog.ts | 41 ++++++++++++++++++++++++++++++----------- session.ts | 3 +++ tui.tsx | 10 ++++------ 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/dialog.ts b/dialog.ts index df8eef8..c3cc8d2 100644 --- a/dialog.ts +++ b/dialog.ts @@ -404,7 +404,10 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): if (f.time) out.push(heading("Where the time went", w, dur(f.time.total)), timeBar(f.time, w), ...legendFlow(f.time, w), []) - if (f.tools.length > 0) { + if (f.tools.length === 0) { + out.push(heading("Tools by time", w)) + out.push([[f.toolsRecorded > 0 ? " no tool calls in these turns" : " not recorded for these turns (recorded from this version on)", "dim"]], []) + } else { const shown = f.tools.slice(0, 8) const most = (shown[0] as { s: number }).s out.push(heading("Tools by time", w, `${n0(f.toolCalls)} calls`)) @@ -566,27 +569,43 @@ function midCut(s: string, n: number): string { /** * A terminal row has one height and a plugin cannot change it, so space - * between lines comes only in whole blank rows. Two ways to spend them: + * between lines comes only in whole blank rows. "roomy" is the layout the + * user settled on (2026-09-25, by editing the mockup): * - * - "headings": a blank row under each section heading; - * - "bars": a blank row under a bar, so its blocks -- which fill their whole - * row -- do not touch the legend or keys below. Consecutive bar rows (the - * timeline, tools by time) stay together. + * - a blank row under every section heading; + * - a blank row above and below a bar, whose blocks fill their whole row and + * would otherwise touch the text next to it; + * - except inside a chart -- the timeline's steps, tools by time -- whose rows + * belong together and keep their axis beside them. */ -export type Spacing = "tight" | "headings" | "bars" +export type Spacing = "tight" | "roomy" const isBarLine = (l: Line): boolean => l.some(([t]) => t.length >= 2 && /^[░█▒▓╳]+$/.test(t)) -const isHeading = (l: Line): boolean => l.some(([t, st]) => st === "rule" && t.startsWith("─")) && (l[0]?.[1] === "bold" || l[0]?.[1] === "engine") +const isHeading = (l: Line): boolean => + l.some(([t, st]) => st === "rule" && t.startsWith("─")) && (l[0]?.[1] === "bold" || l[0]?.[1] === "engine") +/** A chart row: an indented, dim label (a step number, a tool name), then its bar. */ +const isChartRow = (l: Line): boolean => { + const first = l[0] + return first !== undefined && first[1] === "dim" && /^ {2}\S/.test(first[0]) && isBarLine(l) +} +/** The timeline's axis, which stays under its chart. */ +const isAxis = (l: Line): boolean => l[0]?.[1] === "dim" && /^ {5}0s$/.test(l[0][0]) export function spaced(lines: readonly Line[], spacing: Spacing): Line[] { if (spacing === "tight") return [...lines] const out: Line[] = [] + const blank = (): void => { + if (out.length > 0 && (out[out.length - 1] as Line).length > 0) out.push([]) + } lines.forEach((l, i) => { + const bar = isBarLine(l) && !isChartRow(l) + if (bar) blank() out.push(l) const next = lines[i + 1] - const blankNext = next === undefined || next.length === 0 - if (spacing === "headings" && isHeading(l) && !blankNext) out.push([]) - if (spacing === "bars" && isBarLine(l) && !blankNext && !isBarLine(next as Line)) out.push([]) + if (next === undefined || next.length === 0) return + if (isHeading(l)) blank() + else if (bar) blank() + else if (isChartRow(l) && !isChartRow(next) && !isAxis(next)) blank() }) return out } diff --git a/session.ts b/session.ts index 00a9b3e..e081ec7 100644 --- a/session.ts +++ b/session.ts @@ -295,6 +295,8 @@ export interface SessionFigures { time?: { waiting: number; generating: number; tools: number; subagents: number; compaction: number; other: number; total: number } /** Per tool name, most time first. */ tools: Array<{ name: string; s: number; n: number }> + /** Turns whose tool use was recorded at all (rows from before it was lack it). */ + toolsRecorded: number tokens: { output: number; reasoning: number; input?: number; cacheRead?: number; cacheWrite: number } retryReasons: Array<{ reason: string; n: number }> coverage: { engine: number; total: number; without: Array<{ label: string; n: number }> } @@ -383,6 +385,7 @@ export function sessionFigures(history: readonly TurnRecord[], sessionID: string ? { waiting: wait, generating: gen, tools, subagents: sub, compaction: comp, other: Math.max(0, total - wait - gen - tools - sub - comp), total } : undefined, tools: [...byTool.entries()].map(([name, x]) => ({ name, ...x })).sort((a, b) => b.s - a.s), + toolsRecorded: turns.filter((t) => t.tools !== undefined).length, tokens: { output: sum((t) => t.tokens) - reasoning, reasoning, diff --git a/tui.tsx b/tui.tsx index c220cf9..d0af93d 100644 --- a/tui.tsx +++ b/tui.tsx @@ -179,11 +179,8 @@ interface UiState { // ---- entry ------------------------------------------------------------------ -/** - * Blank rows in the details dialog: "tight" as the approved mockup, or a row - * under each heading, or under each bar (see dialog.ts). - */ -const DIALOG_SPACING: Spacing = "tight" +/** Blank rows in the details dialog: "roomy", as the user laid it out (see dialog.ts). */ +const DIALOG_SPACING: Spacing = "roomy" /** Identifies this plugin's panel among any others contributed to the slot. */ const PANEL_NAME = "headsup.history" @@ -1445,7 +1442,8 @@ export default Plugin.define({ cacheWrite: detail?.tokens.cacheWrite || undefined, toolsS: detail?.time?.tools, compactionS: detail?.time?.compaction || undefined, - tools: detail ? toolsByName(detail) : undefined, + // {} when the turn used no tool, so "none" can be told from "not recorded". + tools: detail ? (toolsByName(detail) ?? {}) : undefined, retryReasons: detail?.steps.flatMap((st) => (st.retryReason ? [st.retryReason] : [])), skip: enriched ? undefined From 7983db3d47e096b09ac4541ebdf4c43623855f62 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 14:39:50 -0700 Subject: [PATCH 11/16] Dialog: an option to indent the body under section titles --- dialog.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/dialog.ts b/dialog.ts index c3cc8d2..b3653af 100644 --- a/dialog.ts +++ b/dialog.ts @@ -609,3 +609,24 @@ export function spaced(lines: readonly Line[], spacing: Spacing): Line[] { }) return out } + +/** + * Indents everything under a section heading by two cells, so figures sit in + * from their titles as the time bar does. Headings keep the full width; lines + * already indented (the time bar, charts, tables) stay where they are. Lay the + * body out at `w - 2` before indenting it. + */ +export function indentBody(lines: readonly Line[], w: number): Line[] { + return lines.map((l) => { + if (l.length === 0) return l + if (isHeading(l)) { + // Headings were laid out 2 cells narrower with the body; widen the rule back. + const i = l.findIndex(([t, st]) => st === "rule" && t.startsWith("─")) + if (i < 0) return l + const out = [...l] + out[i] = ["─".repeat((l[i] as Seg)[0].length + (w - width(l))), "rule"] + return out + } + return /^ {2}/.test(l[0]?.[0] ?? "") ? l : [[" ", ""], ...l] + }) +} From 3998e7adbdd893810e939352b11836c1eb30cc1e Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 14:44:22 -0700 Subject: [PATCH 12/16] Two-tone dialog: a lighter panel inside the dialog's darker ring; drop the body indent option --- dialog.ts | 21 --------------------- tui.tsx | 26 +++++++++++++++++++++----- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/dialog.ts b/dialog.ts index b3653af..c3cc8d2 100644 --- a/dialog.ts +++ b/dialog.ts @@ -609,24 +609,3 @@ export function spaced(lines: readonly Line[], spacing: Spacing): Line[] { }) return out } - -/** - * Indents everything under a section heading by two cells, so figures sit in - * from their titles as the time bar does. Headings keep the full width; lines - * already indented (the time bar, charts, tables) stay where they are. Lay the - * body out at `w - 2` before indenting it. - */ -export function indentBody(lines: readonly Line[], w: number): Line[] { - return lines.map((l) => { - if (l.length === 0) return l - if (isHeading(l)) { - // Headings were laid out 2 cells narrower with the body; widen the rule back. - const i = l.findIndex(([t, st]) => st === "rule" && t.startsWith("─")) - if (i < 0) return l - const out = [...l] - out[i] = ["─".repeat((l[i] as Seg)[0].length + (w - width(l))), "rule"] - return out - } - return /^ {2}/.test(l[0]?.[0] ?? "") ? l : [[" ", ""], ...l] - }) -} diff --git a/tui.tsx b/tui.tsx index d0af93d..8e129d3 100644 --- a/tui.tsx +++ b/tui.tsx @@ -496,7 +496,8 @@ export default Plugin.define({ } // Sized to the content, up to 70% of the screen less the tabs and // footer; it scrolls only beyond that. - const bodyRows = (): number => Math.max(4, Math.min(lines().length, Math.floor(details.rows * 0.75) - 6)) + // Less the tabs, footer, their gaps, the panel's padding and the ring. + const bodyRows = (): number => Math.max(4, Math.min(lines().length, Math.floor(details.rows * 0.75) - 9)) ctx.keymap.layer(() => ({ mode: "global", priority: 100, @@ -544,7 +545,8 @@ export default Plugin.define({ // reserved always, it left the right margin wider than the left // (measured). A full-width line that overflowed would wrap. const scrolls = lines().length > bodyRows() - const inner = root?.width !== undefined ? root.width - 4 - (scrolls ? 2 : 0) : undefined + // Less the ring (2 x 2) and the panel's padding (2 x 2). + const inner = root?.width !== undefined ? root.width - 8 - (scrolls ? 2 : 0) : undefined if (inner !== undefined && inner < CONTENT_WIDTH && tries > 0) { ctx.ui.dialog.set({ size: "xlarge", centered: true }) fit(tries - 1) @@ -569,17 +571,30 @@ export default Plugin.define({ }, 60) } fit(1) + // Two tones: an outer ring in the dialog's own colour (its top row is + // the dialog's padding; the plugin adds the sides and bottom), round + // a lighter panel with padding of its own. The ring reads as a thick + // dark border, as the sidebar boxes do (chosen from the mockup). + const panel = (): Color | undefined => + ctx.themeMode === "dark" + ? ("#1f1f1f" as unknown as Color) + : (themeColor("background.raised.high", "background.surface.offset") as Color | undefined) return ( (root = r as typeof root)} > + {drawLine(tabsLine(details.tab, note(), details.w))} {drawLine(footLine(details.tab, details.w))} + ) }, From 54adb81f2b9179857c43b84216ef1e67df852944 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 14:54:38 -0700 Subject: [PATCH 13/16] Dialog: square bars; token rows and engine figures on aligned columns --- dialog.ts | 169 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 137 insertions(+), 32 deletions(-) diff --git a/dialog.ts b/dialog.ts index c3cc8d2..1dbbe56 100644 --- a/dialog.ts +++ b/dialog.ts @@ -95,7 +95,11 @@ export function bar(parts: ReadonlyArray = { waiting: "wait", generating: "gen", @@ -196,6 +200,64 @@ function legendFlow(t: Split, w: number): Line[] { return out } +// ---- aligned rows and columns ------------------------------------------------------- + +/** + * Rows of one section laid out on shared columns: label, value (right-aligned + * to the widest), qualifier (to the widest), then a bar that starts and ends + * at the same place on every row, then a note. A row without a bar puts its + * note where the bars start. + */ +export type AlignedRow = { label: string; value: string; qual?: string; bar?: Array; note?: string } + +export function alignedRows(rows: readonly AlignedRow[], barCells: number): Line[] { + const vw = Math.max(0, ...rows.map((r) => r.value.length)) + const qw = Math.max(0, ...rows.map((r) => (r.qual ? r.qual.length + 1 : 0))) + return rows.map((r): Line => { + const line: Line = [ + [r.label.padEnd(LABEL), "dim"], + [r.value.padStart(vw), "bold"], + [(r.qual ? ` ${r.qual}` : "").padEnd(qw), "dim"], + [" ", ""], + ] + if (r.bar) line.push(...bar(r.bar, barCells), [" ", ""]) + if (r.note) line.push([r.note, "dim"]) + return line + }) +} + +/** + * Label/value items in a grid of `n` columns, row by row. Within a column the + * labels are padded to the widest and the values line up; the first column's + * labels sit in the section's label column. + */ +export function columns(items: ReadonlyArray<{ label: string; value: Line }>, w: number, n = 3): Line[] { + for (let cols = Math.min(n, items.length); cols >= 1; cols--) { + const lw = Array.from({ length: cols }, (_, c) => { + const labels = items.filter((_, i) => i % cols === c).map((it) => it.label.length) + return c === 0 ? Math.max(LABEL - 1, ...labels) : Math.max(0, ...labels) + }) + const vw = Array.from({ length: cols }, (_, c) => Math.max(0, ...items.filter((_, i) => i % cols === c).map((it) => width(it.value)))) + const gap = 4 + const total = lw.reduce((a, b) => a + b + 1, 0) + vw.reduce((a, b) => a + b, 0) + gap * (cols - 1) + if (total > w && cols > 1) continue + const out: Line[] = [] + for (let i = 0; i < items.length; i += cols) { + const line: Line = [] + for (let c = 0; c < cols && i + c < items.length; c++) { + const it = items[i + c] as { label: string; value: Line } + if (c > 0) line.push([" ".repeat(gap), ""]) + line.push([`${it.label.padEnd(lw[c] as number)} `, "dim"], ...it.value) + const pad = (vw[c] as number) - width(it.value) + if (c < cols - 1 && pad > 0) line.push([" ".repeat(pad), ""]) + } + out.push(line) + } + return out + } + return [] +} + // ---- Turn ------------------------------------------------------------------------ /** The Turn tab, laid out as the mockup. */ @@ -290,36 +352,41 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] // Tokens: generated with its split; prompt and context with a bar each. const t = d.tokens const produced = t.output + t.reasoning - out.push(heading("Tokens", w)) - out.push( - row("generated", n0(produced), t.reasoning > 0 ? ` ${n0(t.output)} answer · ${n0(t.reasoning)} reasoning (${share(t.reasoning, produced)})` : "") - ) const prompt = t.input + t.cacheRead + const tokenRows: AlignedRow[] = [ + { + label: "generated", + value: n0(produced), + qual: "tok", + note: t.reasoning > 0 ? `${n0(t.output)} answer · ${n0(t.reasoning)} reasoning (${share(t.reasoning, produced)})` : undefined, + }, + ] if (prompt > 0) { - out.push([ - ...row("input", n0(t.input), " fresh "), - ...bar([[t.input, "gen", GLYPH.generating], [t.cacheRead, "wait", GLYPH.waiting]], sc(24, w)), - [` ${n0(t.cacheRead)} cached (${share(t.cacheRead, prompt)})`, "dim"], - ]) + tokenRows.push({ + label: "input", + value: n0(t.input), + qual: "fresh", + bar: [[t.input, "gen", SQ], [t.cacheRead, "wait", SQ]], + note: `${n0(t.cacheRead)} cached (${share(t.cacheRead, prompt)})`, + }) } - if (t.cacheWrite > 0) out.push(row("", n0(t.cacheWrite), " written to cache")) + if (t.cacheWrite > 0) tokenRows.push({ label: "", value: n0(t.cacheWrite), qual: "written to cache" }) if (d.context) { - const tail: Line = d.context.limit - ? [ - [` of ${n0(d.context.limit)} `, "dim"], - ...bar([[d.context.used, "gen", GLYPH.generating], [Math.max(0, d.context.limit - d.context.used), "wait", GLYPH.waiting]], sc(24, w)), - [` ${share(d.context.used, d.context.limit)}`, "dim"], - ] - : [] - out.push([...row("context", n0(d.context.used)), ...tail]) + tokenRows.push({ + label: "context", + value: n0(d.context.used), + qual: d.context.limit ? `of ${n0(d.context.limit)}` : undefined, + bar: d.context.limit ? [[d.context.used, "gen", SQ], [Math.max(0, d.context.limit - d.context.used), "wait", SQ]] : undefined, + note: d.context.limit ? share(d.context.used, d.context.limit) : undefined, + }) } - if (d.cost !== undefined) out.push(row("cost", `$${d.cost.toFixed(4)}`)) - out.push([]) + if (d.cost !== undefined) tokenRows.push({ label: "cost", value: `$${d.cost.toFixed(4)}` }) + out.push(heading("Tokens", w), ...alignedRows(tokenRows, sc(24, w)), []) // Engine: its own figures, a few to a line; or which were left out and why. if (d.engineRows.length > 0) { out.push(heading(`${ENGINE_MARK} ${d.engine}`, w, "measured by the engine")) - out.push(...packRows(d.engineRows, w)) + out.push(...columns(engineItems(d.engineRows), w, 3)) if (d.compactionEngine) for (const c of d.compactionEngine) out.push(row("compaction", c, " taken out of the above")) } else { out.push(heading(d.engine, w)) @@ -335,6 +402,42 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] return out } +/** + * The engine's rows as grid items, in the mockup's order: rates first, then + * speculative decoding. A continuation row becomes its own item where it is a + * figure of its own (`95 verify passes` -> `verify 95 passes`); acceptance by + * depth folds into `93/87/82%`. The token count is left out: the Tokens + * section has it. + */ +export function engineItems(rows: ReadonlyArray): Array<{ label: string; value: Line }> { + const all: Array<{ label: string; values: string[] }> = [] + for (const [label, value] of rows) { + if (label || all.length === 0) all.push({ label, values: [value] }) + else (all[all.length - 1] as { values: string[] }).values.push(value) + } + const items: Array<{ label: string; value: Line }> = [] + for (const { label, values } of all) { + if (label === "tokens") continue + const depths = values.map((v) => /^(\d+)% at depth \d+$/.exec(v)?.[1]) + if (depths.length > 1 && depths.every((x) => x !== undefined)) { + items.push({ label, value: [[`${depths.join("/")}%`, "bold"], [" by depth", "dim"]] }) + continue + } + const [first, ...rest] = values + items.push({ label, value: [[first ?? "", "bold"]] }) + for (const r of rest) { + const m = /^([\d,.]+) (.+)$/.exec(r) + if (m) { + const words = (m[2] as string).split(" ") + items.push({ label: words[0] as string, value: [[m[1] as string, "bold"], [` ${words.slice(1).join(" ")}`, "dim"]] }) + } else items.push({ label: "", value: [[r, "dim"]] }) + } + } + const ORDER = ["speed", "prefill", "ttft", "MTP", "verify", "accepted", "draft"] + const rank = (l: string): number => (ORDER.includes(l) ? ORDER.indexOf(l) : ORDER.length) + return items.sort((a, b) => rank(a.label) - rank(b.label)) +} + /** * Label/value rows packed several to a line, as the mockup's engine section: * `speed 41.2 tok/s prefill 475 tok/s ttft 17.37s`. A row with an @@ -431,15 +534,17 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): f.coverage.without.forEach(({ label, n }, i) => out.push([[(i === 0 ? "without" : "").padEnd(LABEL), "dim"], [`${n0(n)} ${label}`, "dim"]])) out.push([]) - out.push(heading("Tokens", w)) const gen = f.tokens.output + f.tokens.reasoning - out.push(row("generated", n0(gen), f.tokens.reasoning > 0 ? ` ${n0(f.tokens.output)} answer · ${n0(f.tokens.reasoning)} reasoning` : "")) + const sessRows: AlignedRow[] = [ + { label: "generated", value: n0(gen), qual: "tok", note: f.tokens.reasoning > 0 ? `${n0(f.tokens.output)} answer · ${n0(f.tokens.reasoning)} reasoning (${share(f.tokens.reasoning, gen)})` : undefined }, + ] if (f.tokens.input !== undefined || f.tokens.cacheRead !== undefined) { const fresh = f.tokens.input ?? 0 const cached = f.tokens.cacheRead ?? 0 - out.push(row("input", n0(fresh), ` fresh ${n0(cached)} cached (${share(cached, fresh + cached)} hit)`)) + sessRows.push({ label: "input", value: n0(fresh), qual: "fresh", bar: [[fresh, "gen", SQ], [cached, "wait", SQ]], note: `${n0(cached)} cached (${share(cached, fresh + cached)} hit)` }) } - if (f.tokens.cacheWrite > 0) out.push(row("", n0(f.tokens.cacheWrite), " written to cache")) + if (f.tokens.cacheWrite > 0) sessRows.push({ label: "", value: n0(f.tokens.cacheWrite), qual: "written to cache" }) + out.push(heading("Tokens", w), ...alignedRows(sessRows, sc(24, w))) if (f.retryReasons.length > 0) { out.push([], heading("Retries", w, String(s.retries))) @@ -447,11 +552,11 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): } if (s.engine && (s.engine.mtpX !== undefined || s.engine.draftAccept !== undefined || s.engine.prefillTokS !== undefined)) { out.push([], heading(`${ENGINE_MARK} Engine averages`, w)) - const rows: Array<[string, string]> = [] - if (s.engine.mtpX !== undefined) rows.push(["MTP", `${s.engine.mtpX.toFixed(2)}x`]) - if (s.engine.draftAccept !== undefined) rows.push(["draft", `${Math.round(s.engine.draftAccept * 100)}% accepted`]) - if (s.engine.prefillTokS !== undefined) rows.push(["prefill", `${n0(s.engine.prefillTokS)} tok/s`]) - out.push(...packRows(rows, w)) + const items: Array<{ label: string; value: Line }> = [] + if (s.engine.prefillTokS !== undefined) items.push({ label: "prefill", value: [[`${n0(s.engine.prefillTokS)} tok/s`, "bold"]] }) + if (s.engine.mtpX !== undefined) items.push({ label: "MTP", value: [[`${s.engine.mtpX.toFixed(2)}x`, "bold"]] }) + if (s.engine.draftAccept !== undefined) items.push({ label: "draft", value: [[`${Math.round(s.engine.draftAccept * 100)}%`, "bold"], [" accepted", "dim"]] }) + out.push(...columns(items, w, 3)) } if (s.subagents) { out.push([], heading("Sub-agents", w)) @@ -580,7 +685,7 @@ function midCut(s: string, n: number): string { */ export type Spacing = "tight" | "roomy" -const isBarLine = (l: Line): boolean => l.some(([t]) => t.length >= 2 && /^[░█▒▓╳]+$/.test(t)) +const isBarLine = (l: Line): boolean => l.some(([t]) => t.length >= 2 && /^[░█▒▓╳■]+$/.test(t)) const isHeading = (l: Line): boolean => l.some(([t, st]) => st === "rule" && t.startsWith("─")) && (l[0]?.[1] === "bold" || l[0]?.[1] === "engine") /** A chart row: an indented, dim label (a step number, a tool name), then its bar. */ From 4271f7ec794fc486cb7cab0112eebb7c84007a5f Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 14:55:08 -0700 Subject: [PATCH 14/16] Aligned token rows: the bar gives way to the notes at narrow widths --- dialog.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/dialog.ts b/dialog.ts index 1dbbe56..c75a8fe 100644 --- a/dialog.ts +++ b/dialog.ts @@ -210,9 +210,13 @@ function legendFlow(t: Split, w: number): Line[] { */ export type AlignedRow = { label: string; value: string; qual?: string; bar?: Array; note?: string } -export function alignedRows(rows: readonly AlignedRow[], barCells: number): Line[] { +export function alignedRows(rows: readonly AlignedRow[], barCells: number, w: number): Line[] { const vw = Math.max(0, ...rows.map((r) => r.value.length)) const qw = Math.max(0, ...rows.map((r) => (r.qual ? r.qual.length + 1 : 0))) + // The bar gives way to the widest note on a barred row, so nothing overflows. + const lead = LABEL + vw + qw + 3 + const note = Math.max(0, ...rows.filter((r) => r.bar).map((r) => (r.note ? r.note.length + 3 : 0))) + barCells = Math.max(6, Math.min(barCells, w - lead - note)) return rows.map((r): Line => { const line: Line = [ [r.label.padEnd(LABEL), "dim"], @@ -381,7 +385,7 @@ export function turnLines(d: TurnDetail | undefined, w = CONTENT_WIDTH): Line[] }) } if (d.cost !== undefined) tokenRows.push({ label: "cost", value: `$${d.cost.toFixed(4)}` }) - out.push(heading("Tokens", w), ...alignedRows(tokenRows, sc(24, w)), []) + out.push(heading("Tokens", w), ...alignedRows(tokenRows, sc(24, w), w), []) // Engine: its own figures, a few to a line; or which were left out and why. if (d.engineRows.length > 0) { @@ -544,7 +548,7 @@ export function sessionLines(f: SessionFigures | undefined, w = CONTENT_WIDTH): sessRows.push({ label: "input", value: n0(fresh), qual: "fresh", bar: [[fresh, "gen", SQ], [cached, "wait", SQ]], note: `${n0(cached)} cached (${share(cached, fresh + cached)} hit)` }) } if (f.tokens.cacheWrite > 0) sessRows.push({ label: "", value: n0(f.tokens.cacheWrite), qual: "written to cache" }) - out.push(heading("Tokens", w), ...alignedRows(sessRows, sc(24, w))) + out.push(heading("Tokens", w), ...alignedRows(sessRows, sc(24, w), w)) if (f.retryReasons.length > 0) { out.push([], heading("Retries", w, String(s.retries))) From a0c35ef05b66960684d391c4b7ed09f56789fdc0 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 15:17:23 -0700 Subject: [PATCH 15/16] Dialog spacing: a blank row under headings only; always leave room for the scrollbar; xlarge --- dialog.ts | 28 ++++------------------------ tui.tsx | 19 +++++++++---------- 2 files changed, 13 insertions(+), 34 deletions(-) diff --git a/dialog.ts b/dialog.ts index c75a8fe..6c32853 100644 --- a/dialog.ts +++ b/dialog.ts @@ -679,42 +679,22 @@ function midCut(s: string, n: number): string { /** * A terminal row has one height and a plugin cannot change it, so space * between lines comes only in whole blank rows. "roomy" is the layout the - * user settled on (2026-09-25, by editing the mockup): - * - * - a blank row under every section heading; - * - a blank row above and below a bar, whose blocks fill their whole row and - * would otherwise touch the text next to it; - * - except inside a chart -- the timeline's steps, tools by time -- whose rows - * belong together and keep their axis beside them. + * user settled on (2026-09-25, by editing the mockup): a blank row under every + * section heading, and the blank row between sections. Nothing else: rows + * around the bars made the Tokens section and the time bar look gapped. */ export type Spacing = "tight" | "roomy" -const isBarLine = (l: Line): boolean => l.some(([t]) => t.length >= 2 && /^[░█▒▓╳■]+$/.test(t)) const isHeading = (l: Line): boolean => l.some(([t, st]) => st === "rule" && t.startsWith("─")) && (l[0]?.[1] === "bold" || l[0]?.[1] === "engine") -/** A chart row: an indented, dim label (a step number, a tool name), then its bar. */ -const isChartRow = (l: Line): boolean => { - const first = l[0] - return first !== undefined && first[1] === "dim" && /^ {2}\S/.test(first[0]) && isBarLine(l) -} -/** The timeline's axis, which stays under its chart. */ -const isAxis = (l: Line): boolean => l[0]?.[1] === "dim" && /^ {5}0s$/.test(l[0][0]) export function spaced(lines: readonly Line[], spacing: Spacing): Line[] { if (spacing === "tight") return [...lines] const out: Line[] = [] - const blank = (): void => { - if (out.length > 0 && (out[out.length - 1] as Line).length > 0) out.push([]) - } lines.forEach((l, i) => { - const bar = isBarLine(l) && !isChartRow(l) - if (bar) blank() out.push(l) const next = lines[i + 1] - if (next === undefined || next.length === 0) return - if (isHeading(l)) blank() - else if (bar) blank() - else if (isChartRow(l) && !isChartRow(next) && !isAxis(next)) blank() + if (isHeading(l) && next !== undefined && next.length > 0) out.push([]) }) return out } diff --git a/tui.tsx b/tui.tsx index 8e129d3..2bfd4bb 100644 --- a/tui.tsx +++ b/tui.tsx @@ -540,13 +540,12 @@ export default Plugin.define({ // the layout needs, the dialog goes to `xlarge` and is measured again. const fit = (tries: number): void => { setTimeout(() => { - // Less the side padding (2 x 2, as the mockup), and the - // scrollbar's column with a gap only when the content scrolls: - // reserved always, it left the right margin wider than the left - // (measured). A full-width line that overflowed would wrap. - const scrolls = lines().length > bodyRows() - // Less the ring (2 x 2) and the panel's padding (2 x 2). - const inner = root?.width !== undefined ? root.width - 8 - (scrolls ? 2 : 0) : undefined + // Less the ring (2 x 2), the panel's padding (2 x 2) and the + // scrollbar with a gap (2), always: reserved only when the + // content scrolled, the width was worked out before the + // scrollbar appeared, so full-width lines ran 2 cells long and + // wrapped onto a second, blank-looking row (measured). + const inner = root?.width !== undefined ? root.width - 10 : undefined if (inner !== undefined && inner < CONTENT_WIDTH && tries > 0) { ctx.ui.dialog.set({ size: "xlarge", centered: true }) fit(tries - 1) @@ -619,9 +618,9 @@ export default Plugin.define({ dbg("details: closed") } ) - // large (88 cells, measured) is nearest the mockup's 76-cell dialog; - // the content fills it. - ctx.ui.dialog.set({ size: "large", centered: true }) + // xlarge: large (88 cells) left too little room once the scrollbar + // and the two-tone ring were in (the user's call). The content fills it. + ctx.ui.dialog.set({ size: "xlarge", centered: true }) } const currentSession = (): string | undefined => { const r = ctx.ui.router.current() From 901c1fe5c75654e4cfaf2f86b4de5ea54eab9367 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 15:49:34 -0700 Subject: [PATCH 16/16] Separate a long legend name from its time; changelog for the dialog's final layout --- CHANGELOG.md | 11 ++++++++--- dialog.ts | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efbf040..dad77fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,14 @@ ### Changed - The details dialog is one column with three tabs -- Turn, Session and History -- switched with `tab`, instead of two side-by-side columns. It is - as tall as its content (up to most of the screen) rather than always - near full height, a fixed 72 cells wide with even padding, and uses - section rules, bars for shares, and bold values beside dim labels. + as tall as its content (up to most of the screen), fills the width of an + `xlarge` dialog, and sits on a lighter panel inside a darker ring. Each + section has a title and rule with a blank row under it; shares are drawn + as square bars; and figures within a section sit on shared columns + (Tokens' values, qualifiers and bars; the engine's figures in a + three-column grid). +- Tools by time on the Session tab always shows: the tools used, that none + were, or that tool use was not recorded for older turns. - The Turn tab adds a timeline: each step's wait, generation and tools on one time scale. Its steps table shows the engine's own per-step rate beside OpenCode's where the engine is read per step. diff --git a/dialog.ts b/dialog.ts index 6c32853..a408b62 100644 --- a/dialog.ts +++ b/dialog.ts @@ -169,7 +169,7 @@ function legendColumns(t: Split): Line[] { const rw = Math.max(7, ...right.map((k) => NAME[k].length)) const cell = (k: (typeof KEYS)[number], i: number, nameW: number, valW: number): Line => [ [GLYPH[k], STYLE[k]], - [` ${NAME[k].padEnd(nameW)}`, "dim"], + [` ${NAME[k].padEnd(nameW)} `, "dim"], [`${dur(t[k]).padStart(valW)}`, "bold"], [(pct[i] === 0 && t[k] > 0 ? "<1%" : `${pct[i]}%`).padStart(4), "dim"], ]