From ede66dcf0151eb6cc7fe39c58b00f877b05738b5 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Thu, 24 Sep 2026 18:51:13 -0700 Subject: [PATCH 01/12] Stub the details dialog to measure it: size, scrolling, keys, tab switch --- tui.tsx | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/tui.tsx b/tui.tsx index 50f9fa1..62ec07b 100644 --- a/tui.tsx +++ b/tui.tsx @@ -297,6 +297,120 @@ export default Plugin.define({ ) } + // ---- the details dialog (stub) ------------------------------------------ + // A full-detail view opened from the sidebar. This is the measuring stub: + // real figures come later. It checks what the types promise but 2.0.12 + // has not shown yet -- that ui.dialog.show draws our JSX at xlarge, how + // wide that is, that a scrollbox inside it scrolls by wheel and by keys, + // and that a keymap layer inside the dialog can take tab without the + // prompt behind it seeing it. + const [details, setDetails] = ctx.storage.memory<{ tab: "turn" | "session" }>("details", { + initial: { tab: "turn" }, + }) + /** Below this many terminal columns the two columns are one, switched by tab. */ + const TWO_COLUMN_MIN = 110 + const DETAIL_COL = 46 + const openDetails = (sessionID: string | undefined): void => { + 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"] } + // Filler, so the body is taller than any terminal and has to scroll. + const filler = (tag: string): Array => + Array.from({ length: 40 }, (_, i) => [i === 0 ? "stub" : "", `${tag} row ${i + 1}`] as const) + let scroll: { scrollBy?: (d: number) => void; width?: number; height?: number; focus?: () => void } | undefined + let root: { width?: number; height?: number } | undefined + ctx.ui.dialog.show( + () => { + const subdued = subduedColor() + const wide = ctx.renderer.terminalWidth >= TWO_COLUMN_MIN + const pageRows = Math.max(4, ctx.renderer.terminalHeight - 14) + ctx.keymap.layer(() => ({ + mode: "global", + priority: 100, + commands: [ + { + title: "Switch turn / session", + bind: "tab", + run: () => { + setDetails((d) => { + d.tab = d.tab === "turn" ? "session" : "turn" + }) + 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) }, + ], + })) + const column = (title: string, view: TurnView, tag: string) => ( + + + {title} + + + {[...view.rows, ...filler(tag)].map(([label, value]) => ( + + {label.padEnd(LABEL_WIDTH)} + {value} + + ))} + + ) + setTimeout(() => { + dbg( + `details: wide ${wide}; dialog ${root?.width ?? "?"}x${root?.height ?? "?"}; ` + + `scrollbox ${scroll?.width ?? "?"}x${scroll?.height ?? "?"}` + ) + }, 300) + return ( + (root = r as typeof root)}> + + Heads Up + + {wide ? " · details stub" : ` · ${details.tab === "turn" ? "[turn] session" : "turn [session]"} tab switches`} + + + + { + scroll = r as typeof scroll + scroll?.focus?.() + }} + scrollY + height={pageRows} + > + {wide ? ( + + {column(`Last turn · ${turnView.engine}`, turnView, "turn")} + {column(sessView.engine, sessView, "session")} + + ) : details.tab === "turn" ? ( + column(`Last turn · ${turnView.engine}`, turnView, "turn") + ) : ( + column(sessView.engine, sessView, "session") + )} + + + + {"wheel or ↑↓ pgup pgdn scroll · esc closes"} + + + ) + }, + () => dbg("details: closed") + ) + ctx.ui.dialog.set({ size: "xlarge" }) + } + const currentSession = (): string | undefined => { + const r = ctx.ui.router.current() + return r.type === "session" ? r.sessionID : undefined + } + const show = (text: string, sessionID: string, key: string): void => { setPanel((d) => { const next = setLine(d, sessionID, text, key) @@ -1175,6 +1289,18 @@ export default Plugin.define({ toggleCollapsed() }, }, + { + id: "headsup.details", + title: "Show Inference Details", + description: "Open the full per-turn and session telemetry", + group: "opencode-headsup", + bind: "ctrl+shift+d", + palette: true, + slash: { name: "headsup" }, + run: () => { + openDetails(currentSession()) + }, + }, { id: "headsup.panel", title: "Show Inference History", @@ -1240,6 +1366,9 @@ export default Plugin.define({ {drawBox(turnView, suffix, !ui.collapsed, toggleCollapsed, true)} {summary ? drawBox(sessionView(summary), "", ui.sessionOpen === true, toggleSession, false) : null} + openDetails(input.sessionID)}> + details › + ) }, From 3712f48844421a5b419a45942f5ab4809c77dbd9 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Thu, 24 Sep 2026 18:51:21 -0700 Subject: [PATCH 02/12] Prefer text.action.base for the details link colour --- tui.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tui.tsx b/tui.tsx index 62ec07b..85bba02 100644 --- a/tui.tsx +++ b/tui.tsx @@ -1367,7 +1367,7 @@ export default Plugin.define({ {drawBox(turnView, suffix, !ui.collapsed, toggleCollapsed, true)} {summary ? drawBox(sessionView(summary), "", ui.sessionOpen === true, toggleSession, false) : null} openDetails(input.sessionID)}> - details › + details › ) From a4f8fce499240a34179499865ba068645633fa32 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Thu, 24 Sep 2026 19:22:36 -0700 Subject: [PATCH 03/12] Open details on release; pad, centre and re-lay out the dialog on resize --- tui.tsx | 60 ++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/tui.tsx b/tui.tsx index 85bba02..68de981 100644 --- a/tui.tsx +++ b/tui.tsx @@ -304,9 +304,10 @@ export default Plugin.define({ // wide that is, that a scrollbox inside it scrolls by wheel and by keys, // and that a keymap layer inside the dialog can take tab without the // prompt behind it seeing it. - const [details, setDetails] = ctx.storage.memory<{ tab: "turn" | "session" }>("details", { - initial: { tab: "turn" }, - }) + const [details, setDetails] = ctx.storage.memory<{ tab: "turn" | "session"; cols: number; rows: number }>( + "details", + { initial: { tab: "turn", cols: 0, rows: 0 } } + ) /** Below this many terminal columns the two columns are one, switched by tab. */ const TWO_COLUMN_MIN = 110 const DETAIL_COL = 46 @@ -322,11 +323,27 @@ export default Plugin.define({ Array.from({ length: 40 }, (_, i) => [i === 0 ? "stub" : "", `${tag} row ${i + 1}`] as const) 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 => { + 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) ctx.ui.dialog.show( () => { const subdued = subduedColor() - const wide = ctx.renderer.terminalWidth >= TWO_COLUMN_MIN - const pageRows = Math.max(4, ctx.renderer.terminalHeight - 14) + 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) ctx.keymap.layer(() => ({ mode: "global", priority: 100, @@ -343,8 +360,8 @@ export default Plugin.define({ }, { 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?.(pageRows()) }, + { title: "Page up", bind: "pageup", run: () => scroll?.scrollBy?.(-pageRows()) }, ], })) const column = (title: string, view: TurnView, tag: string) => ( @@ -363,16 +380,23 @@ export default Plugin.define({ ) setTimeout(() => { dbg( - `details: wide ${wide}; dialog ${root?.width ?? "?"}x${root?.height ?? "?"}; ` + + `details: wide ${wide()}; dialog ${root?.width ?? "?"}x${root?.height ?? "?"}; ` + `scrollbox ${scroll?.width ?? "?"}x${scroll?.height ?? "?"}` ) }, 300) return ( - (root = r as typeof root)}> + (root = r as typeof root)} + > Heads Up - {wide ? " · details stub" : ` · ${details.tab === "turn" ? "[turn] session" : "turn [session]"} tab switches`} + {wide() ? " · details stub" : ` · ${details.tab === "turn" ? "[turn] session" : "turn [session]"} tab switches`} @@ -382,9 +406,9 @@ export default Plugin.define({ scroll?.focus?.() }} scrollY - height={pageRows} + height={pageRows()} > - {wide ? ( + {wide() ? ( {column(`Last turn · ${turnView.engine}`, turnView, "turn")} {column(sessView.engine, sessView, "session")} @@ -402,9 +426,12 @@ export default Plugin.define({ ) }, - () => dbg("details: closed") + () => { + ctx.renderer.off("resize", onResize) + dbg("details: closed") + } ) - ctx.ui.dialog.set({ size: "xlarge" }) + ctx.ui.dialog.set({ size: "xlarge", centered: true }) } const currentSession = (): string | undefined => { const r = ctx.ui.router.current() @@ -1366,7 +1393,10 @@ export default Plugin.define({ {drawBox(turnView, suffix, !ui.collapsed, toggleCollapsed, true)} {summary ? drawBox(sessionView(summary), "", ui.sessionOpen === true, toggleSession, false) : null} - openDetails(input.sessionID)}> + {/* On release, not press: opened on press, the dialog's backdrop + took the release as a click outside and closed it at once + (measured: open and close 1ms apart). */} + openDetails(input.sessionID)}> details › From 7514030d24fda7f8ac4de8364455d62073c130ee Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Thu, 24 Sep 2026 20:59:37 -0700 Subject: [PATCH 04/12] Toggle the details dialog closed when its key is pressed again --- tui.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tui.tsx b/tui.tsx index 4b172a6..43c1e2b 100644 --- a/tui.tsx +++ b/tui.tsx @@ -311,7 +311,15 @@ export default Plugin.define({ /** 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 => { + if (detailsOpen) { + dbg("details: toggle closed") + ctx.ui.dialog.clear() + 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 = @@ -337,6 +345,7 @@ export default Plugin.define({ sized(cols, rows) } ctx.renderer.on("resize", onResize) + detailsOpen = true ctx.ui.dialog.show( () => { const subdued = subduedColor() @@ -428,6 +437,7 @@ export default Plugin.define({ }, () => { ctx.renderer.off("resize", onResize) + detailsOpen = false dbg("details: closed") } ) From 41f010132216e9ed755eac6ceb2b99fcba163516 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Thu, 24 Sep 2026 21:05:30 -0700 Subject: [PATCH 05/12] Keep each turn's full detail and show it in the dialog's turn column --- detail.ts | 355 +++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- test/detail.test.mjs | 104 +++++++++++++ tui.tsx | 82 ++++++++-- 4 files changed, 528 insertions(+), 16 deletions(-) create mode 100644 detail.ts create mode 100644 test/detail.test.mjs diff --git a/detail.ts b/detail.ts new file mode 100644 index 0000000..acf7306 --- /dev/null +++ b/detail.ts @@ -0,0 +1,355 @@ +// A turn's full detail, for the details dialog. +// +// Pure, like history.ts and session.ts. The sidebar shows a handful of rows; +// the adapters and the universal layer reduce each turn to those and the rest +// was discarded. This keeps what the dialog needs, built from the turn's +// assistant messages (one per step) and the plugin's own stream marks: +// +// - every step: tokens, streaming time, time to first token, finish reason, +// its tool calls and how long each ran, its last retry's reason; +// - the turn's time split into waiting, generating, tools, sub-agents and +// other, which adds up to the turn's total; +// - tokens in all five buckets. +// +// Everything here is OpenCode's own data. Engine figures travel separately, +// as rows the adapter produced, so each can be marked with its source. + +import type { SessionMessageAssistant } from "@opencode-ai/client" +import type { Turn } from "./universal" +import type { Row } from "./rows" + +export interface ToolDetail { + name: string + status: string + /** Running time: from the tool starting to run to it completing. */ + seconds?: number + /** Epoch ms, for merging overlapping calls. */ + start?: number + end?: number + error?: string +} + +export interface StepDetail { + id: string + output: number + reasoning: number + /** Prompt tokens not served from cache. */ + input: number + cacheRead: number + cacheWrite: number + finish?: string + /** Seconds from the step's request to its first token, retries included. */ + ttftS?: number + /** Seconds from its first token to its last. */ + streamS?: number + tools: ToolDetail[] + /** Attempts beyond the first. */ + retries: number + /** The last retry's reason, as OpenCode recorded it. */ + retryReason?: string + error?: string +} + +export interface TimeSplit { + waiting: number + generating: number + tools: number + subagents: number + other: number +} + +export interface TurnDetail { + sessionID: string + provider: string + model: string + /** The engine's display name, as the sidebar box heads it. */ + engine: string + /** Epoch ms when the turn was recorded. */ + at: number + outcome?: "interrupted" | "failed" + totalS?: number + /** Seconds, adding up to `totalS`; absent when the total is unknown. */ + time?: TimeSplit + steps: StepDetail[] + tokens: { output: number; reasoning: number; input: number; cacheRead: number; cacheWrite: number } + /** Context in use after the turn: the last step's prompt plus its output. */ + context?: { used: number; limit?: number } + cost?: number + /** Figures measured by the engine, as the adapter rendered them. */ + engineRows: Row[] + /** Why engine figures are missing, when they are. */ + engineNote?: string[] + subagents?: { count: number; tokens: number; spanS: number; cost?: number; steps?: number } +} + +/** + * Tools that run a sub-agent. Their time is the sub-agent's, counted under + * sub-agents rather than tools. OpenCode's name for it is `task`. + */ +export const SUBAGENT_TOOLS: ReadonlySet = new Set(["task", "agent", "subagent"]) + +type ToolPart = { + type?: string + name?: string + state?: { status?: string; error?: { message?: string } } + time?: { created?: number; ran?: number; completed?: number } +} + +function toolsOf(m: SessionMessageAssistant): ToolDetail[] { + const out: ToolDetail[] = [] + for (const part of (m.content ?? []) as ToolPart[]) { + if (part?.type !== "tool") continue + const start = part.time?.ran ?? part.time?.created + const end = part.time?.completed + out.push({ + name: part.name ?? "?", + status: part.state?.status ?? "?", + start, + end, + seconds: start !== undefined && end !== undefined && end >= start ? (end - start) / 1000 : undefined, + error: part.state?.status === "error" ? part.state.error?.message : undefined, + }) + } + return out +} + +/** Total length of a set of intervals, overlaps counted once. */ +export function unionSeconds(spans: ReadonlyArray): number { + const sorted = spans.filter(([a, b]) => b > a).sort((x, y) => x[0] - y[0]) + let total = 0 + let curStart = -Infinity + let curEnd = -Infinity + for (const [a, b] of sorted) { + if (a > curEnd) { + if (curEnd > curStart) total += curEnd - curStart + curStart = a + curEnd = b + } else if (b > curEnd) { + curEnd = b + } + } + if (curEnd > curStart) total += curEnd - curStart + return total / 1000 +} + +/** + * The detail of one turn from its steps (oldest first) and the plugin's + * stream marks. `totalS` is the turn's real elapsed time, as the sidebar + * shows it; the split's `other` is what the named parts leave of it. + */ +export function buildTurnDetail( + steps: readonly SessionMessageAssistant[], + marks: ReadonlyMap, + base: { + sessionID: string + provider: string + model: string + engine: string + at: number + totalS?: number + outcome?: "interrupted" | "failed" + contextLimit?: number + engineRows?: Row[] + engineNote?: string[] + subagents?: TurnDetail["subagents"] + } +): TurnDetail { + const tokens = { output: 0, reasoning: 0, input: 0, cacheRead: 0, cacheWrite: 0 } + let cost = 0 + let sawCost = false + let waitMs = 0 + let streamMs = 0 + const toolSpans: Array<[number, number]> = [] + const subSpans: Array<[number, number]> = [] + const out: StepDetail[] = [] + + for (const m of steps) { + const t = marks.get(m.id) + const s: StepDetail = { + id: m.id, + output: m.tokens?.output ?? 0, + reasoning: m.tokens?.reasoning ?? 0, + input: m.tokens?.input ?? 0, + cacheRead: m.tokens?.cache?.read ?? 0, + cacheWrite: m.tokens?.cache?.write ?? 0, + finish: m.finish, + tools: toolsOf(m), + retries: Math.max(0, (t?.attempts ?? 1) - 1), + retryReason: m.retry?.error?.message, + error: m.error?.message, + } + if (t?.firstAt !== undefined && t.firstAt > m.time.created) { + s.ttftS = (t.firstAt - m.time.created) / 1000 + waitMs += t.firstAt - m.time.created + } + if (t?.firstAt !== undefined && t.lastAt !== undefined && t.lastAt > t.firstAt) { + s.streamS = (t.lastAt - t.firstAt) / 1000 + streamMs += t.lastAt - t.firstAt + } + for (const tool of s.tools) { + if (tool.start === undefined || tool.end === undefined) continue + ;(SUBAGENT_TOOLS.has(tool.name) ? subSpans : toolSpans).push([tool.start, tool.end]) + } + tokens.output += s.output + tokens.reasoning += s.reasoning + tokens.input += s.input + tokens.cacheRead += s.cacheRead + tokens.cacheWrite += s.cacheWrite + if (typeof m.cost === "number") { + cost += m.cost + sawCost = true + } + out.push(s) + } + + let time: TimeSplit | undefined + if (base.totalS !== undefined && base.totalS > 0) { + const waiting = waitMs / 1000 + const generating = streamMs / 1000 + // A sub-agent's time is its tool call's; without one (a sub-agent the + // steps don't show), the roll-up's span stands in. + const subagents = subSpans.length > 0 ? unionSeconds(subSpans) : (base.subagents?.spanS ?? 0) + // Tool time while a sub-agent was also running is already in the + // sub-agent's time; counted in both, the parts would exceed the total. + const tools = unionSeconds([...toolSpans, ...subSpans]) - unionSeconds(subSpans) + time = { + waiting, + generating, + tools, + subagents, + other: Math.max(0, base.totalS - waiting - generating - tools - subagents), + } + } + + const last = steps[steps.length - 1] + const used = + last !== undefined + ? (last.tokens?.input ?? 0) + + (last.tokens?.cache?.read ?? 0) + + (last.tokens?.cache?.write ?? 0) + + (last.tokens?.output ?? 0) + + (last.tokens?.reasoning ?? 0) + : 0 + + return { + sessionID: base.sessionID, + provider: base.provider, + model: base.model, + engine: base.engine, + at: base.at, + outcome: base.outcome, + totalS: base.totalS, + time, + steps: out, + tokens, + context: used > 0 ? { used, limit: base.contextLimit } : undefined, + cost: sawCost && cost > 0 ? cost : undefined, + engineRows: base.engineRows ?? [], + engineNote: base.engineNote, + subagents: base.subagents, + } +} + +// ---- as text, for the dialog ------------------------------------------------- + +/** 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. + * Plain rounding of 22/10/47/7/3/12-ish shares gives 101. + */ +export function percents(parts: readonly number[]): number[] { + const total = parts.reduce((a, b) => a + b, 0) + if (total <= 0) return parts.map(() => 0) + const raw = parts.map((p) => (p / total) * 100) + const floor = raw.map(Math.floor) + let left = 100 - floor.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 + floor[i] = (floor[i] as number) + 1 + left-- + } + 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], + ["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"}${s.retryReason ? `: ${s.retryReason}` : ""}`.slice(0, 46)) + }) + out.push({ title: `Steps · ${d.steps.length}`, lines }) + } + 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 || d.engineNote) { + out.push({ title: `Engine · ${d.engine}`, rows: d.engineRows, lines: d.engineNote }) + } + 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/package.json b/package.json index dd7294d..1c90dd5 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,11 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test": "npm run typecheck && bun test/format.test.mjs && bun test/http.test.mjs && bun test/prometheus-text.test.mjs && bun test/universal.test.mjs && bun test/mtplx.test.mjs && bun test/omlx.test.mjs && bun test/llamacpp.test.mjs && bun test/prometheus.test.mjs && bun test/koboldcpp.test.mjs && bun test/splash.test.mjs && bun test/mlxserve.test.mjs && bun test/history.test.mjs && bun test/panels.test.mjs && bun test/session.test.mjs && bun test/rows.test.mjs && bun test/references.test.mjs" + "test": "npm run typecheck && bun test/format.test.mjs && bun test/http.test.mjs && bun test/prometheus-text.test.mjs && bun test/universal.test.mjs && bun test/mtplx.test.mjs && bun test/omlx.test.mjs && bun test/llamacpp.test.mjs && bun test/prometheus.test.mjs && bun test/koboldcpp.test.mjs && bun test/splash.test.mjs && bun test/mlxserve.test.mjs && bun test/history.test.mjs && bun test/panels.test.mjs && bun test/session.test.mjs && bun test/rows.test.mjs && bun test/detail.test.mjs && bun test/references.test.mjs" }, "files": [ "tui.tsx", + "detail.ts", "format.ts", "http.ts", "prometheus-text.ts", diff --git a/test/detail.test.mjs b/test/detail.test.mjs new file mode 100644 index 0000000..2393ae0 --- /dev/null +++ b/test/detail.test.mjs @@ -0,0 +1,104 @@ +// 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" + +let passed = 0 +function test(name, fn) { + try { + fn() + passed++ + console.log(" ok ", name) + } catch (e) { + console.log(" FAIL", name, "\n ", e.message) + process.exitCode = 1 + } +} + +const T0 = 1_000_000 +const tool = (name, ran, completed, status = "completed") => ({ type: "tool", name, state: { status }, time: { created: ran, ran, completed } }) +const step = (id, created, over = {}) => ({ + type: "assistant", + id, + time: { created }, + tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 1000, write: 0 } }, + content: [], + ...over, +}) +// Step 1: request at T0, first token 2s later, streams 1s, then `read` runs 1s. +// Step 2: request at T0+4s, first token 1s later, streams 2s, `task` (a +// sub-agent) runs 10s, and `bash` runs alongside it for 3s. +// Step 3: request at T0+18s, first token 0.5s later, streams 1.5s, stops. +const steps = [ + step("a", T0, { finish: "tool-calls", content: [tool("read", T0 + 3_000, T0 + 4_000)] }), + step("b", T0 + 4_000, { finish: "tool-calls", content: [tool("task", T0 + 7_000, T0 + 17_000), tool("bash", T0 + 7_000, T0 + 10_000)] }), + step("c", T0 + 18_000, { finish: "stop", tokens: { input: 5, output: 200, reasoning: 0, cache: { read: 1200, write: 30 } } }), +] +const marks = new Map([ + ["a", { firstAt: T0 + 2_000, lastAt: T0 + 3_000 }], + ["b", { firstAt: T0 + 5_000, lastAt: T0 + 7_000, attempts: 2 }], + ["c", { firstAt: T0 + 18_500, lastAt: T0 + 20_000 }], +]) +const base = { sessionID: "s", provider: "mtplx", model: "m", engine: "MTPLX", at: T0, totalS: 20, contextLimit: 10_000 } + +test("overlapping intervals count once", () => { + assert.equal(unionSeconds([[0, 3000], [1000, 4000], [6000, 7000]]), 5) + assert.equal(unionSeconds([]), 0) +}) + +test("shares add up to exactly 100", () => { + const p = percents([21.96, 9.72, 47.01, 6.94, 2.56, 11.81]) + assert.equal(p.reduce((a, b) => a + b, 0), 100) + assert.deepEqual(percents([0, 0]), [0, 0]) +}) + +test("the time split names each part and adds up to the total", () => { + const d = buildTurnDetail(steps, marks, base) + assert.equal(d.time.waiting, 2 + 1 + 0.5) + assert.equal(d.time.generating, 1 + 2 + 1.5) + // read's 1s. bash ran inside the sub-agent's 10s, so it is not counted + // again: the parts would otherwise add up to more than the turn. + assert.equal(d.time.tools, 1) + assert.equal(d.time.subagents, 10) + const sum = d.time.waiting + d.time.generating + d.time.tools + d.time.subagents + d.time.other + assert.ok(Math.abs(sum - 20) < 1e-9, String(sum)) +}) + +test("every step keeps its tools, their times, and its retries", () => { + const d = buildTurnDetail(steps, marks, base) + assert.deepEqual(d.steps[1].tools.map((t) => [t.name, t.seconds]), [["task", 10], ["bash", 3]]) + assert.equal(d.steps[1].retries, 1) + assert.equal(d.steps[0].ttftS, 2) + assert.equal(d.steps[2].streamS, 1.5) +}) + +test("tokens are kept in all five buckets", () => { + const d = buildTurnDetail(steps, marks, base) + assert.deepEqual(d.tokens, { output: 300, reasoning: 20, input: 205, cacheRead: 3200, cacheWrite: 30 }) +}) + +test("context is the last step's prompt plus its output", () => { + const d = buildTurnDetail(steps, marks, base) + assert.deepEqual(d.context, { used: 5 + 1200 + 30 + 200, limit: 10_000 }) +}) + +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")) +}) + +console.log(`\n${passed} passed`) diff --git a/tui.tsx b/tui.tsx index 43c1e2b..00af7ab 100644 --- a/tui.tsx +++ b/tui.tsx @@ -34,6 +34,7 @@ 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, rollupSubagents, subagentRows } from "./session" +import { buildTurnDetail, turnSections, type TurnDetail, type Section } from "./detail" import { fetchMtplxLatest, mtplxView, combineMtplxSteps, type MtplxLatest } from "./adapters/mtplx" import { fetchOmlxSample, omlxView, omlxIsThisTurn, type OmlxSample } from "./adapters/omlx" @@ -185,6 +186,11 @@ export default Plugin.define({ // What the sidebar shows, one line per session (see panels.ts). Reactive: // writing it re-renders the slot. Memory-scoped, so it dies with the TUI. + // Each session's last turn in full, for the details dialog. Memory, like + // the panels: the dialog describes this run; history keeps the summary. + const [turnDetail, setTurnDetail] = ctx.storage.memory<{ bySession: Record }>("turnDetail", { + initial: { bySession: {} }, + }) const [panel, setPanel] = ctx.storage.memory("panels", { initial: emptyPanels(), }) @@ -326,9 +332,6 @@ export default Plugin.define({ 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"] } - // Filler, so the body is taller than any terminal and has to scroll. - const filler = (tag: string): Array => - Array.from({ length: 40 }, (_, i) => [i === 0 ? "stub" : "", `${tag} row ${i + 1}`] as const) 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 @@ -373,20 +376,36 @@ export default Plugin.define({ { title: "Page up", bind: "pageup", run: () => scroll?.scrollBy?.(-pageRows()) }, ], })) - const column = (title: string, view: TurnView, tag: string) => ( + const column = (title: string, sections: Section[]) => ( {title} - - {[...view.rows, ...filler(tag)].map(([label, value]) => ( - - {label.padEnd(LABEL_WIDTH)} - {value} - + {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[] => [{ title: "Summary", rows: sessView.rows, lines: sessView.notes }] setTimeout(() => { dbg( `details: wide ${wide()}; dialog ${root?.width ?? "?"}x${root?.height ?? "?"}; ` + @@ -405,7 +424,7 @@ export default Plugin.define({ Heads Up - {wide() ? " · details stub" : ` · ${details.tab === "turn" ? "[turn] session" : "turn [session]"} tab switches`} + {wide() ? "" : ` · ${details.tab === "turn" ? "[turn] session" : "turn [session]"} tab switches`} @@ -419,13 +438,13 @@ export default Plugin.define({ > {wide() ? ( - {column(`Last turn · ${turnView.engine}`, turnView, "turn")} - {column(sessView.engine, sessView, "session")} + {column(turnTitle, turnCol())} + {column(sessView.engine, sessCol())} ) : details.tab === "turn" ? ( - column(`Last turn · ${turnView.engine}`, turnView, "turn") + column(turnTitle, turnCol()) ) : ( - column(sessView.engine, sessView, "session") + column(sessView.engine, sessCol()) )} @@ -1080,6 +1099,39 @@ export default Plugin.define({ else if (tier2.sharedWindow) line.notes.push("engine data skipped:", "overlapping requests") } + // The turn in full, for the dialog. Built before the stream marks are + // released below, and from the rows before sub-agent rows join them: + // those are OpenCode's, kept in the detail's own sub-agent section. + try { + const detail = buildTurnDetail(steps, turns, { + sessionID, + provider, + model, + engine: engineLabel(provider), + at: Date.now(), + totalS: turnRate(0, info, turn).total, + outcome: opts.outcome, + contextLimit: contextLimitFor(provider, model), + engineRows: enriched ? [...line.rows] : [], + engineNote: enriched ? undefined : [...line.notes], + subagents, + }) + dbg( + `detail: ${detail.steps.length} step(s); tools [${detail.steps.flatMap((st) => st.tools.map((t) => `${t.name}:${t.seconds?.toFixed(2) ?? t.status}`)).join(", ")}]` + + (detail.time ? `; split ${Object.entries(detail.time).map(([k, v]) => `${k} ${v.toFixed(2)}`).join(", ")} of ${detail.totalS?.toFixed(2)}` : "") + ) + setTurnDetail((d) => { + d.bySession[sessionID] = detail + const keys = Object.keys(d.bySession) + if (keys.length > 32) { + const oldest = keys.sort((a, b) => (d.bySession[a]?.at ?? 0) - (d.bySession[b]?.at ?? 0))[0] + if (oldest) delete d.bySession[oldest] + } + }) + } catch (e: unknown) { + dbg(`detail threw: ${String(e)}`) + } + if (subagents) line.rows.push(...subagentRows(subagents)) // Keep the turn for the drill-down. Every figure below is OpenCode's own, From ad63fea5ec902c964850aadc871167ed55a6d8c9 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 00:12:42 -0700 Subject: [PATCH 06/12] Show only engine-measured figures in the dialog's engine section, marked, with per-step rates and full retry reasons --- adapters/koboldcpp.ts | 16 ++++++++++-- adapters/llamacpp.ts | 9 +++++-- adapters/mlxserve.ts | 18 +++++++++++-- adapters/mtplx.ts | 19 +++++++++++++- adapters/omlx.ts | 16 +++++++++++- adapters/prometheus.ts | 22 ++++++++++++++-- adapters/splash.ts | 12 +++++++-- detail.ts | 58 +++++++++++++++++++++++++++++++++++++++--- rows.ts | 11 ++++++++ test/detail.test.mjs | 27 ++++++++++++++++++++ test/mtplx.test.mjs | 13 ++++++++++ tui.tsx | 28 +++++++++++++++++--- 12 files changed, 229 insertions(+), 20 deletions(-) diff --git a/adapters/koboldcpp.ts b/adapters/koboldcpp.ts index 568337a..a1395a9 100644 --- a/adapters/koboldcpp.ts +++ b/adapters/koboldcpp.ts @@ -17,7 +17,7 @@ import { httpJson, type HttpOptions } from "../http" import { nn, ni } from "../format" -import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows" +import { rowsOf, timeRows, viewText, nt, phase, type Row, type TurnView } from "../rows" /** * The fields of /api/extra/perf this plugin reads. The endpoint returns more * (image/TTS/transcription counters, horde bookkeeping, seeds) that describe @@ -252,7 +252,19 @@ export function koboldView( t.generationsInWindow !== undefined && t.generationsInWindow > 1 ? [`${ni(t.generationsInWindow)} generations this turn`, "(last shown only)"] : [] - return { engine: "KoboldCpp", rows, notes, key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined } + const detail: Row[] = [ + ...rowsOf("speed", [t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : ""]), + ...rowsOf("prefill", [t.prefillTokS !== undefined ? `${ni(t.prefillTokS)} tok/s` : "", phase(t.promptTokens, t.prefillS)]), + ["decode", phase(t.completionTokens, t.decodeS)], + ...rowsOf("draft", [t.draftAcceptRate !== undefined ? `${ni(t.draftAcceptRate * 100)}% accepted` : ""]), + ] + return { + engine: "KoboldCpp", + rows, + notes, + key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined, + detail, + } } /** The view as text; kept for tests that look for a figure. */ diff --git a/adapters/llamacpp.ts b/adapters/llamacpp.ts index 17a5013..7dcfefc 100644 --- a/adapters/llamacpp.ts +++ b/adapters/llamacpp.ts @@ -22,7 +22,7 @@ import { sumLabeledMetric } from "../prometheus-text" import { httpText, type HttpOptions } from "../http" import { nn, ni } from "../format" -import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows" +import { rowsOf, timeRows, viewText, nt, phase, type Row, type TurnView } from "../rows" export interface LlamaCppCounters { promptTokens: number @@ -142,7 +142,12 @@ export function llamaCppView( ...rowsOf("tokens", [nt(t.completionTokens), host.includesSubagents ? "incl. sub-agents" : ""]), ...timeRows(host.total ?? t.decodeS + t.prefillS, host.retries), ] - return { engine: label, rows, notes: [], key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined } + const detail: Row[] = [ + ...rowsOf("speed", [t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : ""]), + ...rowsOf("prefill", [t.prefillTokS !== undefined ? `${ni(t.prefillTokS)} tok/s` : "", phase(t.promptTokens, t.prefillS)]), + ["decode", phase(t.completionTokens, t.decodeS)], + ] + return { engine: label, rows, notes: [], key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined, detail } } /** The view as text; kept for tests that look for a figure. */ diff --git a/adapters/mlxserve.ts b/adapters/mlxserve.ts index 4b7e3f3..8164c3b 100644 --- a/adapters/mlxserve.ts +++ b/adapters/mlxserve.ts @@ -23,7 +23,7 @@ import { httpJson, type HttpOptions } from "../http" import { nn, ni } from "../format" -import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows" +import { rowsOf, timeRows, viewText, nt, phase, type Row, type TurnView } from "../rows" /** One record from /v1/metrics/requests, as the server names its fields. */ export interface MlxServeRequest { @@ -295,7 +295,21 @@ export function mlxServeView( // as a tenfold slowdown rather than a one-off load. if (t.coldStart) notes.push("cold start (model loaded)") if (t.requests > 1 && t.steps === undefined) notes.push(`${ni(t.requests)} requests this turn`) - return { engine: "mlx-serve", rows, notes, key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined } + const detail: Row[] = [ + ...rowsOf("speed", [t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : ""]), + ...rowsOf("ttft", [t.decodeTokS !== undefined && t.ttft !== undefined ? `${nn(t.ttft, 2)}s` : ""]), + ["tokens", nt(t.completionTokens)], + ...rowsOf("prompt", [t.promptTokens !== undefined ? `${nt(t.promptTokens)} tok` : ""]), + ["request", `${nn(t.totalS, 2)}s`], + ...rowsOf("requests", [t.requests > 1 ? nt(t.requests) : ""]), + ] + return { + engine: "mlx-serve", + rows, + notes, + key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined, + detail, + } } /** The view as text; kept for tests that look for a figure. */ diff --git a/adapters/mtplx.ts b/adapters/mtplx.ts index 7273e8d..369d95e 100644 --- a/adapters/mtplx.ts +++ b/adapters/mtplx.ts @@ -140,7 +140,24 @@ export function mtplxView( rows.push(["accepted", `${l.mean_accept_probability_by_depth.map((p) => Math.round(p * 100)).join("/")}%`]) } } - return { engine: "MTPLX", rows, notes: [], key: decode !== undefined ? `${nn(decode)} tok/s` : undefined } + // The dialog's engine section: MTPLX's own figures only, with the verify + // passes behind the MTP rate and acceptance at every depth. + const depths = Array.isArray(l.mean_accept_probability_by_depth) ? l.mean_accept_probability_by_depth : [] + const detail: Row[] = [ + ...rowsOf("speed", [decode !== undefined ? `${nn(decode)} tok/s` : ""]), + ...rowsOf("ttft", [ttft !== undefined ? `${nn(ttft, 2)}s` : ""]), + ...rowsOf("prefill", [prefill !== undefined ? `${ni(prefill)} tok/s` : ""]), + ...rowsOf("tokens", [completion !== undefined ? nt(completion) : ""]), + ...rowsOf("request", [num(l.request_elapsed_s) !== undefined ? `${nn(num(l.request_elapsed_s) as number, 2)}s` : ""]), + ...(verify !== undefined && verify > 0 && completion !== undefined + ? rowsOf("MTP", [`${nn(completion / verify, 2)}x`, `${nt(verify)} verify passes`]) + : []), + ...rowsOf( + "accepted", + depths.map((p, i) => `${Math.round(p * 100)}% at depth ${i + 1}`) + ), + ] + return { engine: "MTPLX", rows, notes: [], key: decode !== undefined ? `${nn(decode)} tok/s` : undefined, detail } } /** The view as text; kept for tests that look for a figure. */ diff --git a/adapters/omlx.ts b/adapters/omlx.ts index 5c7ff7c..12caa9f 100644 --- a/adapters/omlx.ts +++ b/adapters/omlx.ts @@ -23,7 +23,7 @@ import { httpJson, type HttpOptions } from "../http" import { nn, ni } from "../format" -import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows" +import { rowsOf, timeRows, viewText, nt, phase, type Row, type TurnView } from "../rows" /** Cumulative counters as this plugin reads them. */ export interface OmlxSample { @@ -132,6 +132,10 @@ export function omlxView( ], notes: [], key: `${nn(now.avgGen)} tok/s avg`, + detail: [ + ["speed", `${nn(now.avgGen)} tok/s (server avg)`], + ["prefill", `${ni(now.avgPrefill)} tok/s (server avg)`], + ], } } @@ -173,6 +177,16 @@ export function omlxView( ], notes: [], key: `${nn(decode)} tok/s${decodeLabel}`, + // The host's rate stands in when oMLX's can't be recovered; it is not + // the engine's, so the engine section leaves it out. + detail: [ + ...rowsOf("speed", [useHostRate ? "" : `${nn(decode)} tok/s${decodeLabel}`]), + ["prefill", `${ni(prefill)} tok/s${prefillLabel}`], + ["tokens", nt(completion)], + ["prompt", `${nt(promptTokens)} tok`], + ...rowsOf("cached", [cached > 0 ? `${nt(cached)} tok` : ""]), + ["requests", nt(now.requests - prev.requests)], + ], } } diff --git a/adapters/prometheus.ts b/adapters/prometheus.ts index ab5633e..71cedb5 100644 --- a/adapters/prometheus.ts +++ b/adapters/prometheus.ts @@ -9,7 +9,7 @@ import { httpText, type HttpOptions } from "../http" import { sumLabeledMetric } from "../prometheus-text" import { nn, ni } from "../format" -import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows" +import { rowsOf, timeRows, viewText, nt, phase, type Row, type TurnView } from "../rows" export interface PromSpec { prefix: string promptTokens: string @@ -346,7 +346,25 @@ export function promView( ["prompt", nt(diff.promptTokens)], ...rowsOf("cached", [diff.cachedTokens > 0 ? nt(diff.cachedTokens) : ""]), ] - return { engine: label, rows, notes: [], key: decodeTokS !== undefined ? `${nn(decodeTokS)} tok/s${overall}` : undefined } + // The engine's own figures only: its rate and TTFT for a single request + // (over several, the counters give only means, and the host's stand in). + const detail: Row[] = [ + ...rowsOf("speed", [single && diff.decodeTokS !== undefined ? `${nn(diff.decodeTokS)} tok/s` : ""]), + ...rowsOf("ttft", [single && diff.ttft !== undefined ? `${nn(diff.ttft, 2)}s` : ""]), + ...rowsOf("prefill", [single && diff.prefillTokS !== undefined ? `${ni(diff.prefillTokS)} tok/s` : ""]), + ["tokens", nt(diff.completionTokens)], + ["prompt", `${nt(diff.promptTokens)} tok`], + ...rowsOf("cached", [diff.cachedTokens > 0 ? `${nt(diff.cachedTokens)} tok` : ""]), + ...rowsOf("request", [single && diff.durationS !== undefined ? `${nn(diff.durationS, 2)}s` : ""]), + ["requests", nt(ttftCount)], + ] + return { + engine: label, + rows, + notes: [], + key: decodeTokS !== undefined ? `${nn(decodeTokS)} tok/s${overall}` : undefined, + detail, + } } /** The view as text, or null when declined; kept for tests that look for a figure. */ diff --git a/adapters/splash.ts b/adapters/splash.ts index 0cb91f9..b3cdc19 100644 --- a/adapters/splash.ts +++ b/adapters/splash.ts @@ -18,7 +18,7 @@ import { sumLabeledMetric } from "../prometheus-text" import { httpText, type HttpOptions } from "../http" import { nn, ni } from "../format" -import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows" +import { rowsOf, timeRows, viewText, nt, phase, type Row, type TurnView } from "../rows" /** * Names verified against the server's own metrics.py (Splash 1.0), which maps @@ -219,7 +219,15 @@ export function splashView( // Only when a turn spanned several requests nobody accounted for, so the // figures above read as sums rather than as one reply. const notes = t.requests > 1 && host.steps === undefined ? [`${ni(t.requests)} requests this turn`] : [] - return { engine: "Splash", rows, notes, key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined } + const detail: Row[] = [ + ...rowsOf("speed", [t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : ""]), + ...rowsOf("prefill", [t.prefillTokS !== undefined ? `${ni(t.prefillTokS)} tok/s` : "", phase(t.promptTokens, t.prefillS)]), + ["decode", phase(t.completionTokens, t.decodeS)], + ...rowsOf("cached", [t.cachedTokens > 0 ? `${nt(t.cachedTokens)} tok reused` : ""]), + ...rowsOf("draft", [t.draftAcceptRate !== undefined ? `${ni(t.draftAcceptRate * 100)}% accepted` : ""]), + ["requests", nt(t.requests)], + ] + return { engine: "Splash", rows, notes, key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined, detail } } /** The view as text; kept for tests that look for a figure. */ diff --git a/detail.ts b/detail.ts index acf7306..0dd1c84 100644 --- a/detail.ts +++ b/detail.ts @@ -75,8 +75,13 @@ export interface TurnDetail { /** Context in use after the turn: the last step's prompt plus its output. */ context?: { used: number; limit?: number } cost?: number - /** Figures measured by the engine, as the adapter rendered them. */ + /** Figures measured by the engine, and only those. */ engineRows: Row[] + /** + * Per step, the engine's own reading, where the engine is read once per + * step (MTPLX, KoboldCpp). Index-aligned with `steps`. + */ + stepEngine?: Array<{ decodeTokS?: number; prefillTokS?: number; ttftS?: number } | undefined> /** Why engine figures are missing, when they are. */ engineNote?: string[] subagents?: { count: number; tokens: number; spanS: number; cost?: number; steps?: number } @@ -151,6 +156,7 @@ export function buildTurnDetail( contextLimit?: number engineRows?: Row[] engineNote?: string[] + stepEngine?: TurnDetail["stepEngine"] subagents?: TurnDetail["subagents"] } ): TurnDetail { @@ -246,12 +252,33 @@ export function buildTurnDetail( cost: sawCost && cost > 0 ? cost : undefined, engineRows: base.engineRows ?? [], engineNote: base.engineNote, + stepEngine: base.stepEngine, subagents: base.subagents, } } // ---- as text, for the dialog ------------------------------------------------- +/** Marks what the engine measured, as against OpenCode's figures. */ +export const ENGINE_MARK = "◆" + +/** Words onto lines of at most `width` cells; a longer word is cut. */ +export function wrap(text: string, width: number): string[] { + const out: string[] = [] + let line = "" + for (const word of text.split(/\s+/).filter(Boolean)) { + const w = word.length > width ? word.slice(0, width) : word + if (line && line.length + 1 + w.length > width) { + out.push(line) + line = w + } else { + line = line ? `${line} ${w}` : w + } + } + if (line) out.push(line) + return out +} + /** A titled block of the dialog: labelled rows, or preformatted lines. */ export interface Section { title: string @@ -315,9 +342,14 @@ export function turnSections(d: TurnDetail): Section[] { : "" lines.push((j === 0 ? head : " ".repeat(head.length)) + tail) }) - if (s.retries > 0) lines.push(`${" ".repeat(3)}${s.retries} ${s.retries === 1 ? "retry" : "retries"}${s.retryReason ? `: ${s.retryReason}` : ""}`.slice(0, 46)) + if (s.retries > 0) lines.push(`${" ".repeat(3)}${s.retries} ${s.retries === 1 ? "retry" : "retries"}`) }) 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[] = [ @@ -337,8 +369,26 @@ export function turnSections(d: TurnDetail): Section[] { } if (d.cost !== undefined) rows.push(["cost", `$${d.cost.toFixed(4)}`]) out.push({ title: "Tokens", rows }) - if (d.engineRows.length > 0 || d.engineNote) { - out.push({ title: `Engine · ${d.engine}`, rows: d.engineRows, lines: d.engineNote }) + 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` : "" + }`, + ] + : [] + ) + out.push({ + title: `${ENGINE_MARK} Engine · ${d.engine}`, + rows: d.engineRows, + lines: perStep.length > 1 ? ["", "per step", ...perStep] : undefined, + }) + } else { + out.push({ + title: `Engine · ${d.engine}`, + lines: d.engineNote && d.engineNote.length > 0 ? ["no engine figures:", ...d.engineNote] : ["no engine figures for this turn"], + }) } if (d.subagents) { out.push({ diff --git a/rows.ts b/rows.ts index 8654c03..f3320b3 100644 --- a/rows.ts +++ b/rows.ts @@ -17,6 +17,11 @@ export interface TurnView { notes: string[] /** The one figure a collapsed heading keeps, e.g. `34.4 tok/s`. */ key?: string + /** + * For the details dialog: every figure the engine itself measured, and + * nothing of OpenCode's. Absent on a view drawn from OpenCode's figures. + */ + detail?: Row[] } /** Width of the label column, in cells. */ @@ -61,6 +66,12 @@ export function decodeView(s: string): TurnView { return { engine, rows: [], notes: rest } } +/** A phase's tokens and time, e.g. `7,907 tok · 17.19s`. */ +export const phase = (tokens: number | undefined, seconds: number | undefined): string => + [tokens !== undefined ? `${nt(tokens)} tok` : "", seconds !== undefined && isFinite(seconds) ? `${seconds.toFixed(2)}s` : ""] + .filter(Boolean) + .join(" · ") + /** * The turn's total -- what the user waited, from OpenCode -- and any * retries on the row below it. diff --git a/test/detail.test.mjs b/test/detail.test.mjs index 2393ae0..3b0f64c 100644 --- a/test/detail.test.mjs +++ b/test/detail.test.mjs @@ -101,4 +101,31 @@ test("the steps table lists each tool call on its own line", () => { 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, ["no engine figures:", "engine data skipped:", "overlapping requests"]) +}) + +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)) +}) + console.log(`\n${passed} passed`) diff --git a/test/mtplx.test.mjs b/test/mtplx.test.mjs index a003f3b..20f884c 100644 --- a/test/mtplx.test.mjs +++ b/test/mtplx.test.mjs @@ -212,4 +212,17 @@ test("the total shown is OpenCode's -- what you waited -- with retries named", ( assert.ok(!out.includes("4.48"), out) }) +// ---- the details dialog's engine section ----------------------------------- +test("the engine detail is MTPLX's own figures, with every acceptance depth", () => { + const v = mtplxView(completed.latest, { total: 999, retries: 3 }) + const vals = v.detail.map(([, val]) => val).join("\n") + // OpenCode's total and retries are not the engine's. + assert.ok(!vals.includes("999"), vals) + assert.ok(!vals.includes("retr"), vals) + const depths = completed.latest.mean_accept_probability_by_depth + depths.forEach((_, i) => assert.ok(vals.includes(`depth ${i + 1}`), vals)) + assert.ok(vals.includes("verify passes"), vals) + v.detail.forEach(([l, val]) => assert.ok(12 + val.length <= 46, `${l}: ${val}`)) +}) + console.log(`\n${passed} passed`) diff --git a/tui.tsx b/tui.tsx index e08cb10..d1fcbfc 100644 --- a/tui.tsx +++ b/tui.tsx @@ -34,7 +34,7 @@ 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, rollupSubagents, subagentRows } from "./session" -import { buildTurnDetail, turnSections, type TurnDetail, type Section } from "./detail" +import { buildTurnDetail, turnSections, ENGINE_MARK, type TurnDetail, type Section } from "./detail" import { fetchMtplxLatest, mtplxView, combineMtplxSteps, type MtplxLatest } from "./adapters/mtplx" import { fetchOmlxSample, omlxView, omlxIsThisTurn, type OmlxSample } from "./adapters/omlx" @@ -449,7 +449,7 @@ export default Plugin.define({ - {"wheel or ↑↓ pgup pgdn scroll · esc closes"} + {`${ENGINE_MARK} measured by the engine; the rest is OpenCode's · ↑↓ pgup pgdn · esc`} ) @@ -547,6 +547,8 @@ export default Plugin.define({ sharedWindow: boolean /** Engine-only figures of an accepted reading, for the history row. */ engine?: TurnRecord["engine"] + /** Each step's own engine reading, where the engine is read per step. */ + stepEngine?: TurnDetail["stepEngine"] }, /** The turn's assistant messages, one per step, oldest first. */ steps: readonly SessionMessageAssistant[], @@ -657,6 +659,15 @@ export default Plugin.define({ if (receipts.every((r) => r !== null)) tier2.sharedWindow = true return null } + tier2.stepEngine = receipts.map((r) => + r + ? { + decodeTokS: r.decode_tok_s ?? undefined, + prefillTokS: r.prefill_tok_s ?? undefined, + ttftS: r.ttft_s ?? undefined, + } + : undefined + ) const verifies = combined.verify_calls ?? 0 tier2.engine = { prefillTokS: combined.prefill_tok_s ?? undefined, @@ -759,6 +770,9 @@ export default Plugin.define({ return null } tier2.engine = { prefillTokS: combined.prefillTokS, draftAccept: combined.draftAcceptRate } + tier2.stepEngine = perfs.map((p) => + p ? { decodeTokS: p.last_eval_speed || undefined, prefillTokS: p.last_process_speed || undefined } : undefined + ) return koboldView(combined, hostTtft, hostFigures) } // No per-step reads: one read now, which can only describe the @@ -1055,7 +1069,12 @@ export default Plugin.define({ dbg(`sub-agent lookup threw: ${String(e)}`) } - const tier2: { pendingBaseline: boolean; sharedWindow: boolean; engine?: TurnRecord["engine"] } = { + const tier2: { + pendingBaseline: boolean + sharedWindow: boolean + engine?: TurnRecord["engine"] + stepEngine?: TurnDetail["stepEngine"] + } = { pendingBaseline: false, sharedWindow: false, } @@ -1112,8 +1131,9 @@ export default Plugin.define({ totalS: turnRate(0, info, turn).total, outcome: opts.outcome, contextLimit: contextLimitFor(provider, model), - engineRows: enriched ? [...line.rows] : [], + engineRows: enriched ? [...(line.detail ?? line.rows)] : [], engineNote: enriched ? undefined : [...line.notes], + stepEngine: enriched ? tier2.stepEngine : undefined, subagents, }) dbg( From 2142c6d8a00170fea9c21f90b62f2bd2db54c2e6 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 00:32:06 -0700 Subject: [PATCH 07/12] Name compaction in a turn's time, the step it delayed, and as the reason engine data was skipped --- detail.ts | 48 +++++++++++++++++++++++++++++++------------ test/detail.test.mjs | 13 ++++++++++++ tui.tsx | 49 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/detail.ts b/detail.ts index 0dd1c84..e2e6abb 100644 --- a/detail.ts +++ b/detail.ts @@ -47,6 +47,8 @@ export interface StepDetail { retries: number /** The last retry's reason, as OpenCode recorded it. */ retryReason?: string + /** Seconds of this step's wait for its first token spent on compaction. */ + compactionS?: number error?: string } @@ -55,6 +57,8 @@ export interface TimeSplit { generating: number tools: number subagents: number + /** OpenCode summarising the conversation to fit the context. */ + compaction: number other: number } @@ -84,6 +88,8 @@ export interface TurnDetail { stepEngine?: Array<{ decodeTokS?: number; prefillTokS?: number; ttftS?: number } | undefined> /** Why engine figures are missing, when they are. */ engineNote?: string[] + /** Compactions that ran during the turn, epoch ms. */ + compactions?: Array subagents?: { count: number; tokens: number; spanS: number; cost?: number; steps?: number } } @@ -158,13 +164,14 @@ export function buildTurnDetail( engineNote?: string[] stepEngine?: TurnDetail["stepEngine"] subagents?: TurnDetail["subagents"] + compactions?: Array } ): TurnDetail { const tokens = { output: 0, reasoning: 0, input: 0, cacheRead: 0, cacheWrite: 0 } let cost = 0 let sawCost = false - let waitMs = 0 - let streamMs = 0 + const waitSpans: Array<[number, number]> = [] + const genSpans: Array<[number, number]> = [] const toolSpans: Array<[number, number]> = [] const subSpans: Array<[number, number]> = [] const out: StepDetail[] = [] @@ -186,11 +193,17 @@ export function buildTurnDetail( } if (t?.firstAt !== undefined && t.firstAt > m.time.created) { s.ttftS = (t.firstAt - m.time.created) / 1000 - waitMs += t.firstAt - m.time.created + waitSpans.push([m.time.created, t.firstAt]) } if (t?.firstAt !== undefined && t.lastAt !== undefined && t.lastAt > t.firstAt) { s.streamS = (t.lastAt - t.firstAt) / 1000 - streamMs += t.lastAt - t.firstAt + genSpans.push([t.firstAt, t.lastAt]) + } + if (s.ttftS !== undefined && base.compactions && base.compactions.length > 0) { + const w: [number, number] = [m.time.created, m.time.created + s.ttftS * 1000] + const c = base.compactions.map(([a, b]) => [Math.max(a, w[0]), Math.min(b, w[1])] as [number, number]) + const overlap = unionSeconds(c) + if (overlap > 0) s.compactionS = overlap } for (const tool of s.tools) { if (tool.start === undefined || tool.end === undefined) continue @@ -210,20 +223,26 @@ export function buildTurnDetail( let time: TimeSplit | undefined if (base.totalS !== undefined && base.totalS > 0) { - const waiting = waitMs / 1000 - const generating = streamMs / 1000 - // A sub-agent's time is its tool call's; without one (a sub-agent the - // steps don't show), the roll-up's span stands in. - const subagents = subSpans.length > 0 ? unionSeconds(subSpans) : (base.subagents?.spanS ?? 0) - // Tool time while a sub-agent was also running is already in the - // sub-agent's time; counted in both, the parts would exceed the total. - const tools = unionSeconds([...toolSpans, ...subSpans]) - unionSeconds(subSpans) + // Each moment counts once, under the first of these that covers it: + // compaction, sub-agents, tools, generating, waiting. Overlaps are real + // -- a tool beside a running sub-agent, a step waiting while OpenCode + // compacts -- and counted twice, the parts would exceed the total. + const comp = (base.compactions ?? []).map(([a, b]) => [a, b] as [number, number]) + const layers = [comp, subSpans, toolSpans, genSpans, waitSpans] + const parts = layers.map((spans, i) => { + const higher = layers.slice(0, i).flat() + return unionSeconds([...spans, ...higher]) - unionSeconds(higher) + }) + const [compaction, subFromTools, tools, generating, waiting] = parts as [number, number, number, number, number] + // A sub-agent the steps don't show as a tool call: its roll-up's span. + const subagents = subSpans.length > 0 ? subFromTools : (base.subagents?.spanS ?? 0) time = { waiting, generating, tools, subagents, - other: Math.max(0, base.totalS - waiting - generating - tools - subagents), + compaction, + other: Math.max(0, base.totalS - waiting - generating - tools - subagents - compaction), } } @@ -253,6 +272,7 @@ export function buildTurnDetail( engineRows: base.engineRows ?? [], engineNote: base.engineNote, stepEngine: base.stepEngine, + compactions: base.compactions && base.compactions.length > 0 ? base.compactions : undefined, subagents: base.subagents, } } @@ -317,6 +337,7 @@ export function turnSections(d: TurnDetail): Section[] { ["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") @@ -343,6 +364,7 @@ export function turnSections(d: TurnDetail): Section[] { 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. diff --git a/test/detail.test.mjs b/test/detail.test.mjs index 3b0f64c..b996e3f 100644 --- a/test/detail.test.mjs +++ b/test/detail.test.mjs @@ -128,4 +128,17 @@ test("retry reasons are listed in full, wrapped to the column", () => { 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. + const d = buildTurnDetail(steps, marks, { ...base, compactions: [[T0 + 17_500, T0 + 18_400]] }) + assert.ok(Math.abs(d.time.compaction - 0.9) < 1e-9, String(d.time.compaction)) + assert.ok(Math.abs(d.time.waiting - 3.1) < 1e-9, String(d.time.waiting)) + 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 d1fcbfc..374cec8 100644 --- a/tui.tsx +++ b/tui.tsx @@ -958,6 +958,18 @@ export default Plugin.define({ // a turn in one tab suppress another tab's line (see panels.ts). const latest = new LatestPerKey() + // Compactions per session, epoch ms: OpenCode summarising the conversation + // to fit the context, as a request of its own to the same engine. Kept to + // name them in a turn's time and as the reason a counter window held more + // than the turn (measured: a Splash turn with a sub-agent declined as + // "overlapping requests"; the transcript showed a compaction in it). + const compactions = new Map>() + const compactionsIn = (sessionID: string, from: number, to: number): Array => + (compactions.get(sessionID) ?? []) + .map(([a, b]) => [a, b ?? to] as const) + .filter(([a, b]) => b > from && a < to) + .map(([a, b]) => [Math.max(a, from), Math.min(b, to)] as const) + // Replies already reported, by their last step's id. A reply is reported // when its last step ends, and again asked for when the execution ends; // it must render once. @@ -1003,6 +1015,10 @@ export default Plugin.define({ const turn = agg.turn if (!info) return dbg(`report: ${sessionID} ending ${lastID}${opts.outcome ? ` (${opts.outcome})` : ""}; ${steps.length} step(s)`) + const turnCompactions = compactionsIn(sessionID, info.time.created, Date.now()) + if (turnCompactions.length > 0) { + dbg(` compaction during turn: ${turnCompactions.map(([a, b]) => `${((b - a) / 1000).toFixed(2)}s`).join(", ")}`) + } dbg( `turn: ${steps.length} assistant message(s) [${steps .map((m) => `${(m.tokens?.output ?? 0) + (m.tokens?.reasoning ?? 0)}${m.finish ? `/${m.finish}` : ""}`) @@ -1106,15 +1122,16 @@ export default Plugin.define({ // above are measured and complete; only their SOURCE changes once a // baseline exists, and the rate in particular can move an order of // magnitude when it does. Split to fit the box's 32 cells. - if (opts.outcome) { + if (!opts.outcome && tier2.sharedWindow && turnCompactions.length > 0) { + line.notes.push("engine data skipped:", "compaction ran this turn") + } else if (opts.outcome) { // OpenCode records no tokens for a step it stopped mid-stream // (measured: an interrupted reply's step came back 0/error after 7s // of thinking), so a 0 here is unknown, not none. const out0 = (info.tokens?.output ?? 0) + (info.tokens?.reasoning ?? 0) if (out0 === 0) line.rows = line.rows.filter(([label]) => label !== "tokens" && label !== "speed") line.notes.push(opts.outcome) - } - else if (tier2.pendingBaseline) line.notes.push("engine telemetry", "from the next turn") + } else if (tier2.pendingBaseline) line.notes.push("engine telemetry", "from the next turn") else if (tier2.sharedWindow) line.notes.push("engine data skipped:", "overlapping requests") } @@ -1134,6 +1151,7 @@ export default Plugin.define({ engineRows: enriched ? [...(line.detail ?? line.rows)] : [], engineNote: enriched ? undefined : [...line.notes], stepEngine: enriched ? tier2.stepEngine : undefined, + compactions: turnCompactions, subagents, }) dbg( @@ -1449,6 +1467,31 @@ export default Plugin.define({ }, 50) }) ) + // Compactions: when each started and ended, per session. + const compactionEvent = + (end: boolean) => + (evt: unknown): void => { + const sid = (evt as { data?: { sessionID?: string } }).data?.sessionID + if (typeof sid !== "string") return + const list = compactions.get(sid) ?? [] + if (end) { + const open = list.find(([, b]) => b === undefined) + if (open) open[1] = Date.now() + } else { + list.push([Date.now(), undefined]) + // A session compacts rarely; the last few are all a turn can need. + while (list.length > 8) list.shift() + } + compactions.set(sid, list) + if (compactions.size > 64) { + const oldest = compactions.keys().next().value + if (oldest !== undefined && oldest !== sid) compactions.delete(oldest) + } + dbg(`event compaction.${end ? "ended" : "started"} ${sid}`) + } + off.push(ctx.data.on("session.compaction.started", compactionEvent(false))) + off.push(ctx.data.on("session.compaction.ended", compactionEvent(true))) + off.push(ctx.data.on("session.compaction.failed", compactionEvent(true))) // An execution stopped before its reply finished still used the engine; // the reply is shown, marked, rather than leaving the previous turn up. off.push( From e6abdd51e7bdcce6e13e99b21b01531143134540 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 00:35:52 -0700 Subject: [PATCH 08/12] Take a bracketed compaction out of a counter window; say when engine figures were left out, not missing --- counters.ts | 39 +++++++++++++++++++ detail.ts | 23 +++++++----- package.json | 3 +- test/counters.test.mjs | 44 ++++++++++++++++++++++ test/detail.test.mjs | 4 +- tui.tsx | 85 +++++++++++++++++++++++++++++++++++------- 6 files changed, 174 insertions(+), 24 deletions(-) create mode 100644 counters.ts create mode 100644 test/counters.test.mjs diff --git a/counters.ts b/counters.ts new file mode 100644 index 0000000..bf3bcc7 --- /dev/null +++ b/counters.ts @@ -0,0 +1,39 @@ +// Taking a known request out of a counter window. +// +// Pure, like the other shared modules. Engines that publish cumulative +// counters -- Splash, llama.cpp, llamafile, the Prometheus engines -- give a +// turn's figures as the difference between a reading before it and one after. +// A request of OpenCode's own inside that window (a compaction, measured on +// Splash) lands in the difference too, and the turn is declined because its +// tokens no longer match. When that request was bracketed by readings of its +// own, its difference can be taken back out. + +/** Field-wise `after - before`, for every numeric field. */ +export function counterDelta(before: T, after: T): Partial> { + const out: Partial> = {} + for (const k of Object.keys(after) as Array) { + const a = after[k] + const b = before[k] + if (typeof a === "number" && typeof b === "number") out[k] = a - b + } + return out +} + +/** + * The baseline moved forward by each bracketed request's own difference, so + * that `now - shifted` holds everything in the window except those requests. + * Non-numeric fields keep the baseline's. A bracket that went backwards (a + * restarted engine) can't be trusted, and leaves the baseline as it was. + */ +export function shiftBaseline(prev: T, brackets: ReadonlyArray<{ before: T; after: T }>): T { + const shifted = { ...prev } as Record + for (const { before, after } of brackets) { + const d = counterDelta(before, after) as Record + if (Object.values(d).some((v) => v !== undefined && v < 0)) continue + for (const [k, v] of Object.entries(d)) { + const cur = shifted[k] + if (typeof cur === "number" && v !== undefined) shifted[k] = cur + v + } + } + return shifted as T +} diff --git a/detail.ts b/detail.ts index e2e6abb..c636340 100644 --- a/detail.ts +++ b/detail.ts @@ -90,6 +90,8 @@ export interface TurnDetail { engineNote?: string[] /** Compactions that ran during the turn, epoch ms. */ compactions?: Array + /** What each compaction cost the engine, where it was bracketed by readings. */ + compactionEngine?: string[] subagents?: { count: number; tokens: number; spanS: number; cost?: number; steps?: number } } @@ -165,6 +167,7 @@ export function buildTurnDetail( stepEngine?: TurnDetail["stepEngine"] subagents?: TurnDetail["subagents"] compactions?: Array + compactionEngine?: string[] } ): TurnDetail { const tokens = { output: 0, reasoning: 0, input: 0, cacheRead: 0, cacheWrite: 0 } @@ -273,6 +276,7 @@ export function buildTurnDetail( engineNote: base.engineNote, stepEngine: base.stepEngine, compactions: base.compactions && base.compactions.length > 0 ? base.compactions : undefined, + compactionEngine: base.compactionEngine && base.compactionEngine.length > 0 ? base.compactionEngine : undefined, subagents: base.subagents, } } @@ -401,16 +405,17 @@ export function turnSections(d: TurnDetail): Section[] { ] : [] ) - out.push({ - title: `${ENGINE_MARK} Engine · ${d.engine}`, - rows: d.engineRows, - lines: perStep.length > 1 ? ["", "per step", ...perStep] : undefined, - }) + 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: d.engineNote && d.engineNote.length > 0 ? ["no engine figures:", ...d.engineNote] : ["no engine figures for this turn"], - }) + out.push({ title: `Engine · ${d.engine}`, lines: ["no engine telemetry for this provider"] }) } if (d.subagents) { out.push({ diff --git a/package.json b/package.json index 29e7ad8..5b9f960 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,12 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test": "npm run typecheck && bun test/format.test.mjs && bun test/http.test.mjs && bun test/prometheus-text.test.mjs && bun test/universal.test.mjs && bun test/mtplx.test.mjs && bun test/omlx.test.mjs && bun test/llamacpp.test.mjs && bun test/prometheus.test.mjs && bun test/koboldcpp.test.mjs && bun test/splash.test.mjs && bun test/mlxserve.test.mjs && bun test/history.test.mjs && bun test/panels.test.mjs && bun test/session.test.mjs && bun test/rows.test.mjs && bun test/detail.test.mjs && bun test/references.test.mjs" + "test": "npm run typecheck && bun test/format.test.mjs && bun test/http.test.mjs && bun test/prometheus-text.test.mjs && bun test/universal.test.mjs && bun test/mtplx.test.mjs && bun test/omlx.test.mjs && bun test/llamacpp.test.mjs && bun test/prometheus.test.mjs && bun test/koboldcpp.test.mjs && bun test/splash.test.mjs && bun test/mlxserve.test.mjs && bun test/history.test.mjs && bun test/panels.test.mjs && bun test/session.test.mjs && bun test/rows.test.mjs && bun test/detail.test.mjs && bun test/counters.test.mjs && bun test/references.test.mjs" }, "files": [ "tui.tsx", "detail.ts", + "counters.ts", "format.ts", "http.ts", "prometheus-text.ts", diff --git a/test/counters.test.mjs b/test/counters.test.mjs new file mode 100644 index 0000000..5ec2098 --- /dev/null +++ b/test/counters.test.mjs @@ -0,0 +1,44 @@ +// Validates counters.ts -- taking a bracketed request out of a counter window. +// Run with: bun test/counters.test.mjs +import { strict as assert } from "node:assert" +import { counterDelta, shiftBaseline } from "../counters.ts" + +let passed = 0 +function test(name, fn) { + try { + fn() + passed++ + console.log(" ok ", name) + } catch (e) { + console.log(" FAIL", name, "\n ", e.message) + process.exitCode = 1 + } +} + +// Splash-shaped: a turn of 2 requests (500 tokens), with a compaction of 1 +// request (40 tokens out, 23,000 in) between them. +const prev = { requestsCompleted: 10, decodeTokens: 1_000, prefillTokens: 50_000, model: "q" } +const before = { requestsCompleted: 11, decodeTokens: 1_200, prefillTokens: 60_000, model: "q" } +const after = { requestsCompleted: 12, decodeTokens: 1_240, prefillTokens: 83_000, model: "q" } +const now = { requestsCompleted: 13, decodeTokens: 1_540, prefillTokens: 93_000, model: "q" } + +test("a delta is field-wise, numeric fields only", () => { + assert.deepEqual(counterDelta(before, after), { requestsCompleted: 1, decodeTokens: 40, prefillTokens: 23_000 }) +}) + +test("shifting the baseline leaves only the turn's own requests in the window", () => { + const shifted = shiftBaseline(prev, [{ before, after }]) + assert.deepEqual(counterDelta(shifted, now), { requestsCompleted: 2, decodeTokens: 500, prefillTokens: 20_000 }) + assert.equal(shifted.model, "q") +}) + +test("no brackets, no change", () => { + assert.deepEqual(shiftBaseline(prev, []), prev) +}) + +test("a bracket that went backwards (engine restarted) is ignored", () => { + const restarted = { requestsCompleted: 0, decodeTokens: 0, prefillTokens: 0, model: "q" } + assert.deepEqual(shiftBaseline(prev, [{ before, after: restarted }]), prev) +}) + +console.log(`\n${passed} passed`) diff --git a/test/detail.test.mjs b/test/detail.test.mjs index b996e3f..51b0272 100644 --- a/test/detail.test.mjs +++ b/test/detail.test.mjs @@ -106,7 +106,9 @@ test("the engine section is marked, and says why when there are no engine figure 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, ["no engine figures:", "engine data skipped:", "overlapping requests"]) + 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", () => { diff --git a/tui.tsx b/tui.tsx index 374cec8..b9a0536 100644 --- a/tui.tsx +++ b/tui.tsx @@ -34,6 +34,7 @@ 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, rollupSubagents, subagentRows } from "./session" +import { shiftBaseline, counterDelta } from "./counters" import { buildTurnDetail, turnSections, ENGINE_MARK, type TurnDetail, type Section } from "./detail" import { fetchMtplxLatest, mtplxView, combineMtplxSteps, type MtplxLatest } from "./adapters/mtplx" @@ -557,8 +558,16 @@ export default Plugin.define({ * counter-difference engine's window holds their requests too, so its * check expects the turn's tokens and steps plus theirs. */ - sameEngine?: TurnRecord["subagents"] + sameEngine?: TurnRecord["subagents"], + /** Compactions in the turn, each bracketed by counter readings. */ + bracketed: readonly Compaction[] = [] ): Promise { + // Readings around each compaction on this engine, to take its request + // back out of a counter window. + const bracketsFor = (): Array<{ before: T; after: T }> => + bracketed + .filter((c) => c.provider === provider && c.before && c.after) + .map((c) => ({ before: c.before as T, after: c.after as T })) // OpenCode's own ttft for this turn. Five provider ids report none of // their own (omlx, llamacpp, llamafile, splash, koboldcpp), and the // host has the marks regardless of which tier renders the line. Passed @@ -586,7 +595,8 @@ export default Plugin.define({ ): Promise => { const now = await fetchPromSample(url, spec, http) if (!now) return null - const prev = base.prom[id] + const base0 = base.prom[id] + const prev = base0 ? shiftBaseline(base0, bracketsFor()) : base0 setBase((d) => { d.prom[id] = now }) @@ -709,7 +719,8 @@ export default Plugin.define({ const label = provider === "llamacpp" ? "llama.cpp" : "llamafile" const now = await fetchLlamaCppCounters(url, http) if (!now) return null // unreachable, or started without --metrics - const prev = base.llamacpp[provider] + const base0 = base.llamacpp[provider] + const prev = base0 ? shiftBaseline(base0, bracketsFor()) : base0 setBase((d) => { d.llamacpp[provider] = now }) @@ -731,7 +742,8 @@ export default Plugin.define({ case "splash": { const now = await fetchSplashSample(cfg.splashBase, http) if (!now) return null - const prev = base.splash[cfg.splashBase] + const base0 = base.splash[cfg.splashBase] + const prev = base0 ? shiftBaseline(base0, bracketsFor()) : base0 setBase((d) => { d.splash[cfg.splashBase] = now }) @@ -963,12 +975,34 @@ export default Plugin.define({ // name them in a turn's time and as the reason a counter window held more // than the turn (measured: a Splash turn with a sub-agent declined as // "overlapping requests"; the transcript showed a compaction in it). - const compactions = new Map>() + // + // For an engine that publishes cumulative counters, each compaction is + // also bracketed by a reading of its own at start and end, so its request + // can be taken back out of the turn's window (see counters.ts) instead of + // the whole turn being declined. + interface Compaction { + start: number + end?: number + provider?: string + before?: unknown + after?: unknown + } + const compactions = new Map() + const compactionsOverlapping = (sessionID: string, from: number, to: number): Compaction[] => + (compactions.get(sessionID) ?? []).filter((c) => (c.end ?? to) > from && c.start < to) const compactionsIn = (sessionID: string, from: number, to: number): Array => - (compactions.get(sessionID) ?? []) - .map(([a, b]) => [a, b ?? to] as const) - .filter(([a, b]) => b > from && a < to) - .map(([a, b]) => [Math.max(a, from), Math.min(b, to)] as const) + compactionsOverlapping(sessionID, from, to).map((c) => [Math.max(c.start, from), Math.min(c.end ?? to, to)] as const) + /** A cumulative-counter engine's reading, for bracketing a compaction. */ + const readCounters = (provider: string): Promise | undefined => { + const http: HttpOptions = { signal: life.signal } + const p = promTarget(provider) + if (p) return fetchPromSample(p.url, p.spec, http) + if (provider === "llamacpp") return fetchLlamaCppCounters(cfg.llamacppBase, http) + if (provider === "llamafile") return fetchLlamaCppCounters(cfg.llamafileBase, http) + if (provider === "splash") return fetchSplashSample(cfg.splashBase, http) + // oMLX publishes running averages, which can't be subtracted. + return undefined + } // Replies already reported, by their last step's id. A reply is reported // when its last step ends, and again asked for when the execution ends; @@ -1098,7 +1132,9 @@ export default Plugin.define({ try { // An unfinished reply's last step never completed, so the engine has // no reading of it to check against: OpenCode's figures only. - if (!opts.outcome) line = await enrich(provider, model, info, turn, http, tier2, steps, sameEngine) + const bracketed = compactionsOverlapping(sessionID, info.time.created, Date.now()) + if (bracketed.some((c) => c.before && c.after)) dbg(` taking ${bracketed.length} compaction(s) out of the window`) + if (!opts.outcome) line = await enrich(provider, model, info, turn, http, tier2, steps, sameEngine, bracketed) // Recorded before the fallback overwrites it, so history knows which // tier the figures actually came from. } catch (e: unknown) { @@ -1152,6 +1188,15 @@ export default Plugin.define({ engineNote: enriched ? undefined : [...line.notes], stepEngine: enriched ? tier2.stepEngine : undefined, compactions: turnCompactions, + compactionEngine: compactionsOverlapping(sessionID, info.time.created, Date.now()).flatMap((c) => { + if (!c.before || !c.after || c.provider !== provider) return [] + const d = counterDelta(c.before as object, c.after as object) as Record + const read = d["prefillTokens"] ?? d["promptTokens"] ?? d["prompt"] + const wrote = d["decodeTokens"] ?? d["predictedTokens"] ?? d["generation"] + return read !== undefined || wrote !== undefined + ? [`${read !== undefined ? `${Math.round(read).toLocaleString("en-US")} tok read` : ""}${read !== undefined && wrote !== undefined ? " · " : ""}${wrote !== undefined ? `${Math.round(wrote).toLocaleString("en-US")} written` : ""}`] + : [] + }), subagents, }) dbg( @@ -1474,14 +1519,28 @@ export default Plugin.define({ const sid = (evt as { data?: { sessionID?: string } }).data?.sessionID if (typeof sid !== "string") return const list = compactions.get(sid) ?? [] + let c: Compaction | undefined if (end) { - const open = list.find(([, b]) => b === undefined) - if (open) open[1] = Date.now() + c = list.find((x) => x.end === undefined) + if (c) c.end = Date.now() } else { - list.push([Date.now(), undefined]) + const provider = lastModel(ctx.data.session.message.list(sid) ?? [])?.providerID + c = { start: Date.now(), provider } + list.push(c) // A session compacts rarely; the last few are all a turn can need. while (list.length > 8) list.shift() } + const target = c + const read = target?.provider ? readCounters(target.provider) : undefined + if (target && read) { + read + .then((sample) => { + if (end) target.after = sample ?? undefined + else target.before = sample ?? undefined + dbg(` compaction ${end ? "after" : "before"} reading (${target.provider}): ${sample ? "ok" : "none"}`) + }) + .catch(() => {}) + } compactions.set(sid, list) if (compactions.size > 64) { const oldest = compactions.keys().next().value From 6a63cf0c9095234c09e09faf0cb6caf5abf9475d Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 00:44:00 -0700 Subject: [PATCH 09/12] Add the dialog's session column: spread, time in seconds, tools by time, retries by reason, coverage --- history.ts | 18 +++++ session.ts | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++ tui.tsx | 55 +++++++++++++-- 3 files changed, 257 insertions(+), 7 deletions(-) diff --git a/history.ts b/history.ts index ce9bd80..b936712 100644 --- a/history.ts +++ b/history.ts @@ -78,6 +78,24 @@ export interface TurnRecord { subagents?: { count: number; tokens: number; spanS: number; cost?: number; steps?: number } /** Set when the reply did not finish: stopped by the user, or failed. */ outcome?: "interrupted" | "failed" + // ---- for the session column of the details dialog ------------------------ + // All optional: rows recorded before these existed leave the figures out. + /** Prompt tokens written to the cache this turn. */ + cacheWrite?: number + /** Seconds tools ran, overlaps counted once, sub-agent time excluded. */ + toolsS?: number + /** Per tool name: seconds it ran and how many calls. */ + tools?: Record + /** Seconds OpenCode spent compacting the conversation during the turn. */ + compactionS?: number + /** Each retry's reason, as OpenCode recorded it (one per retried step). */ + retryReasons?: string[] + /** + * Why the engine's figures were not used, when `source` is "host": + * no reading to difference from yet, a window holding other requests or a + * compaction, the reply not finishing, or no adapter for the provider. + */ + skip?: "baseline" | "overlap" | "compaction" | "unfinished" | "no-adapter" | "unavailable" engine?: { prefillTokS?: number /** Tokens committed per verify pass (MTPLX's multi-token prediction). */ diff --git a/session.ts b/session.ts index c37fbca..5793ecf 100644 --- a/session.ts +++ b/session.ts @@ -17,6 +17,7 @@ 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 @@ -228,6 +229,7 @@ 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 @@ -277,3 +279,192 @@ export function sessionView(s: SessionSummary): TurnView { if (s.retries > 0) rows.push(["retries", ni(s.retries)]) return { engine, rows, notes: [], key: s.genTokS !== undefined ? `${nn(s.genTokS)} tok/s` : undefined } } + +// ---- the details dialog's session column ------------------------------------- + +/** The value at fraction `q` of the sorted values (nearest rank). */ +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> = { + baseline: "first turn, no baseline", + overlap: "overlapping requests", + compaction: "compaction", + unfinished: "interrupted or failed", + "no-adapter": "no engine telemetry", + unavailable: "engine unreachable", +} + +/** + * 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. + */ +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 }) + + // Where the time went, in seconds, over the turns that recorded the parts + let gen = 0 + let wait = 0 + let tools = 0 + let sub = 0 + let comp = 0 + let total = 0 + for (const t of turns) { + const w = streamOf(t) + if (w === undefined || t.waitS === undefined || t.totalS === undefined || t.totalS <= 0) continue + gen += w + wait += t.waitS + tools += t.toolsS ?? 0 + sub += Math.min(t.subagents?.spanS ?? 0, Math.max(0, t.totalS - w - t.waitS - (t.toolsS ?? 0))) + 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 ?? {})) { + const cur = byTool.get(name) ?? { s: 0, n: 0 } + cur.s += x.s + cur.n += x.n + 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), + ], + }) + + // 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 out +} diff --git a/tui.tsx b/tui.tsx index b9a0536..66ec151 100644 --- a/tui.tsx +++ b/tui.tsx @@ -33,9 +33,9 @@ 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, rollupSubagents, subagentRows } from "./session" +import { summariseSession, sessionView, sessionSections, rollupSubagents, subagentRows } from "./session" import { shiftBaseline, counterDelta } from "./counters" -import { buildTurnDetail, turnSections, ENGINE_MARK, type TurnDetail, type Section } from "./detail" +import { buildTurnDetail, turnSections, ENGINE_MARK, SUBAGENT_TOOLS, type TurnDetail, type Section } from "./detail" import { fetchMtplxLatest, mtplxView, combineMtplxSteps, type MtplxLatest } from "./adapters/mtplx" import { fetchOmlxSample, omlxView, omlxIsThisTurn, type OmlxSample } from "./adapters/omlx" @@ -406,7 +406,7 @@ export default Plugin.define({ 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[] => [{ title: "Summary", rows: sessView.rows, lines: sessView.notes }] + const sessCol = (): Section[] => sessionSections(history.turns, sessionID) setTimeout(() => { dbg( `details: wide ${wide()}; dialog ${root?.width ?? "?"}x${root?.height ?? "?"}; ` + @@ -881,6 +881,26 @@ export default Plugin.define({ return promTarget(provider)?.label ?? known[provider] ?? provider } + /** Whether any adapter reads this provider's engine. */ + function hasAdapter(provider: string): boolean { + return engineLabel(provider) !== provider || promTarget(provider) !== undefined || provider === "llamafile" + } + + /** A turn's tool time and calls per tool name, sub-agents excluded. */ + function toolsByName(d: TurnDetail): Record | undefined { + const out: Record = {} + for (const st of d.steps) { + for (const t of st.tools) { + if (SUBAGENT_TOOLS.has(t.name)) continue + const cur = out[t.name] ?? { s: 0, n: 0 } + cur.s += t.seconds ?? 0 + cur.n += 1 + out[t.name] = cur + } + } + return Object.keys(out).length > 0 ? out : undefined + } + // ---- baseline priming --------------------------------------------------- // A counter-difference engine is read at each turn's end, and that reading // is the next turn's baseline -- so the first turn after launch had none @@ -1174,8 +1194,9 @@ export default Plugin.define({ // The turn in full, for the dialog. Built before the stream marks are // released below, and from the rows before sub-agent rows join them: // those are OpenCode's, kept in the detail's own sub-agent section. + let detail: TurnDetail | undefined try { - const detail = buildTurnDetail(steps, turns, { + detail = buildTurnDetail(steps, turns, { sessionID, provider, model, @@ -1199,12 +1220,13 @@ export default Plugin.define({ }), subagents, }) + const dd = detail dbg( - `detail: ${detail.steps.length} step(s); tools [${detail.steps.flatMap((st) => st.tools.map((t) => `${t.name}:${t.seconds?.toFixed(2) ?? t.status}`)).join(", ")}]` + - (detail.time ? `; split ${Object.entries(detail.time).map(([k, v]) => `${k} ${v.toFixed(2)}`).join(", ")} of ${detail.totalS?.toFixed(2)}` : "") + `detail: ${dd.steps.length} step(s); tools [${dd.steps.flatMap((st) => st.tools.map((t) => `${t.name}:${t.seconds?.toFixed(2) ?? t.status}`)).join(", ")}]` + + (dd.time ? `; split ${Object.entries(dd.time).map(([k, v]) => `${k} ${v.toFixed(2)}`).join(", ")} of ${dd.totalS?.toFixed(2)}` : "") ) setTurnDetail((d) => { - d.bySession[sessionID] = detail + d.bySession[sessionID] = dd const keys = Object.keys(d.bySession) if (keys.length > 32) { const oldest = keys.sort((a, b) => (d.bySession[a]?.at ?? 0) - (d.bySession[b]?.at ?? 0))[0] @@ -1247,6 +1269,25 @@ export default Plugin.define({ engine: enriched ? tier2.engine : undefined, subagents, outcome: opts.outcome, + // For the dialog's session column. + cacheWrite: detail?.tokens.cacheWrite || undefined, + toolsS: detail?.time?.tools, + compactionS: detail?.time?.compaction || undefined, + tools: detail ? toolsByName(detail) : undefined, + retryReasons: detail?.steps.flatMap((st) => (st.retryReason ? [st.retryReason] : [])), + skip: enriched + ? undefined + : opts.outcome + ? "unfinished" + : tier2.pendingBaseline + ? "baseline" + : tier2.sharedWindow + ? turnCompactions.length > 0 + ? "compaction" + : "overlap" + : hasAdapter(provider) + ? "unavailable" + : "no-adapter", } setHistory((d) => { d.turns = record({ turns: d.turns }, rec).turns From 8308f8d05451b0f60d4650f804c4324de7e55c43 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 00:50:18 -0700 Subject: [PATCH 10/12] Add an end-to-end harness and suites; give each turn its own starting counter reading, which a same-engine sub-agent was overwriting --- package.json | 2 +- test/e2e/counters.e2e.mjs | 171 ++++++++++++++++++++ test/e2e/harness.mjs | 317 ++++++++++++++++++++++++++++++++++++++ test/e2e/turns.e2e.mjs | 153 ++++++++++++++++++ tui.tsx | 48 +++++- 5 files changed, 685 insertions(+), 6 deletions(-) create mode 100644 test/e2e/counters.e2e.mjs create mode 100644 test/e2e/harness.mjs create mode 100644 test/e2e/turns.e2e.mjs diff --git a/package.json b/package.json index 5b9f960..2d1545e 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test": "npm run typecheck && bun test/format.test.mjs && bun test/http.test.mjs && bun test/prometheus-text.test.mjs && bun test/universal.test.mjs && bun test/mtplx.test.mjs && bun test/omlx.test.mjs && bun test/llamacpp.test.mjs && bun test/prometheus.test.mjs && bun test/koboldcpp.test.mjs && bun test/splash.test.mjs && bun test/mlxserve.test.mjs && bun test/history.test.mjs && bun test/panels.test.mjs && bun test/session.test.mjs && bun test/rows.test.mjs && bun test/detail.test.mjs && bun test/counters.test.mjs && bun test/references.test.mjs" + "test": "npm run typecheck && bun test/format.test.mjs && bun test/http.test.mjs && bun test/prometheus-text.test.mjs && bun test/universal.test.mjs && bun test/mtplx.test.mjs && bun test/omlx.test.mjs && bun test/llamacpp.test.mjs && bun test/prometheus.test.mjs && bun test/koboldcpp.test.mjs && bun test/splash.test.mjs && bun test/mlxserve.test.mjs && bun test/history.test.mjs && bun test/panels.test.mjs && bun test/session.test.mjs && bun test/rows.test.mjs && bun test/detail.test.mjs && bun test/counters.test.mjs && bun test/references.test.mjs && bun test/e2e/turns.e2e.mjs && bun test/e2e/counters.e2e.mjs" }, "files": [ "tui.tsx", diff --git a/test/e2e/counters.e2e.mjs b/test/e2e/counters.e2e.mjs new file mode 100644 index 0000000..ff333f6 --- /dev/null +++ b/test/e2e/counters.e2e.mjs @@ -0,0 +1,171 @@ +// End-to-end: engines that publish cumulative counters (Splash here), whose +// turn figures are a difference across the turn. See harness.mjs. +// Run with: bun test/e2e/counters.e2e.mjs +import { strict as assert } from "node:assert" +import { startPlugin, engineServer, splashCounters, settle, test, done, rowsOf } from "./harness.mjs" + +const SPLASH = { provider: "splash", model: "q27" } + +/** A fake Splash, answering /metrics from live counters. */ +function fakeSplash() { + const eng = engineServer() + const sc = splashCounters() + Object.defineProperty(eng.routes, "/metrics", { get: sc.text, enumerable: true, configurable: true }) + return { eng, sc } +} + +/** A Splash step: the engine completes one request as the step streams. */ +const splashStep = (h, sc, sid, output, extra = {}) => + h.step(sid, { + ...SPLASH, ttftMs: 1_000, streamMs: 2_000, finish: "stop", + tokens: { input: 100, output, reasoning: 0, cache: { read: 0, write: 0 } }, + beforeStreamed: () => sc.add({ output }), + ...extra, + }) + +await test("an existing session's first turn after launch is primed: engine figures on turn one", async () => { + const { eng, sc } = fakeSplash() + const h = await startPlugin({ splashBaseUrl: eng.url }) + try { + const sid = h.session("ses_p") + h.earlier(sid, "splash", "q27") + h.user(sid) + h.executionStarted(sid) + await settle(60) + await splashStep(h, sc, sid, 200) + h.executionSucceeded(sid) + await settle() + const v = h.sidebar(sid) + assert.equal(v.engine, "Splash") + assert.ok(!v.notes.join(" ").includes("next turn"), JSON.stringify(v)) + assert.equal(h.history()[0].source, "engine") + } finally { + h.restore() + eng.stop() + } +}) + +await test("a new session's first turn has no baseline, and says so", async () => { + const { eng, sc } = fakeSplash() + const h = await startPlugin({ splashBaseUrl: eng.url }) + try { + const sid = h.session("ses_n") + h.user(sid) + h.executionStarted(sid) + await splashStep(h, sc, sid, 50) + h.executionSucceeded(sid) + await settle() + assert.deepEqual(h.sidebar(sid).notes, ["engine telemetry", "from the next turn"]) + assert.equal(h.history()[0].skip, "baseline") + } finally { + h.restore() + eng.stop() + } +}) + +// Never seen live: a sub-agent on the same engine, nothing else in the window. +await test("a sub-agent on the same engine: the window holds both, and is accepted and labelled", async () => { + const { eng, sc } = fakeSplash() + const h = await startPlugin({ splashBaseUrl: eng.url }) + try { + const sid = h.session("ses_s") + h.earlier(sid, "splash", "q27") + h.user(sid) + h.executionStarted(sid) + await settle(60) + await splashStep(h, sc, sid, 80, { + finish: "tool-calls", + tools: [{ + name: "subagent", ms: 500, + run: async () => { + const child = h.session("ses_s_child", sid) + h.user(child) + h.executionStarted(child) + await splashStep(h, sc, child, 300) + h.executionSucceeded(child) + await settle() + h.session(sid) + }, + }], + }) + await splashStep(h, sc, sid, 120) + h.executionSucceeded(sid) + await settle() + const v = h.sidebar(sid) + assert.equal(v.engine, "Splash", JSON.stringify(v)) + assert.ok(v.rows.some(([, val]) => val === "incl. sub-agents"), JSON.stringify(v)) + assert.equal(h.history()[0].source, "engine") + assert.equal(h.history()[0].subagents.tokens, 300) + } finally { + h.restore() + eng.stop() + } +}) + +// Measured live 2026-09-25: a compaction inside a Splash turn made the window +// hold more than the turn, and it was declined. +await test("a compaction bracketed by readings is taken out of the window", async () => { + const { eng, sc } = fakeSplash() + const h = await startPlugin({ splashBaseUrl: eng.url }) + try { + const sid = h.session("ses_c") + h.earlier(sid, "splash", "q27") + h.user(sid) + h.executionStarted(sid) + await settle(60) + await splashStep(h, sc, sid, 150, { finish: "tool-calls", tools: [{ name: "read", ms: 10 }] }) + h.compaction(sid, "started") + await settle(60) + h.ctx // the compaction is its own request to the engine + sc.add({ output: 40, input: 23_000, prefillMs: 20_000 }) + const { clock } = await import("./harness.mjs") + clock.advance(28_000) + h.compaction(sid, "ended") + await settle(60) + await splashStep(h, sc, sid, 250) + h.executionSucceeded(sid) + await settle() + const v = h.sidebar(sid) + assert.equal(v.engine, "Splash", JSON.stringify(v)) + assert.equal(rowsOf(v).tokens, "400") + assert.equal(h.history()[0].source, "engine") + const d = h.detail(sid) + assert.ok(d.time.compaction > 27, String(d.time.compaction)) + assert.ok(d.compactionEngine?.[0]?.includes("23,000 tok read"), JSON.stringify(d.compactionEngine)) + } finally { + h.restore() + eng.stop() + } +}) + +await test("a compaction with no readings of its own declines the window, and names it", async () => { + const { eng, sc } = fakeSplash() + const h = await startPlugin({ splashBaseUrl: eng.url }) + try { + const sid = h.session("ses_d") + h.earlier(sid, "splash", "q27") + h.user(sid) + h.executionStarted(sid) + await settle(60) + await splashStep(h, sc, sid, 150, { finish: "tool-calls", tools: [{ name: "read", ms: 10 }] }) + // The engine is unreachable while OpenCode compacts, so neither reading lands. + const saved = sc.text + Object.defineProperty(eng.routes, "/metrics", { get: () => undefined, configurable: true }) + h.compaction(sid, "started") + await settle(60) + sc.add({ output: 40, input: 23_000 }) + h.compaction(sid, "ended") + await settle(60) + Object.defineProperty(eng.routes, "/metrics", { get: saved, configurable: true }) + await splashStep(h, sc, sid, 250) + h.executionSucceeded(sid) + await settle() + assert.deepEqual(h.sidebar(sid).notes, ["engine data skipped:", "compaction ran this turn"]) + assert.equal(h.history()[0].skip, "compaction") + } finally { + h.restore() + eng.stop() + } +}) + +done() diff --git a/test/e2e/harness.mjs b/test/e2e/harness.mjs new file mode 100644 index 0000000..c65891c --- /dev/null +++ b/test/e2e/harness.mjs @@ -0,0 +1,317 @@ +// End-to-end harness: runs the real entry file against a fake OpenCode. +// +// `tui.tsx` is imported as OpenCode loads it, and its `setup` is called with a +// fake Context: storage that behaves like the host's, an event bus for +// `ctx.data.on`, and a session message store the test fills as a turn goes. +// Engines are served over real HTTP from whatever the test sets, so the +// adapters' fetch, parse and check paths all run. Time is a fake clock the +// test advances, so rates and durations are exact. +// +// Nothing is rendered: slot claims are recorded, not drawn. Assertions read +// what the plugin stored for the UI -- the sidebar view per session, the +// history rows, the dialog's turn detail -- which is what the user sees. + +import { lineFor } from "../../panels.ts" +import { decodeView } from "../../rows.ts" + +// ---- the clock ---------------------------------------------------------------- + +let now = Date.UTC(2026, 8, 25, 12, 0, 0) +Date.now = () => now +export const clock = { + get: () => now, + /** Advances the fake clock by `ms`. */ + advance(ms) { + now += ms + }, +} + +/** Lets pending promises, HTTP round trips and short timers run. */ +export const settle = (ms = 120) => new Promise((r) => setTimeout(r, ms)) + +// ---- the fake engine ------------------------------------------------------------ + +/** + * A local HTTP server answering from `routes`: path -> string (served as + * text) or object (served as JSON) or undefined (404). Tests change routes + * between events, as a real engine's counters change. + */ +export function engineServer() { + const routes = {} + const server = Bun.serve({ + port: 0, + fetch(req) { + const path = new URL(req.url).pathname + const body = routes[path] + if (body === undefined) return new Response("not found", { status: 404 }) + return typeof body === "string" + ? new Response(body, { headers: { "content-type": "text/plain" } }) + : Response.json(body) + }, + }) + return { routes, url: `http://127.0.0.1:${server.port}`, stop: () => server.stop(true) } +} + +// ---- the fake host ---------------------------------------------------------------- + +/** + * Starts the plugin. Returns helpers to drive sessions and turns, and to read + * what the plugin stored for the UI. + */ +export async function startPlugin(options = {}) { + delete process.env.OPENCODE_HUD_DEBUG + const plugin = (await import("../../tui.tsx")).default + + const handlers = new Map() + const emit = (type, data) => { + for (const h of handlers.get(type) ?? []) h({ type, data }) + } + const memory = new Map() + const makeStore = (durable) => (key, { initial }) => { + if (!memory.has(key)) memory.set(key, structuredClone(initial)) + const value = memory.get(key) + const set = (mutate) => { + mutate(value) + return durable ? Promise.resolve() : undefined + } + return [value, set] + } + + const messages = new Map() // sessionID -> message[] + const sessions = new Map() // sessionID -> { id, parentID } + const claims = [] + let route = { type: "home" } + + const ctx = { + options, + location: undefined, + storage: { memory: makeStore(false), store: makeStore(true) }, + theme: {}, + themeMode: "dark", + renderer: { terminalWidth: 200, terminalHeight: 50, on() {}, off() {} }, + data: { + on(type, handler) { + if (!handlers.has(type)) handlers.set(type, []) + handlers.get(type).push(handler) + return () => handlers.set(type, handlers.get(type).filter((h) => h !== handler)) + }, + listen: () => () => {}, + session: { + list: () => [...sessions.values()], + get: (id) => sessions.get(id), + root: (id) => { + let s = sessions.get(id) + while (s?.parentID) s = sessions.get(s.parentID) + return s?.id ?? id + }, + family(id) { + const root = ctx.data.session.root(id) + return [...sessions.keys()].filter((s) => ctx.data.session.root(s) === root) + }, + cost: () => 0, + status: () => "idle", + message: { + list: (sid) => messages.get(sid) ?? [], + get: (sid, mid) => (messages.get(sid) ?? []).find((m) => m.id === mid), + }, + }, + location: { + model: { list: () => options.__models ?? [] }, + }, + }, + ui: { + slot(claim) { + claims.push(claim) + return () => {} + }, + dialog: { show() {}, set() {}, clear() {} }, + toast: {}, + router: { current: () => route }, + panel: { open: () => true, close() {}, current: () => undefined }, + }, + keymap: { layer() {} }, + } + + const dispose = plugin.setup(ctx) + + let seq = 0 + const id = (p) => `${p}_${String(++seq).padStart(4, "0")}` + + const h = { + ctx, + claims, + emit, + dispose, + /** Opens a session (optionally a sub-agent of `parentID`). */ + session(sid, parentID) { + sessions.set(sid, { id: sid, parentID }) + messages.set(sid, messages.get(sid) ?? []) + if (parentID) emit("session.created", { sessionID: sid, parentID }) + route = { type: "session", sessionID: sid } + return sid + }, + /** The user sends a message. */ + user(sid, text = "…") { + messages.get(sid).push({ type: "user", id: id("usr"), text, time: { created: now } }) + }, + /** An earlier reply in the session, so it names its model (for priming). */ + earlier(sid, providerID, modelID) { + messages.get(sid).push({ type: "user", id: id("usr"), text: "earlier", time: { created: now - 60_000 } }) + messages.get(sid).push({ + type: "assistant", id: id("msg"), agent: "build", model: { providerID, id: modelID }, + time: { created: now - 59_000, completed: now - 50_000 }, finish: "stop", content: [], + tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }, + /** A model switch recorded in the session, as OpenCode shows it. */ + switchModel(sid, providerID, modelID) { + messages.get(sid).push({ type: "model-switched", id: id("sw"), time: { created: now }, model: { providerID, id: modelID } }) + }, + executionStarted(sid) { + emit("session.execution.started", { sessionID: sid }) + }, + executionSucceeded(sid) { + emit("session.execution.succeeded", { sessionID: sid }) + }, + executionInterrupted(sid) { + emit("session.execution.interrupted", { sessionID: sid, reason: "user" }) + }, + compaction(sid, which) { + emit(`session.compaction.${which}`, { sessionID: sid, reason: "auto" }) + }, + /** + * One step, as OpenCode runs it: the request, a wait for the first token, + * streaming, then the step ending -- and after it, each tool running. + * + * - `provider`, `model`: the engine + * - `ttftMs`, `streamMs`: wait for the first token, then streaming + * - `argsMs`: of the streaming, time spent writing tool arguments at the + * end (arrives as tool.input.started/ended, never as deltas) + * - `tokens`: { output, reasoning, input, cache: { read, write } } + * - `finish`: "stop" | "tool-calls" | ... + * - `tools`: [{ name, ms }] run after the step ends + * - `beforeStreamed`: a hook run just before step.streamed, where a test + * sets what a per-step engine will answer with + * - `interruptAfterMs`: stop streaming after this long, without ending + */ + async step(sid, s) { + const mid = id("msg") + const msg = { + type: "assistant", + id: mid, + agent: "build", + model: { providerID: s.provider, id: s.model }, + time: { created: now }, + content: [], + } + messages.get(sid).push(msg) + clock.advance(s.ttftMs ?? 0) + emit("session.step.started", { sessionID: sid, assistantMessageID: mid, model: msg.model }) + emit(s.reasoningFirst ? "session.reasoning.delta" : "session.text.delta", { sessionID: sid, assistantMessageID: mid, delta: "x" }) + if (s.interruptAfterMs !== undefined) { + clock.advance(s.interruptAfterMs) + emit("session.text.delta", { sessionID: sid, assistantMessageID: mid, delta: "x" }) + msg.finish = "error" + msg.tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + return mid + } + const argsMs = s.argsMs ?? 0 + clock.advance((s.streamMs ?? 0) - argsMs) + emit("session.text.delta", { sessionID: sid, assistantMessageID: mid, delta: "x" }) + if (argsMs > 0) { + emit("session.tool.input.started", { sessionID: sid, assistantMessageID: mid, id: "call", name: s.tools?.[0]?.name ?? "write" }) + clock.advance(argsMs) + emit("session.tool.input.ended", { sessionID: sid, assistantMessageID: mid, id: "call", text: "{}" }) + } + if (s.beforeStreamed) await s.beforeStreamed() + msg.tokens = s.tokens + msg.finish = s.finish ?? "stop" + msg.time.completed = now + emit("session.step.streamed", { sessionID: sid, assistantMessageID: mid }) + await settle(40) + emit("session.step.ended", { + sessionID: sid, + assistantMessageID: mid, + finish: msg.finish, + tokens: s.tokens, + cost: 0, + }) + for (const t of s.tools ?? []) { + const start = now + // A tool that does its own work meanwhile -- a sub-agent's turn. + if (t.run) await t.run() + clock.advance(t.ms ?? 0) + msg.content.push({ type: "tool", id: id("tool"), name: t.name, state: { status: "completed" }, time: { created: start, ran: start, completed: now } }) + } + return mid + }, + // ---- what the user sees ---- + /** The sidebar's last-turn box for a session, as the plugin stored it. */ + sidebar(sid) { + return decodeView(lineFor(memory.get("panels"), sid)) + }, + history() { + return memory.get("history").turns + }, + detail(sid) { + return memory.get("turnDetail").bySession[sid] + }, + /** Releases the plugin. The fake clock stays: every test runs on it. */ + restore() { + dispose?.() + }, + } + return h +} + +/** Rows of a view as a label -> value map (first row per label). */ +export const rowsOf = (view) => Object.fromEntries(view.rows.filter(([l]) => l).map(([l, v]) => [l, v]).reverse()) + +// ---- a tiny runner, like the other suites ---------------------------------------- + +let passed = 0 +let failed = 0 +export async function test(name, fn) { + try { + await fn() + passed++ + console.log(" ok ", name) + } catch (e) { + failed++ + console.log(" FAIL", name, "\n ", e.message) + process.exitCode = 1 + } +} +export const done = () => console.log(`\n${passed} passed${failed ? `, ${failed} failed` : ""}`) + +// ---- a Splash engine's counters ------------------------------------------------------ + +/** + * Splash's cumulative counters, as its /metrics text. `add` moves them the + * way one completed request does. + */ +export function splashCounters() { + const c = { requests: 0, decode: 0, decodeMs: 0, prefill: 0, prefillMs: 0, reused: 0 } + return { + c, + add({ output, input = 100, decodeMs = 1_000, prefillMs = 200 }) { + c.requests += 1 + c.decode += output + c.decodeMs += decodeMs + c.prefill += input + c.prefillMs += prefillMs + }, + text: () => + [ + "splash_info 1", + `splash_requests_completed_total ${c.requests}`, + `splash_decode_output_tokens_total ${c.decode}`, + `splash_decode_wall_milliseconds_total ${c.decodeMs}`, + `splash_prefill_input_tokens_total ${c.prefill}`, + `splash_prefill_wall_milliseconds_total ${c.prefillMs}`, + `splash_cache_reused_tokens_total ${c.reused}`, + "splash_drafted_tokens_total 0", + "splash_accepted_draft_tokens_total 0", + ].join("\n"), + } +} diff --git a/test/e2e/turns.e2e.mjs b/test/e2e/turns.e2e.mjs new file mode 100644 index 0000000..7df983c --- /dev/null +++ b/test/e2e/turns.e2e.mjs @@ -0,0 +1,153 @@ +// End-to-end: whole turns through the real entry file, against a fake +// OpenCode and a fixture engine server. See harness.mjs. +// Run with: bun test/e2e/turns.e2e.mjs +import { strict as assert } from "node:assert" +import { startPlugin, engineServer, settle, test, done, rowsOf } from "./harness.mjs" + +const MTPLX = { provider: "mtplx", model: "qwen" } +// An MTPLX receipt for a step of `tokens` generated at `tokS`. +const receipt = (tokens, tokS) => ({ + latest: { completion_tokens: tokens, decode_tok_s: tokS, prefill_tok_s: 450, ttft_s: 2, verify_calls: Math.round(tokens / 3), mean_accept_probability_by_depth: [0.9, 0.8, 0.6] }, +}) + +await test("an MTPLX tool turn: engine figures in the sidebar, each tool in the detail", async () => { + const eng = engineServer() + const h = await startPlugin({ mtplxMetricsUrl: `${eng.url}/metrics` }) + try { + const sid = h.session("ses_a") + h.user(sid) + h.executionStarted(sid) + await h.step(sid, { + ...MTPLX, ttftMs: 2_000, streamMs: 4_000, finish: "tool-calls", + tokens: { input: 900, output: 100, reasoning: 20, cache: { read: 0, write: 0 } }, + tools: [{ name: "bash", ms: 3_000 }], + beforeStreamed: () => { eng.routes["/metrics"] = receipt(120, 30) }, + }) + await h.step(sid, { + ...MTPLX, ttftMs: 500, streamMs: 2_000, finish: "stop", + tokens: { input: 50, output: 60, reasoning: 0, cache: { read: 900, write: 0 } }, + beforeStreamed: () => { eng.routes["/metrics"] = receipt(60, 30) }, + }) + h.executionSucceeded(sid) + await settle() + + const v = h.sidebar(sid) + assert.equal(v.engine, "MTPLX") + const r = rowsOf(v) + assert.equal(r.tokens, "180") + assert.equal(r.speed, "30.0 tok/s") + assert.equal(r.time, "11.50s") + const row = h.history().at(0) + assert.equal(row.source, "engine") + assert.equal(row.steps, 2) + assert.deepEqual(row.tools, { bash: { s: 3, n: 1 } }) + const d = h.detail(sid) + assert.equal(d.time.tools, 3) + assert.equal(d.time.waiting, 2.5) + assert.equal(d.time.generating, 6) + assert.ok(d.engineRows.some(([, val]) => val.includes("at depth 3")), JSON.stringify(d.engineRows)) + assert.ok(!d.engineRows.some(([, val]) => val.includes("11.50s")), "OpenCode's total is not the engine's") + } finally { + h.restore() + eng.stop() + } +}) + +// ---- one execution, two replies (a message queued while the first ran) ------ +// Measured 2026-09-25: reply 1 ended `stop`, the queued message's step began +// 1s later in the same execution, and the execution only succeeded after it. +await test("a queued message: each reply is its own turn, timed from the one before", async () => { + const eng = engineServer() + const h = await startPlugin({ mtplxMetricsUrl: `${eng.url}/metrics` }) + try { + const sid = h.session("ses_q") + h.user(sid, "first") + h.executionStarted(sid) + await h.step(sid, { ...MTPLX, ttftMs: 1_000, streamMs: 9_000, finish: "stop", + tokens: { input: 10, output: 270, reasoning: 0, cache: { read: 0, write: 0 } }, + beforeStreamed: () => { eng.routes["/metrics"] = receipt(270, 30) } }) + await settle() + // Reply 1 is shown before the execution ends. + assert.equal(rowsOf(h.sidebar(sid)).tokens, "270") + assert.equal(rowsOf(h.sidebar(sid)).time, "10.00s") + h.user(sid, "queued") + await h.step(sid, { ...MTPLX, ttftMs: 1_000, streamMs: 4_000, finish: "stop", + tokens: { input: 10, output: 120, reasoning: 0, cache: { read: 0, write: 0 } }, + beforeStreamed: () => { eng.routes["/metrics"] = receipt(120, 30) } }) + h.executionSucceeded(sid) + await settle() + assert.equal(h.history().length, 2, "each reply recorded once") + assert.equal(rowsOf(h.sidebar(sid)).tokens, "120") + // From reply 1's end, not from when the message was typed. + assert.equal(h.history()[0].totalS, 5) + } finally { + h.restore() + eng.stop() + } +}) + +// ---- an interrupted reply -------------------------------------------------- +// Measured: OpenCode records 0 tokens for a step it stopped mid-stream. +await test("an interrupted reply is shown and marked, with no made-up token count", async () => { + const h = await startPlugin({}) + try { + const sid = h.session("ses_i") + h.user(sid) + h.executionStarted(sid) + await h.step(sid, { provider: "lmstudio", model: "m", ttftMs: 900, interruptAfterMs: 6_000 }) + h.executionInterrupted(sid) + await settle() + const v = h.sidebar(sid) + assert.ok(v.notes.includes("interrupted"), JSON.stringify(v)) + assert.equal(rowsOf(v).tokens, undefined, "0 tokens here means unknown") + assert.equal(h.history()[0].outcome, "interrupted") + assert.equal(h.history()[0].skip, "unfinished") + } finally { + h.restore() + } +}) + +// ---- tool-call arguments are generation time -------------------------------- +// Measured: argument deltas never reach a plugin; the start/end events do. +// Unwatched, a write step read 162.9 tok/s against the engine's 36.4. +await test("time spent writing a tool call's arguments counts as generating", async () => { + const h = await startPlugin({}) + try { + const sid = h.session("ses_w") + h.user(sid) + h.executionStarted(sid) + await h.step(sid, { provider: "lmstudio", model: "m", ttftMs: 1_000, streamMs: 10_000, argsMs: 6_000, + finish: "tool-calls", tokens: { input: 10, output: 300, reasoning: 0, cache: { read: 0, write: 0 } }, + tools: [{ name: "write", ms: 10 }] }) + await h.step(sid, { provider: "lmstudio", model: "m", ttftMs: 500, streamMs: 1_000, finish: "stop", + tokens: { input: 10, output: 30, reasoning: 0, cache: { read: 0, write: 0 } } }) + h.executionSucceeded(sid) + await settle() + // 330 tokens over 11s of streaming, arguments included: 30, not 66. + assert.equal(rowsOf(h.sidebar(sid)).speed, "30.0 tok/s") + } finally { + h.restore() + } +}) + +// ---- an engine that isn't there ---------------------------------------------- +await test("an unreachable engine falls back to OpenCode's figures, never a blank box", async () => { + const h = await startPlugin({ mtplxMetricsUrl: "http://127.0.0.1:9/metrics" }) + try { + const sid = h.session("ses_u") + h.user(sid) + h.executionStarted(sid) + await h.step(sid, { ...MTPLX, ttftMs: 1_000, streamMs: 2_000, finish: "stop", + tokens: { input: 10, output: 60, reasoning: 0, cache: { read: 0, write: 0 } } }) + h.executionSucceeded(sid) + await settle(300) + const r = rowsOf(h.sidebar(sid)) + assert.equal(r.tokens, "60") + assert.equal(r.speed, "30.0 tok/s") + assert.equal(h.history()[0].source, "host") + } finally { + h.restore() + } +}) + +done() diff --git a/tui.tsx b/tui.tsx index 66ec151..2941822 100644 --- a/tui.tsx +++ b/tui.tsx @@ -550,6 +550,8 @@ export default Plugin.define({ engine?: TurnRecord["engine"] /** Each step's own engine reading, where the engine is read per step. */ stepEngine?: TurnDetail["stepEngine"] + /** A counter engine's reading at the turn's end. */ + endSample?: unknown }, /** The turn's assistant messages, one per step, oldest first. */ steps: readonly SessionMessageAssistant[], @@ -560,7 +562,13 @@ export default Plugin.define({ */ sameEngine?: TurnRecord["subagents"], /** Compactions in the turn, each bracketed by counter readings. */ - bracketed: readonly Compaction[] = [] + bracketed: readonly Compaction[] = [], + /** + * This turn's own starting reading of a counter engine, taken when the + * turn started. Preferred to the engine-wide baseline, which another + * session's report -- a sub-agent's, on the same engine -- moves. + */ + startSample?: unknown ): Promise { // Readings around each compaction on this engine, to take its request // back out of a counter window. @@ -595,7 +603,8 @@ export default Plugin.define({ ): Promise => { const now = await fetchPromSample(url, spec, http) if (!now) return null - const base0 = base.prom[id] + tier2.endSample = now + const base0 = (startSample as PromSample | undefined) ?? base.prom[id] const prev = base0 ? shiftBaseline(base0, bracketsFor()) : base0 setBase((d) => { d.prom[id] = now @@ -719,7 +728,8 @@ export default Plugin.define({ const label = provider === "llamacpp" ? "llama.cpp" : "llamafile" const now = await fetchLlamaCppCounters(url, http) if (!now) return null // unreachable, or started without --metrics - const base0 = base.llamacpp[provider] + tier2.endSample = now + const base0 = (startSample as LlamaCppCounters | undefined) ?? base.llamacpp[provider] const prev = base0 ? shiftBaseline(base0, bracketsFor()) : base0 setBase((d) => { d.llamacpp[provider] = now @@ -742,7 +752,8 @@ export default Plugin.define({ case "splash": { const now = await fetchSplashSample(cfg.splashBase, http) if (!now) return null - const base0 = base.splash[cfg.splashBase] + tier2.endSample = now + const base0 = (startSample as SplashSample | undefined) ?? base.splash[cfg.splashBase] const prev = base0 ? shiftBaseline(base0, bracketsFor()) : base0 setBase((d) => { d.splash[cfg.splashBase] = now @@ -1008,6 +1019,12 @@ export default Plugin.define({ after?: unknown } const compactions = new Map() + // Each session's reading of its counter engine when its current turn + // started. The engine-wide baseline alone can't bracket a turn: a + // sub-agent on the same engine reports first, moving it to after the + // sub-agent, and the parent's window then held only its last step + // (found by the end-to-end suite; live, it read as "overlapping requests"). + const turnStart = new Map() const compactionsOverlapping = (sessionID: string, from: number, to: number): Compaction[] => (compactions.get(sessionID) ?? []).filter((c) => (c.end ?? to) > from && c.start < to) const compactionsIn = (sessionID: string, from: number, to: number): Array => @@ -1144,6 +1161,7 @@ export default Plugin.define({ sharedWindow: boolean engine?: TurnRecord["engine"] stepEngine?: TurnDetail["stepEngine"] + endSample?: unknown } = { pendingBaseline: false, sharedWindow: false, @@ -1154,7 +1172,14 @@ export default Plugin.define({ // no reading of it to check against: OpenCode's figures only. const bracketed = compactionsOverlapping(sessionID, info.time.created, Date.now()) if (bracketed.some((c) => c.before && c.after)) dbg(` taking ${bracketed.length} compaction(s) out of the window`) - if (!opts.outcome) line = await enrich(provider, model, info, turn, http, tier2, steps, sameEngine, bracketed) + const start = turnStart.get(sessionID) + const startSample = start?.provider === provider ? start.sample : undefined + if (!opts.outcome) { + line = await enrich(provider, model, info, turn, http, tier2, steps, sameEngine, bracketed, startSample) + } + // The end of this reply starts the next one in the same execution. + if (tier2.endSample) turnStart.set(sessionID, { provider, sample: tier2.endSample }) + else turnStart.delete(sessionID) // Recorded before the fallback overwrites it, so history knows which // tier the figures actually came from. } catch (e: unknown) { @@ -1368,6 +1393,19 @@ export default Plugin.define({ const m = selectedModel.get(sid) ?? lastModel(ctx.data.session.message.list(sid) ?? []) dbg(`prime lookup ${sid}: ${m ? `${m.providerID}/${m.id}` : "no model known"}`) if (m) void prime(m.providerID) + const read = m ? readCounters(m.providerID) : undefined + turnStart.delete(sid) + if (m && read) { + read + .then((sample) => { + if (sample) turnStart.set(sid, { provider: m.providerID, sample }) + if (turnStart.size > 64) { + const oldest = turnStart.keys().next().value + if (oldest !== undefined && oldest !== sid) turnStart.delete(oldest) + } + }) + .catch(() => {}) + } }) ) off.push( From acbe866fbeb38ddfa6ad554c8f8d22989cfdcf13 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 00:51:07 -0700 Subject: [PATCH 11/12] Document the details dialog, compaction and the sub-agent fix --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ README.md | 45 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aba991a..ec21d04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## [Unreleased] +### Added +- A **details dialog**, opened by `details ›` under the sidebar boxes, + `ctrl+shift+d` or `/headsup`, and closed the same way or with `esc`. Last + turn on the left, session on the right; one at a time below 110 columns, + switched with `tab`. The sidebar is unchanged. + - Last turn: where the time went, in seconds and shares that add up to the + total; every step with its tool calls and their times; tokens in all + five kinds and context used; the engine's own figures only, marked `◆`, + including MTPLX acceptance at every depth and per-step engine rates; + retry reasons in full. + - Session: speed and time-to-first-token spread (min, median, p90, max), + time split in seconds, tools by time, retries by reason, and coverage: + how many turns had engine figures and why the others did not. +- Compaction is named. OpenCode compacting the conversation mid-turn gets + its own share of the turn's time, and the step it delayed says so. On an + engine that publishes cumulative counters (Splash, llama.cpp, llamafile, + the vLLM family), the compaction's own request is read before and after + and taken out of the turn, so the turn keeps its engine figures; where it + can't be, the reason reads `compaction ran this turn`. + +### Fixed +- A sub-agent on the same counter engine as its parent no longer makes the + parent's turn lose its engine figures. The sub-agent's own report moved + the engine-wide starting reading, so the parent's window held only its + last step and was declined as `overlapping requests`. Each turn now takes + its own starting reading when it starts. +- When engine figures were declined, the dialog says the engine's figures + were left out and why, rather than reading as though the engine reported + nothing. + ## [0.3.3] – 2026-09-25 ### Fixed - Generation speed counts the time a model spends writing a tool call's diff --git a/README.md b/README.md index d3fd2fa..0bde6b4 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ sub-agent 191 tok ``` Two boxes, each opened and closed by clicking its heading: the last turn, -and the session so far. +and the session so far. `details ›` under them opens the full picture: +where each turn's time went, every step and tool call, and the session's +spread and coverage (see [Details](#details)). Requires [**OpenCode 2**](https://opencode.ai/v2/docs). For the v1 line (OpenCode 1.18.x), see @@ -36,6 +38,7 @@ Requires [**OpenCode 2**](https://opencode.ai/v2/docs). For the v1 line - [Install](#install) - [Keys](#keys) +- [Details](#details) - [Configuration](#configuration) - [Supported Engines](#supported-engines) - [Engine Details](#engine-details) @@ -68,10 +71,11 @@ Equivalent, if you keep your config in version control: | --- | --- | | `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 ›`. | -Both are registered with stable command ids (`headsup.toggle`, -`headsup.panel`), so they can be remapped from your own OpenCode keybind -config and are reachable from the command palette. +All three are registered with stable command ids (`headsup.toggle`, +`headsup.panel`, `headsup.details`), so they can be remapped from your own +OpenCode keybind config and are reachable from the command palette. The Session box has no key; click its heading. Collapsed, each box keeps one figure rather than becoming a bare label: @@ -80,6 +84,39 @@ one figure rather than becoming a bare label: ▸ MTPLX · last turn 34.4 tok/s ``` +## 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. +- **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; + context used against the model's limit. +- **◆ Engine**: only what the engine itself measured -- MTPLX's acceptance + at every depth and its verify passes, prefill and decode as tokens and + seconds, cache reuse, draft acceptance, and per-step rates where the + engine is read per step. When its figures were not used, it says which + and why. +- Retry and error reasons in full, and sub-agent totals. + +**Session** (the current model's turns) +- 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. +- **Coverage**: how many turns had the engine's own figures, and why the + rest did not (first turn, compaction, overlapping requests, no engine + telemetry, ...). + +Everything unmarked is OpenCode's own data; `◆` marks the engine's. + ## Configuration Two keys, because two things are genuinely preferences. Everything else From ec859a0640b0d149c9fc1d297eaa660863c7cc45 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Fri, 25 Sep 2026 12:12:36 -0700 Subject: [PATCH 12/12] Describe the dialog as built, not as a stub --- tui.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tui.tsx b/tui.tsx index 2941822..ddce647 100644 --- a/tui.tsx +++ b/tui.tsx @@ -304,13 +304,12 @@ export default Plugin.define({ ) } - // ---- the details dialog (stub) ------------------------------------------ - // A full-detail view opened from the sidebar. This is the measuring stub: - // real figures come later. It checks what the types promise but 2.0.12 - // has not shown yet -- that ui.dialog.show draws our JSX at xlarge, how - // wide that is, that a scrollbox inside it scrolls by wheel and by keys, - // and that a keymap layer inside the dialog can take tab without the - // prompt behind it seeing it. + // ---- the details dialog --------------------------------------------------- + // A full-detail view opened from the sidebar: the last turn (detail.ts) + // beside the session (session.ts). Measured on 2.0.12 before it was + // 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 }>( "details", { initial: { tab: "turn", cols: 0, rows: 0 } }