diff --git a/CHANGELOG.md b/CHANGELOG.md index 1042afe..dad77fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## [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), 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. +- `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..a408b62 --- /dev/null +++ b/dialog.ts @@ -0,0 +1,700 @@ +// 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")}` +} + +/** 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. */ +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] : [])) +} + +// The mockup's glyphs: parts of a bar differ by shade as well as colour, so +// they still read apart where colour does not. +// Squares (chosen 2026-09-25): centred, about half a row tall, with a gap +// between cells, so a bar clears the lines above and below it. Parts are +// told apart by colour. +const SQ = "■" +const GLYPH = { waiting: SQ, generating: SQ, tools: SQ, subagents: SQ, compaction: SQ, other: SQ } 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", +} + +// ---- 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"], + [" switch 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 +} + +// ---- 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 +} + +// ---- 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, 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"], + [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. */ +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(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 - 12 + 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) 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) { + 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] + } + // 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] + else line.push([glyph, style]) + } + out.push(line) + }) + 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: 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(heading("Steps", w, String(d.steps.length))) + out.push([ + [" # 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)}${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(10)} ${t.seconds !== undefined ? dur(t.seconds) : t.status}`, ""]] + : 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 - 2)) out.push([[` ${l}`, "dim"]]) + out.push([]) + } + + // Tokens: generated with its split; prompt and context with a bar each. + const t = d.tokens + const produced = t.output + t.reasoning + 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) { + 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) tokenRows.push({ label: "", value: n0(t.cacheWrite), qual: "written to cache" }) + if (d.context) { + 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) tokenRows.push({ label: "cost", value: `$${d.cost.toFixed(4)}` }) + 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) { + out.push(heading(`${ENGINE_MARK} ${d.engine}`, w, "measured by the engine")) + 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)) + 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 +} + +/** + * 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 + * 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 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) + } + // 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, value: [[`${depths.join("/")}%`, "bold"], [" by depth", "dim"]] } + } + const [first, ...rest] = values + 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 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 = first + } else cur = [...cur, ...next] + } + if (cur.length > 0) out.push(cur) + return out +} + +// ---- Session ------------------------------------------------------------------------ + +/** 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(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)}` + : "" + out.push(row("average", n1(s.genTokS), spread)) + } + const spark = sparkline(f.rates.slice(0, 24).reverse()) + 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(row("ttft", `${(quantile(f.ttfts, 0.5) as number).toFixed(2)}s`, more)) + } + out.push([]) + + 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) { + 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`)) + for (const t of shown) { + out.push([ + [` ${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"], + ]) + } + if (f.tools.length > shown.length) out.push([[` and ${f.tools.length - shown.length} more`, "dim"]]) + out.push([]) + } + + 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([]) + + const gen = f.tokens.output + f.tokens.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 + 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), w)) + + 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([], heading(`${ENGINE_MARK} Engine averages`, 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)) + 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, 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[], + 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) + 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. + 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: 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"]) }, + ] + 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)) { + 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]}`) + } + if (notes.length > 0) { + out.push([]) + for (const l of wrap(notes.slice(0, 6).join(" · "), w - 2)) out.push([[` ${l}`, "dim"]]) + } + 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))}` +} + +// ---- vertical spacing ------------------------------------------------------------------ + +/** + * 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, 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 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] + if (isHeading(l) && next !== undefined && next.length > 0) out.push([]) + }) + 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..e081ec7 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,39 @@ 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 }> + /** 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 }> } +} + +/** 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 +320,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 +347,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 +357,47 @@ 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), + toolsRecorded: turns.filter((t) => t.tools !== undefined).length, + 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..2bfd4bb 100644 --- a/tui.tsx +++ b/tui.tsx @@ -33,9 +33,25 @@ 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, + spaced, + type Spacing, + 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" @@ -163,6 +179,9 @@ interface UiState { // ---- entry ------------------------------------------------------------------ +/** 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" @@ -310,123 +329,272 @@ 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; w: number }>( "details", - { initial: { tab: "turn", 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. + // 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 + } + // 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) { + case "dim": + c = subduedColor() + break + case "wait": + c = shade("gray", 3) ?? subduedColor() + break + case "gen": + case "accent": + c = shade("blue", 6) ?? (themeColor("text.action.primary") as Color | undefined) + break + case "tool": + c = shade("cyan", 6) ?? shade("green", 6) + break + case "sub": + c = shade("purple", 6) + break + case "comp": + c = shade("red", 5) + break + case "engine": + c = shade("orange", 7) ?? shade("yellow", 7) + break + case "rule": + 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) => ( + + {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[] => { + 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") { + 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. + // 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, 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) + // 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(() => { + // 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) + return + } + if (inner !== undefined) { + setDetails((d) => { + // 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`) + 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) + // 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)} > - - Heads Up - - {wide() ? "" : ` · ${details.tab === "turn" ? "[turn] session" : "turn [session]"} tab switches`} - - + + {drawLine(tabsLine(details.tab, note(), details.w))} { @@ -434,23 +602,13 @@ 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, details.w))} + ) }, @@ -460,6 +618,8 @@ export default Plugin.define({ dbg("details: closed") } ) + // 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 => { @@ -1297,7 +1457,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 @@ -1700,9 +1861,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 +1878,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 +1938,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 › )