From 65ab07a8c9fbf785aa1c675f05a2dfb602dc9d22 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 15:40:42 -0700 Subject: [PATCH 01/17] Record the figures a session summary needs in each history row --- history.ts | 22 ++++++++++++++++++++++ test/universal.test.mjs | 20 ++++++++++++++++++++ tui.tsx | 27 +++++++++++++++++++++++++-- universal.ts | 16 ++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/history.ts b/history.ts index 32b81a1..534ab26 100644 --- a/history.ts +++ b/history.ts @@ -55,6 +55,28 @@ export interface TurnRecord { /** Prompt tokens served from cache rather than recomputed. */ cached?: number source: Source + /** Prompt tokens NOT served from cache, summed over the turn's steps. */ + promptTokens?: number + /** Time spent streaming after each step's first token, summed (seconds). */ + streamS?: number + /** Time spent waiting for each step's first token, summed (seconds). */ + waitS?: number + /** Retries OpenCode made across the turn. */ + retries?: number + /** Model requests in the turn: one per step. */ + steps?: number + /** + * Engine-only figures, recorded only when the engine's reading for this + * turn was accepted -- so a session average of them covers only such + * turns. Every other figure in the row is OpenCode's own. + */ + engine?: { + prefillTokS?: number + /** Tokens committed per verify pass (MTPLX's multi-token prediction). */ + mtpX?: number + /** Share of speculative draft tokens accepted (KoboldCpp, Splash). */ + draftAccept?: number + } } export interface History { diff --git a/test/universal.test.mjs b/test/universal.test.mjs index fa4733f..234feb7 100644 --- a/test/universal.test.mjs +++ b/test/universal.test.mjs @@ -343,4 +343,24 @@ test("a one-step turn aggregates to exactly that step", () => { assert.ok(Math.abs(turnRate(140, info, turn).decodeTokS - 140 / 3.13) < 0.01) }) +// ---- what the Session section adds up ---------------------------------------- +test("the turn records time spent waiting for each step's first token", () => { + // a1: created T0, first token T0+7.4s; a2: T0+14s -> T0+20s; a3: T0+26.93s -> T0+34s. + const { turn } = aggregateTurn(turnSteps(toolTurn), marks) + assert.equal(turn.waitMs, 7_400 + 6_000 + 7_070) +}) + +test("the turn records the prompt tokens of every step, not just the last", () => { + // The last step's input is the context the turn ended at (info.tokens.input); + // a cache hit rate needs every step's prompt, since each step read one. + const { turn } = aggregateTurn(turnSteps(toolTurn), marks) + assert.equal(turn.promptTokens, 7000 + 7200 + 7500) +}) + +test("a step with no first token contributes no waiting time and marks it incomplete", () => { + const partial = new Map([...marks].filter(([k]) => k !== "a2")) + const { turn } = aggregateTurn(turnSteps(toolTurn), partial) + assert.equal(turn.waitMs, undefined) +}) + console.log(`\n${passed} passed`) diff --git a/tui.tsx b/tui.tsx index 5c989ea..959b96e 100644 --- a/tui.tsx +++ b/tui.tsx @@ -273,7 +273,12 @@ export default Plugin.define({ * other requests besides this turn: the engine figures were declined * as unattributable, and the line should say why they are missing. */ - tier2: { pendingBaseline: boolean; sharedWindow: boolean }, + tier2: { + pendingBaseline: boolean + sharedWindow: boolean + /** Engine-only figures of an accepted reading, for the history row. */ + engine?: TurnRecord["engine"] + }, /** The turn's assistant messages, one per step, oldest first. */ steps: readonly SessionMessageAssistant[] ): Promise { @@ -331,6 +336,7 @@ export default Plugin.define({ retries: turn?.retries, }) if (line === null) tier2.sharedWindow = true + else if ((turn?.steps ?? 1) === 1 && diff.prefillTokS !== undefined) tier2.engine = { prefillTokS: diff.prefillTokS } return line } @@ -368,6 +374,11 @@ export default Plugin.define({ if (receipts.every((r) => r !== null)) tier2.sharedWindow = true return null } + const verifies = combined.verify_calls ?? 0 + tier2.engine = { + prefillTokS: combined.prefill_tok_s ?? undefined, + mtpX: verifies > 0 && combined.completion_tokens ? combined.completion_tokens / verifies : undefined, + } return formatMtplxLine(combined, model, hostFigures) } @@ -418,6 +429,7 @@ export default Plugin.define({ tier2.sharedWindow = true return null } + tier2.engine = { prefillTokS: t.prefillTokS } return formatLlamaCppLine(t, label, model, hostTtft, hostFigures) } @@ -439,6 +451,7 @@ export default Plugin.define({ tier2.sharedWindow = true return null } + tier2.engine = { prefillTokS: t.prefillTokS, draftAccept: t.draftAcceptRate } return formatSplashLine(t, model, hostTtft, { ...hostFigures, steps: steps.length }) } @@ -461,6 +474,7 @@ export default Plugin.define({ if (perfs.every((p) => p !== null)) tier2.sharedWindow = true return null } + tier2.engine = { prefillTokS: combined.prefillTokS, draftAccept: combined.draftAcceptRate } return formatKoboldLine(combined, model, hostTtft, hostFigures) } // No per-step reads: one read now, which can only describe the @@ -589,7 +603,10 @@ export default Plugin.define({ // them all at once rather than leaving them to run the clock out. const http: HttpOptions = { signal: life.signal } - const tier2 = { pendingBaseline: false, sharedWindow: false } + const tier2: { pendingBaseline: boolean; sharedWindow: boolean; engine?: TurnRecord["engine"] } = { + pendingBaseline: false, + sharedWindow: false, + } let line: string | null = null try { line = await enrich(provider, model, info, turn, http, tier2, steps) @@ -643,6 +660,12 @@ export default Plugin.define({ source: enriched ? "engine" : "host", // Host-derived like every figure in this row; see TurnRecord.ttftSource. ttftSource: "host", + promptTokens: turn?.promptTokens, + streamS: turn?.streamMs ? turn.streamMs / 1000 : undefined, + waitS: turn?.waitMs !== undefined ? turn.waitMs / 1000 : undefined, + retries: turn?.retries, + steps: turn?.steps, + engine: enriched ? tier2.engine : undefined, } setHistory((d) => { d.turns = record({ turns: d.turns }, rec).turns diff --git a/universal.ts b/universal.ts index 7f5dea1..32f9b61 100644 --- a/universal.ts +++ b/universal.ts @@ -30,6 +30,14 @@ export interface Turn { retries?: number /** Assistant messages in the turn, when aggregated; one per step. */ steps?: number + /** + * Time spent waiting for each step's first token, summed: request to + * first streamed delta, per step. Undefined when any step has no first + * token, so a partial sum is never passed off as the turn's. + */ + waitMs?: number + /** Prompt tokens of every step, summed; each step read a prompt. */ + promptTokens?: number } /** @@ -150,6 +158,9 @@ export function aggregateTurn( let streamMs = 0 let timed = true let retries = 0 + let waitMs = 0 + let waited = true + let promptTokens = 0 for (const m of steps) { output += m.tokens?.output ?? 0 reasoning += m.tokens?.reasoning ?? 0 @@ -161,6 +172,9 @@ export function aggregateTurn( } const t = marks.get(m.id) retries += Math.max(0, (t?.attempts ?? 1) - 1) + promptTokens += m.tokens?.input ?? 0 + if (t?.firstAt !== undefined && t.firstAt > m.time.created) waitMs += t.firstAt - m.time.created + else waited = false if (t?.firstAt !== undefined && t.lastAt !== undefined && t.lastAt > t.firstAt) streamMs += t.lastAt - t.firstAt else timed = false } @@ -191,6 +205,8 @@ export function aggregateTurn( streamMs: timed ? streamMs : 0, retries, steps: steps.length, + waitMs: waited ? waitMs : undefined, + promptTokens, } return { info, turn } } From 87130a2e5119dbe06e60a72c26b9631edbef5bc2 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 15:42:48 -0700 Subject: [PATCH 02/17] Add the session summary: generation rate, ttft, cache, time split, engine averages --- package.json | 3 +- session.ts | 203 ++++++++++++++++++++++++++++++++++++++++++ test/session.test.mjs | 160 +++++++++++++++++++++++++++++++++ 3 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 session.ts create mode 100644 test/session.test.mjs diff --git a/package.json b/package.json index 1cec1f6..c0417e7 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/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/references.test.mjs" }, "files": [ "tui.tsx", @@ -35,6 +35,7 @@ "universal.ts", "history.ts", "panels.ts", + "session.ts", "adapters/", "README.md", "LICENSE" diff --git a/session.ts b/session.ts new file mode 100644 index 0000000..e3bf360 --- /dev/null +++ b/session.ts @@ -0,0 +1,203 @@ +// The collapsible Session section: aggregates over one session's turns. +// +// Pure functions only, for the same reason as history.ts and panels.ts: the +// entry file cannot be imported by tests. Built from the history rows the +// plugin already records per turn, so it needs no data of its own. +// +// The per-turn rules carry over to the aggregate: +// - tok/s is generation only: total tokens over total streaming time, never a +// mean of per-turn rates and never a whole-turn figure; +// - averages never mix models -- only the current model's turns count, and +// the heading says so when some were left out; +// - an engine-only figure is averaged over the turns that have it, which are +// the turns whose engine reading was accepted. +// OpenCode's own sidebar already shows the session's tokens, % context used +// and $ spent, so those are deliberately not repeated here. + +import { nn, ni, short } from "./format" +import type { TurnRecord } from "./history" + +/** Recent turns shown in the generation trend. */ +export const TREND_TURNS = 8 + +export interface SessionSummary { + /** Turns counted: this session's, on its current model. */ + turns: number + /** All of this session's turns, whatever the model. */ + totalTurns: number + provider: string + model: string + /** Generation tok/s: total tokens over total streaming time. */ + genTokS?: number + /** Recent turns' generation rates, oldest first. */ + trend: number[] + ttftMedian?: number + ttftMax?: number + /** Cached prompt tokens over all prompt tokens. */ + cacheHit?: number + /** Shares of the counted turns' total time; they sum to 1. */ + time?: { generating: number; waiting: number; other: number } + retries: number + engine?: { prefillTokS?: number; mtpX?: number; draftAccept?: number } +} + +/** A turn's streaming time: recorded, or derived from an older row's rate. */ +function streamOf(t: TurnRecord): number | undefined { + if (t.streamS !== undefined && t.streamS > 0) return t.streamS + // Rows recorded before streamS existed carry a generation rate whose + // window is tokens / rate. A whole-turn rate is not generation, so no. + if (t.rate !== undefined && t.rate > 0 && t.rateWindow !== "whole") return t.tokens / t.rate + return undefined +} + +const mean = (xs: number[]): number | undefined => + xs.length > 0 ? xs.reduce((a, b) => a + b, 0) / xs.length : undefined + +function median(xs: number[]): number | undefined { + if (xs.length === 0) return undefined + const s = [...xs].sort((a, b) => a - b) + const mid = Math.floor(s.length / 2) + return s.length % 2 === 1 ? (s[mid] as number) : ((s[mid - 1] as number) + (s[mid] as number)) / 2 +} + +/** + * The summary for one session, from history rows (newest first, as + * `record` keeps them). Undefined when the session has no turns yet. + */ +export function summariseSession( + history: readonly TurnRecord[], + sessionID: string | undefined +): SessionSummary | undefined { + if (sessionID === undefined) return undefined + const all = history.filter((t) => t.sessionID === sessionID) + const latest = all[0] + if (!latest) return undefined + const turns = all.filter((t) => t.provider === latest.provider && t.model === latest.model) + + let tokens = 0 + let streamS = 0 + for (const t of turns) { + const s = streamOf(t) + if (s !== undefined) { + tokens += t.tokens + streamS += s + } + } + + const trend = turns + .filter((t) => streamOf(t) !== undefined) + .slice(0, TREND_TURNS) + .map((t) => t.tokens / (streamOf(t) as number)) + .reverse() + + const ttfts = turns.map((t) => t.ttft).filter((v): v is number => v !== undefined) + + let cached = 0 + let prompt = 0 + let cacheTurns = 0 + for (const t of turns) { + if (t.promptTokens === undefined || t.cached === undefined) continue + cached += t.cached + prompt += t.promptTokens + t.cached + cacheTurns++ + } + + let gen = 0 + let wait = 0 + let total = 0 + for (const t of turns) { + const s = streamOf(t) + if (s === undefined || t.waitS === undefined || t.totalS === undefined || t.totalS <= 0) continue + gen += s + wait += t.waitS + total += t.totalS + } + const other = Math.max(0, total - gen - wait) + + const eng = (pick: (e: NonNullable) => number | undefined): number | undefined => + mean(turns.map((t) => (t.engine ? pick(t.engine) : undefined)).filter((v): v is number => v !== undefined)) + const engine = { + prefillTokS: eng((e) => e.prefillTokS), + mtpX: eng((e) => e.mtpX), + draftAccept: eng((e) => e.draftAccept), + } + + return { + turns: turns.length, + totalTurns: all.length, + provider: latest.provider, + model: latest.model, + genTokS: streamS > 0 ? tokens / streamS : undefined, + trend, + ttftMedian: median(ttfts), + ttftMax: ttfts.length > 0 ? Math.max(...ttfts) : undefined, + cacheHit: cacheTurns > 0 && prompt > 0 ? cached / prompt : undefined, + time: total > 0 ? { generating: gen / total, waiting: wait / total, other: other / total } : undefined, + retries: turns.reduce((n, t) => n + (t.retries ?? 0), 0), + engine: + engine.prefillTokS !== undefined || engine.mtpX !== undefined || engine.draftAccept !== undefined + ? engine + : undefined, + } +} + +const BARS = "▁▂▃▄▅▆▇█" + +/** A small trend chart, scaled between the lowest and highest value. */ +export function sparkline(values: readonly number[]): string { + if (values.length < 2) return "" + const lo = Math.min(...values) + const hi = Math.max(...values) + return values + .map((v) => { + const i = hi === lo ? 3 : Math.round(((v - lo) / (hi - lo)) * (BARS.length - 1)) + return BARS[i] + }) + .join("") +} + +/** + * The section's heading. Collapsed, it keeps its key figure so the closed + * section is still useful; expanded, the figures follow below it. It names + * the model and turn range when the session changed model partway through. + */ +export function sessionHeading(s: SessionSummary, expanded: boolean): string { + const scope = + s.turns === s.totalTurns + ? `${ni(s.turns)} ${s.turns === 1 ? "turn" : "turns"}` + : `${short(s.model, 16)} · ${ni(s.turns)} of ${ni(s.totalTurns)} turns` + if (expanded) return `▾ Session · ${scope}` + const key = s.genTokS !== undefined ? ` · ${nn(s.genTokS)} tok/s avg` : "" + return `▸ Session · ${scope}${key}` +} + +const pct = (v: number): string => `${ni(v * 100)}%` + +/** The expanded section's rows, as label/value pairs; absent figures leave none. */ +export function sessionRows(s: SessionSummary): Array<[string, string]> { + const rows: Array<[string, string]> = [] + if (s.genTokS !== undefined) { + const spark = sparkline(s.trend) + rows.push(["generation", `${nn(s.genTokS)} tok/s avg${spark ? ` ${spark}` : ""}`]) + } + if (s.ttftMedian !== undefined && s.ttftMax !== undefined) { + rows.push([ + "ttft", + s.turns > 1 ? `${nn(s.ttftMedian, 2)}s median · ${nn(s.ttftMax, 2)}s max` : `${nn(s.ttftMedian, 2)}s`, + ]) + } + if (s.cacheHit !== undefined) rows.push(["cache", `${pct(s.cacheHit)} hit`]) + if (s.time) { + rows.push(["time", `${pct(s.time.generating)} gen · ${pct(s.time.waiting)} wait · ${pct(s.time.other)} other`]) + } + if (s.engine) { + const e = [ + s.engine.mtpX !== undefined ? `MTP ${nn(s.engine.mtpX, 2)}x` : "", + s.engine.draftAccept !== undefined ? `draft ${pct(s.engine.draftAccept)}` : "", + s.engine.prefillTokS !== undefined ? `prefill ${ni(s.engine.prefillTokS)} tok/s` : "", + ].filter(Boolean) + if (e.length > 0) rows.push(["engine", e.join(" · ")]) + } + if (s.retries > 0) rows.push(["retries", ni(s.retries)]) + return rows +} diff --git a/test/session.test.mjs b/test/session.test.mjs new file mode 100644 index 0000000..fe55276 --- /dev/null +++ b/test/session.test.mjs @@ -0,0 +1,160 @@ +// Validates session.ts -- the collapsible Session section's figures. +// +// Everything here is an aggregate over one session's history rows. The rules +// it must hold are the per-turn rules, applied across turns: tok/s is +// generation only (tokens over streaming time), averages never mix two +// models, and an engine-only figure is averaged only over turns that had it. +// Run with: bun test/session.test.mjs +import { strict as assert } from "node:assert" +import { summariseSession, sessionHeading, sessionRows, sparkline } from "../session.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 SID = "ses_a" +// History is newest first, as `record` keeps it. +const row = (over = {}) => ({ + at: 0, + provider: "mtplx", + model: "qwen", + sessionID: SID, + tokens: 100, + rate: 50, + rateWindow: "decode", + ttft: 1.0, + totalS: 10, + streamS: 2, + waitS: 1, + promptTokens: 200, + cached: 800, + source: "host", + ...over, +}) + +test("only this session's turns are counted", () => { + const s = summariseSession([row(), row({ sessionID: "ses_b" }), row()], SID) + assert.equal(s.turns, 2) + assert.equal(summariseSession([row({ sessionID: "ses_b" })], SID), undefined) +}) + +test("generation tok/s is total tokens over total streaming time", () => { + // 100 tok / 2s and 300 tok / 3s -> 400 / 5 = 80, not the mean of 50 and 100. + const s = summariseSession([row({ tokens: 300, streamS: 3 }), row({ tokens: 100, streamS: 2 })], SID) + assert.equal(s.genTokS, 80) +}) + +test("an older row without streaming time contributes through its decode rate", () => { + // Rows recorded before streamS existed: tokens / rate is their stream time. + const s = summariseSession([row({ streamS: undefined, tokens: 100, rate: 50 })], SID) + assert.equal(s.genTokS, 50) +}) + +test("a whole-turn rate from an older row is never counted as generation", () => { + const s = summariseSession([row({ streamS: undefined, rateWindow: "whole", rate: 3.7 })], SID) + assert.equal(s.genTokS, undefined) +}) + +test("the trend is recent turns' generation rates, oldest first, at most eight", () => { + const rows = Array.from({ length: 10 }, (_, i) => row({ rate: 10 + i, streamS: undefined })) + // newest first: rate 10 is newest, so oldest-first reads 17..10 for the last 8 + const s = summariseSession(rows, SID) + const want = [17, 16, 15, 14, 13, 12, 11, 10] + assert.equal(s.trend.length, want.length) + s.trend.forEach((v, i) => assert.ok(Math.abs(v - want[i]) < 1e-9, `${v} vs ${want[i]}`)) +}) + +test("ttft is the median and the worst, not a mean a cold first turn drags up", () => { + const s = summariseSession([row({ ttft: 0.5 }), row({ ttft: 0.7 }), row({ ttft: 17.6 })], SID) + assert.equal(s.ttftMedian, 0.7) + assert.equal(s.ttftMax, 17.6) +}) + +test("cache hit is cached over all prompt tokens, across turns", () => { + // (800 + 100) cached of (200+800 + 900+100) prompt = 900 / 2000. + const s = summariseSession([row(), row({ cached: 100, promptTokens: 900 })], SID) + assert.equal(s.cacheHit, 900 / 2000) +}) + +test("the time split is generating, waiting, and everything else", () => { + // 20s total: 4s streaming, 2s waiting, 14s other (tools, retries, overhead). + const s = summariseSession([row(), row()], SID) + assert.equal(s.time.generating, 4 / 20) + assert.equal(s.time.waiting, 2 / 20) + assert.equal(s.time.other, 14 / 20) +}) + +test("averages never mix models: only the current model's turns count", () => { + const s = summariseSession([row({ model: "b", rate: 200, tokens: 200, streamS: 1 }), row(), row()], SID) + assert.equal(s.turns, 1) + assert.equal(s.totalTurns, 3) + assert.equal(s.model, "b") + assert.equal(s.genTokS, 200) +}) + +test("engine-only figures average over the turns that have them", () => { + const s = summariseSession( + [row({ engine: { mtpX: 3.0, prefillTokS: 400 } }), row(), row({ engine: { mtpX: 4.0, prefillTokS: 500 } })], + SID + ) + assert.equal(s.engine.mtpX, 3.5) + assert.equal(s.engine.prefillTokS, 450) + assert.equal(s.engine.draftAccept, undefined) +}) + +test("retries are summed", () => { + assert.equal(summariseSession([row({ retries: 2 }), row({ retries: 1 }), row()], SID).retries, 3) +}) + +test("the collapsed heading keeps its key figure", () => { + const s = summariseSession([row(), row()], SID) + assert.equal(sessionHeading(s, false), "▸ Session · 2 turns · 50.0 tok/s avg") + assert.equal(sessionHeading(s, true), "▾ Session · 2 turns") +}) + +test("a heading says which turns count when the model changed", () => { + const s = summariseSession([row({ model: "b" }), row(), row()], SID) + assert.equal(sessionHeading(s, true), "▾ Session · b · 1 of 3 turns") +}) + +test("one turn is singular", () => { + assert.equal(sessionHeading(summariseSession([row()], SID), true), "▾ Session · 1 turn") +}) + +test("rows are label/value pairs, and an absent figure leaves no row", () => { + const s = summariseSession([row({ ttft: undefined, cached: undefined, promptTokens: undefined })], SID) + const labels = sessionRows(s).map(([l]) => l) + assert.ok(!labels.includes("ttft"), labels.join(",")) + assert.ok(!labels.includes("cache"), labels.join(",")) + assert.ok(labels.includes("generation")) +}) + +test("rows read as aggregates: avg, median, max, %", () => { + const s = summariseSession( + [row({ ttft: 0.5, retries: 2, engine: { mtpX: 3.4, prefillTokS: 449 } }), row({ ttft: 17.6 })], + SID + ) + const rows = Object.fromEntries(sessionRows(s)) + assert.ok(rows.generation.startsWith("50.0 tok/s avg"), rows.generation) + assert.equal(rows.ttft, "9.05s median · 17.60s max") + assert.equal(rows.cache, "80% hit") + assert.equal(rows.time, "20% gen · 10% wait · 70% other") + assert.equal(rows.engine, "MTP 3.40x · prefill 449 tok/s") + assert.equal(rows.retries, "2") +}) + +test("the sparkline scales between the lowest and highest rate", () => { + assert.equal(sparkline([10, 20, 30]), "▁▅█") + assert.equal(sparkline([5, 5]), "▄▄", "a flat trend sits mid-height") + assert.equal(sparkline([7]), "", "one point is not a trend") +}) + +console.log(`\n${passed} passed`) From 59e8aa21aa5e3c4af8e9e3342798d903074f18e2 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 15:43:56 -0700 Subject: [PATCH 03/17] Render the Session section below the per-turn figures, collapsed by default --- CHANGELOG.md | 19 ++++++++++++++++++ README.md | 10 ++++++++-- tui.tsx | 54 +++++++++++++++++++++++++++++++++++++++++++++++----- universal.ts | 9 ++++++++- 4 files changed, 84 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13aab43..d414552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## [Unreleased] +### Added +- A **Session** section below the per-turn figures, collapsed by default: + click its heading to open it. Collapsed, it still shows the session's + generation speed (`▸ Session · 14 turns · 48.2 tok/s avg`). Open, it + shows generation tok/s with a trend of recent turns, TTFT median and + worst, cache hit rate, how the time split between generating, waiting + for the first token and everything else, the engine's own averages + (MTP or draft acceptance, prefill rate) where the engine provides them, + and retries. Only the current model's turns count; the heading says so + when the model changed partway through. The session's tokens, context + and cost are left to OpenCode's own sidebar. +- `sessionBackground` option: the theme's offset shade behind the Session + section. + +### Changed +- The per-turn block's first line (engine and model) is bold, matching + OpenCode's own sidebar sections. + ## [0.2.4] – 2026-09-23 ### Changed - `mtplx` is read at the end of every step, not once per turn, so a turn diff --git a/README.md b/README.md index 9b043ac..56f792a 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Collapsed, the line keeps one figure rather than becoming a bare label: ## Configuration -One key, because one figure is genuinely a preference. Everything else +Two keys, because two things are genuinely preferences. Everything else appears exactly when its underlying data exists and stays silent when it does not — there is nothing to choose. @@ -79,7 +79,7 @@ does not — there is nothing to choose. "plugins": [ { "package": "@banburist/opencode-headsup", - "options": { "showContext": false } + "options": { "showContext": false, "sessionBackground": false } } ] } @@ -92,6 +92,12 @@ Adds a `13% prompt/limit` line, computed as config in `opencode.json`. Labeled as `prompt/limit` rather than `context used`. +**sessionBackground** (default `false`) + +Puts your theme's offset panel shade behind the Session section. Off by +default because some themes and terminals use a transparent background, +where the shade can disappear. + ### Endpoints Engine endpoints use the defaults below, overridable per key or by env var. diff --git a/tui.tsx b/tui.tsx index 959b96e..b4db936 100644 --- a/tui.tsx +++ b/tui.tsx @@ -32,6 +32,7 @@ import type { HttpOptions } from "./http" import { universalLine, turnRate, turnSteps, aggregateTurn, type Turn, type Display, DEFAULT_DISPLAY } from "./universal" import { record, formatHistory, formatCollapsedLine, latestFor, type History, type TurnRecord } from "./history" import { emptyPanels, lineFor, keyFor, setLine, LatestPerKey, type Panels } from "./panels" +import { summariseSession, sessionHeading, sessionRows } from "./session" import { fetchMtplxLatest, formatMtplxLine, combineMtplxSteps, type MtplxLatest } from "./adapters/mtplx" import { fetchOmlxSample, formatOmlxLine, omlxIsThisTurn, type OmlxSample } from "./adapters/omlx" @@ -122,6 +123,7 @@ function readConfig(options: Readonly>): Config { mlxServeKey: str(options["mlxServeApiKey"], "MLX_API_KEY", ""), display: { context: bool(options["showContext"], DEFAULT_DISPLAY.context), + sessionBackground: bool(options["sessionBackground"], DEFAULT_DISPLAY.sessionBackground), }, } } @@ -152,6 +154,8 @@ interface Baselines { /** Durable UI preference, independent of any one turn. */ interface UiState { collapsed: boolean + /** The Session section is expanded. Collapsed by default. */ + sessionOpen?: boolean } // ---- entry ------------------------------------------------------------------ @@ -202,6 +206,11 @@ export default Plugin.define({ d.collapsed = !d.collapsed }).catch((e: unknown) => dbg(`ui write failed: ${String(e)}`)) } + const toggleSession = (): void => { + setUi((d) => { + d.sessionOpen = !d.sessionOpen + }).catch((e: unknown) => dbg(`ui write failed: ${String(e)}`)) + } const show = (text: string, sessionID: string, key: string): void => { setPanel((d) => { @@ -912,12 +921,47 @@ export default Plugin.define({ // highlight) instead of just toggling. This is a footer we // render, not a passage a user would want to copy, so turning // selection off is the right default rather than a workaround. + if (ui.collapsed) { + return ( + toggleCollapsed()}> + {formatCollapsedLine(latestFor(history.turns, input.sessionID), input.sessionID)} + + ) + } + // The per-turn block keeps OpenCode's own sidebar style: its first + // line (engine and model) bold as a title, the figures below. + const [title, ...figures] = lineFor(panel, input.sessionID).split("\n") + // The Session section: below the per-turn block, collapsed by + // default, set apart by a blank line, a bold clickable heading + // and subdued label/value rows -- so the two never read as one + // list. Absent until the session has a recorded turn. + const summary = summariseSession(history.turns, input.sessionID) + const open = ui.sessionOpen === true return ( - toggleCollapsed()}> - {ui.collapsed - ? formatCollapsedLine(latestFor(history.turns, input.sessionID), input.sessionID) - : lineFor(panel, input.sessionID)} - + + toggleCollapsed()}> + {title} + {figures.length > 0 ? `\n${figures.join("\n")}` : ""} + + {summary ? ( + + toggleSession()}> + {sessionHeading(summary, open)} + + {open ? ( + + {sessionRows(summary) + .map(([label, value]) => ` ${label.padEnd(11)}${value}`) + .join("\n")} + + ) : null} + + ) : null} + ) }, }) diff --git a/universal.ts b/universal.ts index 32f9b61..485afea 100644 --- a/universal.ts +++ b/universal.ts @@ -242,9 +242,16 @@ export function aggregateTurn( */ export interface Display { context: boolean + /** + * Put the theme's offset panel shade behind the Session section. Off by + * default: on themes and terminals with a transparent background the shade + * can vanish, and the section already reads as separate by its heading, + * spacing and subdued rows. Judged live, in the user's own theme. + */ + sessionBackground: boolean } -export const DEFAULT_DISPLAY: Display = { context: false } +export const DEFAULT_DISPLAY: Display = { context: false, sessionBackground: false } export function universalLine( provider: string, From 435a5b4db7670f19f9825e2d14bc26da86a25753 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 15:52:48 -0700 Subject: [PATCH 04/17] Log session trees and sub-agent sessions under debug --- tui.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tui.tsx b/tui.tsx index b4db936..55ddc89 100644 --- a/tui.tsx +++ b/tui.tsx @@ -596,6 +596,21 @@ export default Plugin.define({ .map((m) => `${(m.tokens?.output ?? 0) + (m.tokens?.reasoning ?? 0)}${m.finish ? `/${m.finish}` : ""}`) .join(", ")}]; retries ${turn?.retries ?? 0}` ) + // Diagnostics for sub-agent roll-ups: which session this turn is, its + // parent if it is a sub-agent, and the session tree OpenCode reports. + if (HUD_DEBUG) { + try { + const parent = ctx.data.session.get(sessionID)?.parentID + const family = ctx.data.session.family(sessionID) + const recorded = family.map((id) => `${id}:${history.turns.filter((t) => t.sessionID === id).length}`) + dbg( + ` session ${sessionID}${parent ? ` (sub-agent of ${parent})` : ""}; family [${recorded.join(", ")}]; ` + + `status ${family.map((id) => ctx.data.session.status(id)).join("/")}` + ) + } catch (e: unknown) { + dbg(` session lookup threw: ${String(e)}`) + } + } const provider = info.model?.providerID ?? "" const model = info.model?.id ?? "" @@ -790,6 +805,17 @@ export default Plugin.define({ dbg(`event execution.started ${(evt as { data?: { sessionID?: string } }).data?.sessionID ?? "?"}`) }) ) + off.push( + ctx.data.on("session.created", (evt) => { + const d = (evt as { data?: { sessionID?: string; parentID?: string } }).data + dbg(`event session.created ${d?.sessionID ?? "?"}${d?.parentID ? ` parent ${d.parentID}` : ""}`) + }) + ) + off.push( + ctx.data.on("session.execution.succeeded", (evt) => { + dbg(`event execution.succeeded ${(evt as { data?: { sessionID?: string } }).data?.sessionID ?? "?"}`) + }) + ) off.push( ctx.data.on("session.step.started", (evt) => { const d = (evt as { data?: { assistantMessageID?: string; model?: { providerID?: string; id?: string } } }).data From 566bff3f3f79ba828f6c5ac772ac6972aff76008 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 16:09:36 -0700 Subject: [PATCH 05/17] Roll up a turn's sub-agents onto its line and into the session totals --- CHANGELOG.md | 5 ++++ history.ts | 6 ++++ session.ts | 69 ++++++++++++++++++++++++++++++++++++++++++- test/session.test.mjs | 50 ++++++++++++++++++++++++++++++- tui.tsx | 40 ++++++++++++++++++++++++- 5 files changed, 167 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d414552..a05e53b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ and retries. Only the current model's turns count; the heading says so when the model changed partway through. The session's tokens, context and cost are left to OpenCode's own sidebar. +- Sub-agent roll-ups. A turn that started sub-agents adds a line to the + per-turn figures, `+2 sub-agents 4210 tok 38.10s $0.012`: their tokens + and cost summed, and the time from the first starting to the last + finishing. Rates are never combined across them. The Session section + totals them in a `sub-agents` row. - `sessionBackground` option: the theme's offset shade behind the Session section. diff --git a/history.ts b/history.ts index 534ab26..9c967d5 100644 --- a/history.ts +++ b/history.ts @@ -70,6 +70,12 @@ export interface TurnRecord { * turn was accepted -- so a session average of them covers only such * turns. Every other figure in the row is OpenCode's own. */ + /** + * Sub-agents that ran during this turn, each in its own child session: + * their summed tokens and cost, and the span they ran (they can run in + * parallel, so not a sum). Rates are never combined across them. + */ + subagents?: { count: number; tokens: number; spanS: number; cost?: number } engine?: { prefillTokS?: number /** Tokens committed per verify pass (MTPLX's multi-token prediction). */ diff --git a/session.ts b/session.ts index e3bf360..b60cd85 100644 --- a/session.ts +++ b/session.ts @@ -14,7 +14,7 @@ // OpenCode's own sidebar already shows the session's tokens, % context used // and $ spent, so those are deliberately not repeated here. -import { nn, ni, short } from "./format" +import { nn, ni, short, money } from "./format" import type { TurnRecord } from "./history" /** Recent turns shown in the generation trend. */ @@ -39,6 +39,54 @@ export interface SessionSummary { time?: { generating: number; waiting: number; other: number } retries: number engine?: { prefillTokS?: number; mtpX?: number; draftAccept?: number } + /** Sub-agents across the counted turns: how many, their tokens and cost. */ + subagents?: { count: number; tokens: number; cost?: number } +} + +export type SubagentRollup = NonNullable + +/** + * The sub-agents of one turn: history rows of this session's sub-agent + * sessions (`childIDs`) that finished inside the turn (`since`..`until`, + * epoch ms). Tokens and cost are summed; the time is the span from the + * first one starting to the last one finishing, since sub-agents can run in + * parallel. Undefined when none ran. + */ +export function rollupSubagents( + history: readonly TurnRecord[], + childIDs: readonly string[], + since: number, + until: number +): SubagentRollup | undefined { + const ids = new Set(childIDs) + const rows = history.filter( + (t) => t.sessionID !== undefined && ids.has(t.sessionID) && t.at >= since && t.at <= until + ) + if (rows.length === 0) return undefined + let start = Infinity + let end = -Infinity + let cost = 0 + let sawCost = false + for (const t of rows) { + end = Math.max(end, t.at) + start = Math.min(start, t.at - (t.totalS ?? 0) * 1000) + if (typeof t.cost === "number" && t.cost > 0) { + cost += t.cost + sawCost = true + } + } + return { + count: new Set(rows.map((t) => t.sessionID)).size, + tokens: rows.reduce((n, t) => n + t.tokens, 0), + spanS: (end - start) / 1000, + cost: sawCost ? cost : undefined, + } +} + +/** The per-turn block's sub-agent line: sums only, never a rate. */ +export function formatSubagentLine(r: SubagentRollup): string { + const cost = money(r.cost) + return `+${ni(r.count)} sub-agent${r.count === 1 ? "" : "s"} ${ni(r.tokens)} tok ${nn(r.spanS, 2)}s${cost ? ` ${cost}` : ""}` } /** A turn's streaming time: recorded, or derived from an older row's rate. */ @@ -122,6 +170,20 @@ export function summariseSession( draftAccept: eng((e) => e.draftAccept), } + let subCount = 0 + let subTokens = 0 + let subCost = 0 + let subSawCost = false + for (const t of turns) { + if (!t.subagents) continue + subCount += t.subagents.count + subTokens += t.subagents.tokens + if (t.subagents.cost !== undefined) { + subCost += t.subagents.cost + subSawCost = true + } + } + return { turns: turns.length, totalTurns: all.length, @@ -138,6 +200,7 @@ export function summariseSession( engine.prefillTokS !== undefined || engine.mtpX !== undefined || engine.draftAccept !== undefined ? engine : undefined, + subagents: subCount > 0 ? { count: subCount, tokens: subTokens, cost: subSawCost ? subCost : undefined } : undefined, } } @@ -198,6 +261,10 @@ export function sessionRows(s: SessionSummary): Array<[string, string]> { ].filter(Boolean) if (e.length > 0) rows.push(["engine", e.join(" · ")]) } + if (s.subagents) { + const cost = money(s.subagents.cost) + rows.push(["sub-agents", `${ni(s.subagents.count)} · ${ni(s.subagents.tokens)} tok${cost ? ` · ${cost}` : ""}`]) + } if (s.retries > 0) rows.push(["retries", ni(s.retries)]) return rows } diff --git a/test/session.test.mjs b/test/session.test.mjs index fe55276..e258882 100644 --- a/test/session.test.mjs +++ b/test/session.test.mjs @@ -6,7 +6,7 @@ // models, and an engine-only figure is averaged only over turns that had it. // Run with: bun test/session.test.mjs import { strict as assert } from "node:assert" -import { summariseSession, sessionHeading, sessionRows, sparkline } from "../session.ts" +import { summariseSession, sessionHeading, sessionRows, sparkline, rollupSubagents, formatSubagentLine } from "../session.ts" let passed = 0 function test(name, fn) { @@ -157,4 +157,52 @@ test("the sparkline scales between the lowest and highest rate", () => { assert.equal(sparkline([7]), "", "one point is not a trend") }) +// ---- sub-agents --------------------------------------------------------------- +// A sub-agent runs in its own child session, so its turns are recorded under +// that session, not the parent's (measured: the parent's family listed the +// child with 1 history row when the parent's turn ended, 11s after the child +// finished). The roll-up adds up the child rows that finished during the +// parent's turn. +const child = (over = {}) => row({ sessionID: "ses_child", at: 20_000, totalS: 25, tokens: 228, cost: 0.004, ...over }) + +test("sub-agent rows that finished during the turn are rolled up", () => { + const r = rollupSubagents([child(), child({ sessionID: "ses_other_child", tokens: 100, at: 30_000, totalS: 5 })], + ["ses_child", "ses_other_child"], 0, 40_000) + assert.equal(r.count, 2) + assert.equal(r.tokens, 328) + assert.ok(Math.abs(r.cost - 0.004 * 2) < 1e-12) +}) + +test("a sub-agent row from an earlier turn is not this turn's", () => { + assert.equal(rollupSubagents([child({ at: 5_000 })], ["ses_child"], 10_000, 40_000), undefined) +}) + +test("rows of sessions that are not this session's sub-agents are ignored", () => { + assert.equal(rollupSubagents([child()], ["ses_somebody_else"], 0, 40_000), undefined) +}) + +test("sub-agent time is the span they ran, not a sum -- they can run in parallel", () => { + // Two 10s sub-agents side by side, both finishing at 20s: 10s of wall time, not 20. + const r = rollupSubagents( + [child({ at: 20_000, totalS: 10 }), child({ sessionID: "ses_b", at: 20_000, totalS: 10 })], + ["ses_child", "ses_b"], 0, 40_000) + assert.equal(r.spanS, 10) +}) + +test("the per-turn line sums tokens, time and cost but never a rate", () => { + const r = rollupSubagents([child()], ["ses_child"], 0, 40_000) + const line = formatSubagentLine(r) + assert.equal(line, "+1 sub-agent 228 tok 25.00s $0.0040") + assert.ok(!line.includes("tok/s")) +}) + +test("the session section sums each turn's sub-agents", () => { + const s = summariseSession( + [row({ subagents: { count: 2, tokens: 500, spanS: 30, cost: 0.01 } }), row(), row({ subagents: { count: 1, tokens: 100, spanS: 5 } })], + SID + ) + assert.deepEqual(s.subagents, { count: 3, tokens: 600, cost: 0.01 }) + assert.equal(Object.fromEntries(sessionRows(s))["sub-agents"], "3 · 600 tok · $0.010") +}) + console.log(`\n${passed} passed`) diff --git a/tui.tsx b/tui.tsx index 55ddc89..89ee44f 100644 --- a/tui.tsx +++ b/tui.tsx @@ -32,7 +32,7 @@ import type { HttpOptions } from "./http" import { universalLine, turnRate, turnSteps, aggregateTurn, type Turn, type Display, DEFAULT_DISPLAY } from "./universal" import { record, formatHistory, formatCollapsedLine, latestFor, type History, type TurnRecord } from "./history" import { emptyPanels, lineFor, keyFor, setLine, LatestPerKey, type Panels } from "./panels" -import { summariseSession, sessionHeading, sessionRows } from "./session" +import { summariseSession, sessionHeading, sessionRows, rollupSubagents, formatSubagentLine } from "./session" import { fetchMtplxLatest, formatMtplxLine, combineMtplxSteps, type MtplxLatest } from "./adapters/mtplx" import { fetchOmlxSample, formatOmlxLine, omlxIsThisTurn, type OmlxSample } from "./adapters/omlx" @@ -662,6 +662,27 @@ export default Plugin.define({ else if (tier2.sharedWindow) line += "\nengine data skipped: overlapping requests" } + // Sub-agents that ran during this turn, each in its own child session + // whose turns are recorded under that session (measured: the child's + // row existed, and the child had finished, 11s before the parent's turn + // ended). Their tokens, time and cost are summed onto one line; rates + // are never combined, since a sub-agent can run on another model. + let subagents: TurnRecord["subagents"] + try { + const descendants = ctx.data.session.family(sessionID).filter((id) => { + let p = ctx.data.session.get(id)?.parentID + for (let hops = 0; p && hops < 16; hops++) { + if (p === sessionID) return true + p = ctx.data.session.get(p)?.parentID + } + return false + }) + subagents = rollupSubagents(history.turns, descendants, info.time.created, Date.now()) + } catch (e: unknown) { + dbg(`sub-agent lookup threw: ${String(e)}`) + } + if (subagents) line += `\n${formatSubagentLine(subagents)}` + // Keep the turn for the drill-down. Every figure below is OpenCode's own, // whatever tier drew the sidebar line; `source` records only which tier // that was, so a row that differs from the live line can be explained. @@ -690,6 +711,7 @@ export default Plugin.define({ retries: turn?.retries, steps: turn?.steps, engine: enriched ? tier2.engine : undefined, + subagents, } setHistory((d) => { d.turns = record({ turns: d.turns }, rec).turns @@ -805,6 +827,22 @@ export default Plugin.define({ dbg(`event execution.started ${(evt as { data?: { sessionID?: string } }).data?.sessionID ?? "?"}`) }) ) + off.push( + ctx.data.on("session.step.streamed", (evt) => { + // Whether an engine that keeps only its latest request has already + // recorded the step when streaming ends -- step.ended comes only + // after the step's tools have run (measured: 29ms after a sub-agent + // on the same engine finished), by which time `latest` can be the + // tool's request, not the step's. + const id = (evt as { data?: { assistantMessageID?: string } }).data?.assistantMessageID ?? "?" + dbg(`event step.streamed ${id}`) + if (stepProvider.get(id) === "mtplx") { + fetchMtplxLatest(cfg.mtplxUrl, { signal: life.signal }) + .then((l) => dbg(` probe mtplx latest at step.streamed: ${l?.completion_tokens ?? "none"} tok`)) + .catch(() => {}) + } + }) + ) off.push( ctx.data.on("session.created", (evt) => { const d = (evt as { data?: { sessionID?: string; parentID?: string } }).data From d9bdbfe01e77ddb0e0ba119669bc12b459b4580f Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 16:16:22 -0700 Subject: [PATCH 06/17] Read latest-request engines when a step finishes streaming, not after its tools --- CHANGELOG.md | 7 +++++ tui.tsx | 88 +++++++++++++++++++++++++++++++--------------------- 2 files changed, 59 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a05e53b..1eeb0e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ section. ### Changed +- `mtplx`, `koboldcpp` and `mlxserve` are read when each step finishes + streaming, not when OpenCode marks the step ended. For a step that calls + tools, "ended" comes only after the tools have run, and a sub-agent on the + same engine had replaced the step's figures by then, so the turn was + declined with `engine data skipped: overlapping requests`. Measured on + MTPLX: 194 tokens (the step's own) at the end of streaming, 163 (the + sub-agent's) at "ended". - The per-turn block's first line (engine and model) is bold, matching OpenCode's own sidebar sections. diff --git a/tui.tsx b/tui.tsx index 89ee44f..60a655f 100644 --- a/tui.tsx +++ b/tui.tsx @@ -771,6 +771,10 @@ export default Plugin.define({ stepModel.set(id, d.model.id) bound(stepModel) } + // A new attempt at the step: any read from an earlier attempt is not + // this attempt's, and would block the new read (readStep keeps the + // first read per step). + stepReads.delete(id) const t = turnFor(id) t.firstAt = undefined t.lastAt = undefined @@ -783,32 +787,55 @@ export default Plugin.define({ if (typeof sid === "string") execStart.set(sid, Date.now()) }) ) + // Read engines that keep only their latest request when a step's + // streaming ends. Not at step.ended: for a step that calls tools, that + // fires only after the tools have run, so a tool using the same engine + // -- a sub-agent, measured -- has replaced `latest` by then (step.ended + // read 163 tok, the sub-agent's; step.streamed read 194, the step's + // own). At step.streamed the engine already held the step on all five + // steps measured. step.ended stays as a fallback for a step whose + // stream event never arrived. + const readStep = (id: string, moment: string): void => { + if (stepReads.has(id)) return + const provider = stepProvider.get(id) + const http: HttpOptions = { signal: life.signal } + let read: Promise | undefined + if (provider === "mtplx") { + const r = fetchMtplxLatest(cfg.mtplxUrl, http).catch(() => null) + r.then((l) => dbg(` mtplx receipt at ${moment}: ${l?.completion_tokens ?? "none"} tok`)).catch(() => {}) + read = r + } else if (provider === "koboldcpp" || provider === "kobold") { + const r = fetchKoboldPerf(cfg.koboldBase, http).catch(() => null) + r.then((p) => dbg(` koboldcpp receipt at ${moment}: ${p?.last_token_count ?? "none"} tok`)).catch(() => {}) + read = r + } else if (provider === "mlxserve" || provider === "mlx-serve") { + const r = fetchMlxServeRequests(cfg.mlxServeBase, stepModel.get(id), cfg.mlxServeKey || undefined, http).catch( + () => null + ) + r.then((recs) => dbg(` mlxserve receipt at ${moment}: ${recs?.[0]?.completionTokens ?? "none"} tok`)).catch( + () => {} + ) + read = r + } + if (read) { + stepReads.set(id, read) + bound(stepReads) + } + } + const stepID = (evt: unknown): string | undefined => { + const id = (evt as { data?: { assistantMessageID?: string } }).data?.assistantMessageID + return typeof id === "string" ? id : undefined + } + off.push( + ctx.data.on("session.step.streamed", (evt) => { + const id = stepID(evt) + if (id) readStep(id, "step.streamed") + }) + ) off.push( ctx.data.on("session.step.ended", (evt) => { - const id = (evt as { data?: { assistantMessageID?: string } }).data?.assistantMessageID - if (typeof id !== "string") return - const provider = stepProvider.get(id) - const http: HttpOptions = { signal: life.signal } - let read: Promise | undefined - if (provider === "mtplx") { - const r = fetchMtplxLatest(cfg.mtplxUrl, http).catch(() => null) - r.then((l) => dbg(` mtplx receipt at step.ended: ${l?.completion_tokens ?? "none"} tok`)).catch(() => {}) - read = r - } else if (provider === "koboldcpp" || provider === "kobold") { - const r = fetchKoboldPerf(cfg.koboldBase, http).catch(() => null) - r.then((p) => dbg(` koboldcpp receipt at step.ended: ${p?.last_token_count ?? "none"} tok`)).catch(() => {}) - read = r - } else if (provider === "mlxserve" || provider === "mlx-serve") { - const r = fetchMlxServeRequests(cfg.mlxServeBase, stepModel.get(id), cfg.mlxServeKey || undefined, http).catch( - () => null - ) - r.then((recs) => dbg(` mlxserve receipt at step.ended: ${recs?.[0]?.completionTokens ?? "none"} tok`)).catch(() => {}) - read = r - } - if (read) { - stepReads.set(id, read) - bound(stepReads) - } + const id = stepID(evt) + if (id) readStep(id, "step.ended (fallback)") }) ) off.push(ctx.data.on("session.text.delta", mark)) @@ -829,18 +856,7 @@ export default Plugin.define({ ) off.push( ctx.data.on("session.step.streamed", (evt) => { - // Whether an engine that keeps only its latest request has already - // recorded the step when streaming ends -- step.ended comes only - // after the step's tools have run (measured: 29ms after a sub-agent - // on the same engine finished), by which time `latest` can be the - // tool's request, not the step's. - const id = (evt as { data?: { assistantMessageID?: string } }).data?.assistantMessageID ?? "?" - dbg(`event step.streamed ${id}`) - if (stepProvider.get(id) === "mtplx") { - fetchMtplxLatest(cfg.mtplxUrl, { signal: life.signal }) - .then((l) => dbg(` probe mtplx latest at step.streamed: ${l?.completion_tokens ?? "none"} tok`)) - .catch(() => {}) - } + dbg(`event step.streamed ${(evt as { data?: { assistantMessageID?: string } }).data?.assistantMessageID ?? "?"}`) }) ) off.push( From 066c0c940dde10b1501f6cd61e15fac9142fdad1 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 16:29:11 -0700 Subject: [PATCH 07/17] Give sub-agent time its own share of the session time split --- CHANGELOG.md | 3 ++- session.ts | 24 +++++++++++++++++++----- test/session.test.mjs | 17 +++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eeb0e3..488b8fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ per-turn figures, `+2 sub-agents 4210 tok 38.10s $0.012`: their tokens and cost summed, and the time from the first starting to the last finishing. Rates are never combined across them. The Session section - totals them in a `sub-agents` row. + totals them in a `sub-agents` row, and its time split gives the real time + sub-agents were running its own share instead of folding it into `other`. - `sessionBackground` option: the theme's offset shade behind the Session section. diff --git a/session.ts b/session.ts index b60cd85..ff96c59 100644 --- a/session.ts +++ b/session.ts @@ -35,8 +35,12 @@ export interface SessionSummary { ttftMax?: number /** Cached prompt tokens over all prompt tokens. */ cacheHit?: number - /** Shares of the counted turns' total time; they sum to 1. */ - time?: { generating: number; waiting: number; other: number } + /** + * Shares of the counted turns' real total time; they sum to 1. `subagents` + * is time a sub-agent was running inside the turn (its span, real time), + * taken out of what would otherwise be `other` -- never added on top. + */ + time?: { generating: number; waiting: number; subagents: number; other: number } retries: number engine?: { prefillTokS?: number; mtpX?: number; draftAccept?: number } /** Sub-agents across the counted turns: how many, their tokens and cost. */ @@ -152,15 +156,19 @@ export function summariseSession( let gen = 0 let wait = 0 + let sub = 0 let total = 0 for (const t of turns) { const s = streamOf(t) if (s === undefined || t.waitS === undefined || t.totalS === undefined || t.totalS <= 0) continue gen += s wait += t.waitS + // The parent is waiting on a tool while a sub-agent runs, so the span + // falls inside the turn's other time; capped so it can never exceed it. + sub += Math.min(t.subagents?.spanS ?? 0, Math.max(0, t.totalS - s - t.waitS)) total += t.totalS } - const other = Math.max(0, total - gen - wait) + const other = Math.max(0, total - gen - wait - sub) const eng = (pick: (e: NonNullable) => number | undefined): number | undefined => mean(turns.map((t) => (t.engine ? pick(t.engine) : undefined)).filter((v): v is number => v !== undefined)) @@ -194,7 +202,10 @@ export function summariseSession( ttftMedian: median(ttfts), ttftMax: ttfts.length > 0 ? Math.max(...ttfts) : undefined, cacheHit: cacheTurns > 0 && prompt > 0 ? cached / prompt : undefined, - time: total > 0 ? { generating: gen / total, waiting: wait / total, other: other / total } : undefined, + time: + total > 0 + ? { generating: gen / total, waiting: wait / total, subagents: sub / total, other: other / total } + : undefined, retries: turns.reduce((n, t) => n + (t.retries ?? 0), 0), engine: engine.prefillTokS !== undefined || engine.mtpX !== undefined || engine.draftAccept !== undefined @@ -251,7 +262,10 @@ export function sessionRows(s: SessionSummary): Array<[string, string]> { } if (s.cacheHit !== undefined) rows.push(["cache", `${pct(s.cacheHit)} hit`]) if (s.time) { - rows.push(["time", `${pct(s.time.generating)} gen · ${pct(s.time.waiting)} wait · ${pct(s.time.other)} other`]) + const parts = [`${pct(s.time.generating)} gen`, `${pct(s.time.waiting)} wait`] + if (s.time.subagents > 0) parts.push(`${pct(s.time.subagents)} sub-agents`) + parts.push(`${pct(s.time.other)} other`) + rows.push(["time", parts.join(" · ")]) } if (s.engine) { const e = [ diff --git a/test/session.test.mjs b/test/session.test.mjs index e258882..f5b14a1 100644 --- a/test/session.test.mjs +++ b/test/session.test.mjs @@ -205,4 +205,21 @@ test("the session section sums each turn's sub-agents", () => { assert.equal(Object.fromEntries(sessionRows(s))["sub-agents"], "3 · 600 tok · $0.010") }) +test("sub-agent time is split out of other, not added on top", () => { + // 20s total: 4s generating, 2s waiting, 6s of sub-agents running, 8s other. + // The sub-agent span is real time inside the turn's total. + const s = summariseSession([row({ subagents: { count: 1, tokens: 50, spanS: 6 } }), row()], SID) + assert.equal(s.time.generating, 4 / 20) + assert.equal(s.time.waiting, 2 / 20) + assert.equal(s.time.subagents, 6 / 20) + assert.equal(s.time.other, 8 / 20) +}) + +test("the time row names sub-agents only when some ran", () => { + const with_ = Object.fromEntries(sessionRows(summariseSession([row({ subagents: { count: 1, tokens: 50, spanS: 6 } }), row()], SID))) + assert.equal(with_.time, "20% gen · 10% wait · 30% sub-agents · 40% other") + const without = Object.fromEntries(sessionRows(summariseSession([row(), row()], SID))) + assert.equal(without.time, "20% gen · 10% wait · 70% other") +}) + console.log(`\n${passed} passed`) From eaeef90f62fb3f630c853c61eecbf074dd5f8749 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 16:32:23 -0700 Subject: [PATCH 08/17] Expect same-engine sub-agents in counter-difference windows, and label their figures --- CHANGELOG.md | 6 +++ adapters/llamacpp.ts | 4 +- adapters/omlx.ts | 4 +- adapters/prometheus.ts | 8 +++- adapters/splash.ts | 4 +- history.ts | 2 +- session.ts | 2 + test/llamacpp.test.mjs | 6 +++ test/omlx.test.mjs | 5 +++ test/prometheus.test.mjs | 18 +++++++++ test/session.test.mjs | 7 ++++ test/splash.test.mjs | 7 ++++ tui.tsx | 84 ++++++++++++++++++++++++++-------------- 13 files changed, 120 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 488b8fe..0539d85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ finishing. Rates are never combined across them. The Session section totals them in a `sub-agents` row, and its time split gives the real time sub-agents were running its own share instead of folding it into `other`. +- On `vllm`, `sglang`, `vllmmlx`, `aphrodite`, `lmdeploy`, `llamacpp`, + `llamafile`, `splash` and `omlx`, a turn whose sub-agent used the same + engine is no longer declined: the engine's window is expected to hold the + turn's requests and tokens plus the sub-agents'. Those figures then cover + both, and the line says `incl. sub-agents`. Rate and TTFT stay the turn's + own. Built and tested against captures; not yet run live. - `sessionBackground` option: the theme's offset shade behind the Session section. diff --git a/adapters/llamacpp.ts b/adapters/llamacpp.ts index c339e74..cc077c2 100644 --- a/adapters/llamacpp.ts +++ b/adapters/llamacpp.ts @@ -129,7 +129,7 @@ export function formatLlamaCppLine( label: string, model: string, hostTtft?: number, - host: { total?: number; retries?: number } = {} + host: { total?: number; retries?: number; includesSubagents?: boolean } = {} ): string { // Host-derived, and labelled as such. No derived figure on this line takes // its numerator from one source and its denominator from the other -- ttft @@ -141,7 +141,7 @@ export function formatLlamaCppLine( t.prefillTokS !== undefined ? `prefill ${ni(t.prefillTokS)} tok/s` : "", `${ni(t.completionTokens)} tok ${nn(host.total ?? t.decodeS + t.prefillS, 2)}s${ (host.retries ?? 0) > 0 ? ` (${host.retries} ${host.retries === 1 ? "retry" : "retries"})` : "" - }`, + }${host.includesSubagents ? " incl. sub-agents" : ""}`, ] .filter(Boolean) .join("\n") diff --git a/adapters/omlx.ts b/adapters/omlx.ts index 5781a21..baaefa7 100644 --- a/adapters/omlx.ts +++ b/adapters/omlx.ts @@ -114,7 +114,7 @@ export function formatOmlxLine( now: OmlxSample, prev: OmlxSample | undefined, hostTtft?: number, - host: { decodeTokS?: number; total?: number; retries?: number } = {} + host: { decodeTokS?: number; total?: number; retries?: number; includesSubagents?: boolean } = {} ): string { const header = `oMLX ${short(now.model ?? "")}` // Host-derived, and labelled as such. No derived figure on this line takes @@ -163,7 +163,7 @@ export function formatOmlxLine( host.total !== undefined ? ` ${nn(host.total, 2)}s` : "" }${ (host.retries ?? 0) > 0 ? ` (${host.retries} ${host.retries === 1 ? "retry" : "retries"})` : "" - }`, + }${host.includesSubagents ? " incl. sub-agents" : ""}`, ].join("\n") } diff --git a/adapters/prometheus.ts b/adapters/prometheus.ts index 4bdaca7..386cda6 100644 --- a/adapters/prometheus.ts +++ b/adapters/prometheus.ts @@ -309,6 +309,12 @@ export function formatPromLine( /** Assistant messages in the turn; a tool-using turn is one per step. */ steps?: number retries?: number + /** + * The window also holds sub-agents' requests on this same engine; `tokens` + * and `steps` already include theirs. The counters can't separate them, + * so the engine's figures cover both, and the tokens line says so. + */ + includesSubagents?: boolean } ): string | null { // One TTFT per step: a tool-using turn makes one request per step, so its @@ -339,7 +345,7 @@ export function formatPromLine( single && diff.prefillTokS !== undefined ? `prefill ${ni(diff.prefillTokS)} tok/s` : "", `${ni(diff.completionTokens)} tok (${ni(diff.promptTokens)} prompt${ diff.cachedTokens > 0 ? `, ${ni(diff.cachedTokens)} cached` : "" - })${total !== undefined ? ` ${nn(total, 2)}s${retries}` : ""}`, + })${fallback.includesSubagents ? " incl. sub-agents" : ""}${total !== undefined ? ` ${nn(total, 2)}s${retries}` : ""}`, ] .filter(Boolean) .join("\n") diff --git a/adapters/splash.ts b/adapters/splash.ts index 4f507ff..d25b2d5 100644 --- a/adapters/splash.ts +++ b/adapters/splash.ts @@ -202,7 +202,7 @@ export function formatSplashLine( t: SplashTurn, model: string, hostTtft?: number, - host: { total?: number; retries?: number; steps?: number } = {} + host: { total?: number; retries?: number; steps?: number; includesSubagents?: boolean } = {} ): string { // Host-derived, and labelled as such. No derived figure on this line takes // its numerator from one source and its denominator from the other -- ttft @@ -215,7 +215,7 @@ export function formatSplashLine( t.prefillTokS !== undefined ? `prefill ${ni(t.prefillTokS)} tok/s` : "", `${ni(t.completionTokens)} tok ${nn(host.total ?? t.prefillS + t.decodeS, 2)}s${ (host.retries ?? 0) > 0 ? ` (${host.retries} ${host.retries === 1 ? "retry" : "retries"})` : "" - }`, + }${host.includesSubagents ? " incl. sub-agents" : ""}`, `${ni(prompt)} prompt${t.cachedTokens > 0 ? `, ${ni(t.cachedTokens)} cached` : ""}`, t.draftAcceptRate !== undefined ? `draft ${ni(t.draftAcceptRate * 100)}% accepted` : "", // Only when a turn spanned several requests (tool round trips), so the diff --git a/history.ts b/history.ts index 9c967d5..0109ebc 100644 --- a/history.ts +++ b/history.ts @@ -75,7 +75,7 @@ export interface TurnRecord { * their summed tokens and cost, and the span they ran (they can run in * parallel, so not a sum). Rates are never combined across them. */ - subagents?: { count: number; tokens: number; spanS: number; cost?: number } + subagents?: { count: number; tokens: number; spanS: number; cost?: number; steps?: number } engine?: { prefillTokS?: number /** Tokens committed per verify pass (MTPLX's multi-token prediction). */ diff --git a/session.ts b/session.ts index ff96c59..230edaa 100644 --- a/session.ts +++ b/session.ts @@ -82,6 +82,8 @@ export function rollupSubagents( return { count: new Set(rows.map((t) => t.sessionID)).size, tokens: rows.reduce((n, t) => n + t.tokens, 0), + // Engine requests: one per step. A row from before steps existed is one. + steps: rows.reduce((n, t) => n + (t.steps ?? 1), 0), spanS: (end - start) / 1000, cost: sawCost ? cost : undefined, } diff --git a/test/llamacpp.test.mjs b/test/llamacpp.test.mjs index fd9fb13..8a46a90 100644 --- a/test/llamacpp.test.mjs +++ b/test/llamacpp.test.mjs @@ -169,4 +169,10 @@ test("the total is OpenCode's -- what you waited -- with retries named", () => { assert.ok(out.includes("32 tok 7.00s (1 retry)"), out) }) +test("figures that include a same-engine sub-agent say so", () => { + const t = diffLlamaCppCounters(before, after) + const out = formatLlamaCppLine(t, "llama.cpp", "m", 0.3, { total: 7.0, includesSubagents: true }) + assert.ok(out.includes("32 tok 7.00s incl. sub-agents"), out) +}) + console.log(`\n${passed} passed`) diff --git a/test/omlx.test.mjs b/test/omlx.test.mjs index be42185..635f4b6 100644 --- a/test/omlx.test.mjs +++ b/test/omlx.test.mjs @@ -177,4 +177,9 @@ test("a multi-step turn uses OpenCode's generation rate, not the server's all-ti assert.ok(out.includes("12.00s"), out) }) +test("figures that include a same-engine sub-agent say so", () => { + const out = formatOmlxLine(afterTwo, afterOne, 0.5, { decodeTokS: 42.5, total: 12.0, includesSubagents: true }) + assert.ok(out.includes("12.00s incl. sub-agents"), out) +}) + console.log(`\n${passed} passed`) diff --git a/test/prometheus.test.mjs b/test/prometheus.test.mjs index c57dfb2..adee9fe 100644 --- a/test/prometheus.test.mjs +++ b/test/prometheus.test.mjs @@ -538,6 +538,24 @@ test("Prometheus: renders from a real live vLLM capture", () => { assert.ok(!out.includes("?"), out) }) +// A sub-agent on the same engine adds its requests and tokens to the turn's +// window. The window is accepted when it holds the turn's steps plus the +// sub-agents', and the tokens equal both; its engine figures then cover both, +// and the tokens line says so. +test("Prometheus: a window with a same-engine sub-agent is accepted and labelled", () => { + const diff = { completionTokens: 1424, promptTokens: 30000, cachedTokens: 0, ttftExact: false, + requests: { ttft: 7, duration: 7 } } + const out = formatPromLine(diff, "vllm-mlx", "m", + { decodeTokS: 30, ttft: 1.2, total: 207.4, tokens: 1424, steps: 7, includesSubagents: true }) + assert.ok(out !== null, "5 parent steps + 2 sub-agent steps, 1233 + 191 tokens") + assert.ok(out.includes("1424 tok (30000 prompt) incl. sub-agents"), out) +}) + +test("Prometheus: without the flag, the same line carries no such label", () => { + const diff = { completionTokens: 50, promptTokens: 33, cachedTokens: 0, ttftExact: true, requests: { ttft: 1, duration: 1 } } + assert.ok(!formatPromLine(diff, "vLLM", "m", { tokens: 50 }).includes("sub-agents")) +}) + console.log(`\n${passed} passed`) if (process.exitCode) { console.error("some tests failed") diff --git a/test/session.test.mjs b/test/session.test.mjs index f5b14a1..6efbd26 100644 --- a/test/session.test.mjs +++ b/test/session.test.mjs @@ -222,4 +222,11 @@ test("the time row names sub-agents only when some ran", () => { assert.equal(without.time, "20% gen · 10% wait · 70% other") }) +test("the roll-up carries its sub-agents' steps -- one engine request each", () => { + const r = rollupSubagents([child({ steps: 2 }), child({ sessionID: "ses_b", steps: 3 })], ["ses_child", "ses_b"], 0, 40_000) + assert.equal(r.steps, 5) + // A row recorded before steps existed counts as one request. + assert.equal(rollupSubagents([child({ steps: undefined })], ["ses_child"], 0, 40_000).steps, 1) +}) + console.log(`\n${passed} passed`) diff --git a/test/splash.test.mjs b/test/splash.test.mjs index 6342ee8..3c7035c 100644 --- a/test/splash.test.mjs +++ b/test/splash.test.mjs @@ -289,4 +289,11 @@ test("Splash: a verified multi-step turn needs no 'requests this turn' note", () assert.ok(formatSplashLine(t, "m").includes("2 requests this turn")) }) +test("Splash: figures that include a same-engine sub-agent say so", () => { + const [b, a] = pair("splash") + const t = { ...diffSplashSamples(b, a), requests: 2 } + const out = formatSplashLine(t, "m", 0.4, { total: 9.0, steps: 2, includesSubagents: true }) + assert.ok(out.includes("200 tok 9.00s incl. sub-agents"), out) +}) + console.log(`\n${passed} passed`) diff --git a/tui.tsx b/tui.tsx index 60a655f..8704dd7 100644 --- a/tui.tsx +++ b/tui.tsx @@ -289,7 +289,13 @@ export default Plugin.define({ engine?: TurnRecord["engine"] }, /** The turn's assistant messages, one per step, oldest first. */ - steps: readonly SessionMessageAssistant[] + steps: readonly SessionMessageAssistant[], + /** + * Sub-agents that ran on this same engine during the turn. A + * counter-difference engine's window holds their requests too, so its + * check expects the turn's tokens and steps plus theirs. + */ + sameEngine?: TurnRecord["subagents"] ): Promise { // OpenCode's own ttft for this turn. Five provider ids report none of // their own (omlx, llamacpp, llamafile, splash, koboldcpp), and the @@ -338,11 +344,14 @@ export default Plugin.define({ // Tier 1 supplies the fallback rate and total wherever the engine has // no single-request figure of its own. Passed in rather than imported // by the adapter, so adapters stay leaves. + // The fallback rate is this turn's own generation: OpenCode's count + // over its streaming, never the window's, which can include sub-agents. const line = formatPromLine(diff, label, model, { - ...turnRate(diff.completionTokens, info, turn), - tokens: hostTok, - steps: turn?.steps, + ...turnRate(hostTok, info, turn), + tokens: windowTokens, + steps: windowSteps, retries: turn?.retries, + includesSubagents, }) if (line === null) tier2.sharedWindow = true else if ((turn?.steps ?? 1) === 1 && diff.prefillTokS !== undefined) tier2.engine = { prefillTokS: diff.prefillTokS } @@ -359,6 +368,11 @@ export default Plugin.define({ return (await Promise.all(reads)) as Array } const hostFigures = { total: turnRate(0, info, turn).total, retries: turn?.retries } + // What a counter-difference window should hold: this turn's tokens and + // steps, plus any sub-agents' on this same engine. + const windowTokens = hostTokens + (sameEngine?.tokens ?? 0) + const windowSteps = steps.length + (sameEngine?.steps ?? 0) + const includesSubagents = sameEngine !== undefined switch (provider) { case "mtplx": { @@ -403,7 +417,7 @@ export default Plugin.define({ // A window that isn't this turn's -- a spare request, or tokens // that don't match -- is declined. The no-baseline render below // is labelled as the server's averages and needs no check. - if (now.requests > prev.requests && !omlxIsThisTurn(prev, now, { tokens: hostTokens, steps: steps.length })) { + if (now.requests > prev.requests && !omlxIsThisTurn(prev, now, { tokens: windowTokens, steps: windowSteps })) { tier2.sharedWindow = true return null } @@ -411,6 +425,7 @@ export default Plugin.define({ return formatOmlxLine(now, prev, hostTtft, { ...hostFigures, decodeTokS: turnRate(hostTokens, info, turn).decodeTokS, + includesSubagents, }) } @@ -434,12 +449,12 @@ export default Plugin.define({ const t = diffLlamaCppCounters(prev, now) if (!t) return null match(t.completionTokens, `; steps ${steps.length}`) - if (!llamaCppIsThisTurn(t, hostTokens)) { + if (!llamaCppIsThisTurn(t, windowTokens)) { tier2.sharedWindow = true return null } tier2.engine = { prefillTokS: t.prefillTokS } - return formatLlamaCppLine(t, label, model, hostTtft, hostFigures) + return formatLlamaCppLine(t, label, model, hostTtft, { ...hostFigures, includesSubagents }) } case "splash": { @@ -456,12 +471,12 @@ export default Plugin.define({ const t = diffSplashSamples(prev, now) if (!t) return null match(t.completionTokens, `; requests ${t.requests}, steps ${steps.length}`) - if (!splashIsThisTurn(t, { tokens: hostTokens, steps: steps.length })) { + if (!splashIsThisTurn(t, { tokens: windowTokens, steps: windowSteps })) { tier2.sharedWindow = true return null } tier2.engine = { prefillTokS: t.prefillTokS, draftAccept: t.draftAcceptRate } - return formatSplashLine(t, model, hostTtft, { ...hostFigures, steps: steps.length }) + return formatSplashLine(t, model, hostTtft, { ...hostFigures, steps: windowSteps, includesSubagents }) } case "koboldcpp": @@ -627,13 +642,43 @@ export default Plugin.define({ // them all at once rather than leaving them to run the clock out. const http: HttpOptions = { signal: life.signal } + // Sub-agents that ran during this turn, each in its own child session + // whose turns are recorded under that session (measured: the child's + // row existed, and the child had finished, 11s before the parent's turn + // ended). Their tokens, time and cost are summed onto one line; rates + // are never combined, since a sub-agent can run on another model. + let subagents: TurnRecord["subagents"] + let sameEngine: TurnRecord["subagents"] + try { + const descendants = ctx.data.session.family(sessionID).filter((id) => { + let p = ctx.data.session.get(id)?.parentID + for (let hops = 0; p && hops < 16; hops++) { + if (p === sessionID) return true + p = ctx.data.session.get(p)?.parentID + } + return false + }) + const until = Date.now() + subagents = rollupSubagents(history.turns, descendants, info.time.created, until) + // The ones on this same engine: counter-difference engines see their + // requests in this turn's window, so the check expects them too. + sameEngine = rollupSubagents( + history.turns.filter((t) => t.provider === provider), + descendants, + info.time.created, + until + ) + } catch (e: unknown) { + dbg(`sub-agent lookup threw: ${String(e)}`) + } + const tier2: { pendingBaseline: boolean; sharedWindow: boolean; engine?: TurnRecord["engine"] } = { pendingBaseline: false, sharedWindow: false, } let line: string | null = null try { - line = await enrich(provider, model, info, turn, http, tier2, steps) + line = await enrich(provider, model, info, turn, http, tier2, steps, sameEngine) // Recorded before the fallback overwrites it, so history knows which // tier the figures actually came from. } catch (e: unknown) { @@ -662,25 +707,6 @@ export default Plugin.define({ else if (tier2.sharedWindow) line += "\nengine data skipped: overlapping requests" } - // Sub-agents that ran during this turn, each in its own child session - // whose turns are recorded under that session (measured: the child's - // row existed, and the child had finished, 11s before the parent's turn - // ended). Their tokens, time and cost are summed onto one line; rates - // are never combined, since a sub-agent can run on another model. - let subagents: TurnRecord["subagents"] - try { - const descendants = ctx.data.session.family(sessionID).filter((id) => { - let p = ctx.data.session.get(id)?.parentID - for (let hops = 0; p && hops < 16; hops++) { - if (p === sessionID) return true - p = ctx.data.session.get(p)?.parentID - } - return false - }) - subagents = rollupSubagents(history.turns, descendants, info.time.created, Date.now()) - } catch (e: unknown) { - dbg(`sub-agent lookup threw: ${String(e)}`) - } if (subagents) line += `\n${formatSubagentLine(subagents)}` // Keep the turn for the drill-down. Every figure below is OpenCode's own, From ff525d3651ae0a6f507e2756a24218494a35ef5e Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 19:08:46 -0700 Subject: [PATCH 09/17] Lay the sidebar out as two independent boxes of labelled rows --- CHANGELOG.md | 13 +++- README.md | 31 +++++--- adapters/koboldcpp.ts | 52 +++++++------ adapters/llamacpp.ts | 43 ++++++----- adapters/mlxserve.ts | 54 +++++++------- adapters/mtplx.ts | 85 ++++++++++------------ adapters/omlx.ts | 65 +++++++++++------ adapters/prometheus.ts | 54 ++++++++++---- adapters/splash.ts | 58 ++++++++------- package.json | 3 +- rows.ts | 73 +++++++++++++++++++ session.ts | 80 ++++++++++---------- test/koboldcpp.test.mjs | 6 +- test/llamacpp.test.mjs | 26 ++++--- test/mlxserve.test.mjs | 4 +- test/mtplx.test.mjs | 44 ++++++----- test/omlx.test.mjs | 21 +++--- test/prometheus.test.mjs | 20 ++--- test/rows.test.mjs | 56 ++++++++++++++ test/session.test.mjs | 66 +++++++++++------ test/splash.test.mjs | 12 +-- test/universal.test.mjs | 25 ++++--- tui.tsx | 153 +++++++++++++++++++++------------------ universal.ts | 95 +++++++++++------------- 24 files changed, 693 insertions(+), 446 deletions(-) create mode 100644 rows.ts create mode 100644 test/rows.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0539d85..5f99c0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,17 @@ turn's requests and tokens plus the sub-agents'. Those figures then cover both, and the line says `incl. sub-agents`. Rate and TTFT stay the turn's own. Built and tested against captures; not yet run live. -- `sessionBackground` option: the theme's offset shade behind the Session - section. +- `background` option (default on): the theme's offset shade behind each + box. ### Changed +- The sidebar is two boxes, the last turn and the session, each opened and + closed independently by clicking its heading. Figures are laid out one + per line as label and value; a figure with parts continues on the next + line. Collapsed, a heading keeps one figure. The heading names the + engine, not the model, which OpenCode already shows under the prompt. +- mlx-serve no longer shows a non-streamed request's whole-request rate: + it includes prefill, so it is not generation speed. - `mtplx`, `koboldcpp` and `mlxserve` are read when each step finishes streaming, not when OpenCode marks the step ended. For a step that calls tools, "ended" comes only after the tools have run, and a sub-agent on the @@ -33,8 +40,6 @@ declined with `engine data skipped: overlapping requests`. Measured on MTPLX: 194 tokens (the step's own) at the end of streaming, 163 (the sub-agent's) at "ended". -- The per-turn block's first line (engine and model) is bold, matching - OpenCode's own sidebar sections. ## [0.2.4] – 2026-09-23 ### Changed diff --git a/README.md b/README.md index 56f792a..a42ffb1 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,24 @@ the provider. ``` -MTPLX arsis-dev-ukisai-swift-… -38.1 tok/s ttft 9.06s -prefill 452 tok/s -37 tok 10.03s -MTP 3.70x 99/96/80% +▾ MTPLX · last turn + +speed 34.4 tok/s +ttft 17.19s +prefill 460 tok/s +tokens 1,233 +time 207.37s +MTP 3.42x +accepted 91/79/64% +sub-agent 191 tok + 23.91s + +▸ Session · 14 turns 48.2 tok/s ``` +Two boxes, each opened and closed by clicking its heading: the last turn, +and the session so far. + Requires [**OpenCode 2**](https://opencode.ai/v2/docs). For the v1 line (OpenCode 1.18.x), see [opencode-engine-hud](https://github.com/charlesnutter/opencode-engine-hud). @@ -79,7 +90,7 @@ does not — there is nothing to choose. "plugins": [ { "package": "@banburist/opencode-headsup", - "options": { "showContext": false, "sessionBackground": false } + "options": { "showContext": false, "background": true } } ] } @@ -92,11 +103,11 @@ Adds a `13% prompt/limit` line, computed as config in `opencode.json`. Labeled as `prompt/limit` rather than `context used`. -**sessionBackground** (default `false`) +**background** (default `true`) -Puts your theme's offset panel shade behind the Session section. Off by -default because some themes and terminals use a transparent background, -where the shade can disappear. +Puts your theme's offset panel shade behind each box. Turn it off for a +theme or terminal with a transparent background, where the shade can +disappear. ### Endpoints diff --git a/adapters/koboldcpp.ts b/adapters/koboldcpp.ts index 6eb8a5b..568337a 100644 --- a/adapters/koboldcpp.ts +++ b/adapters/koboldcpp.ts @@ -16,7 +16,8 @@ // without the TUI runtime. import { httpJson, type HttpOptions } from "../http" -import { nn, ni, short } from "../format" +import { nn, ni } from "../format" +import { rowsOf, timeRows, viewText, nt, 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 @@ -228,33 +229,40 @@ export function combineKoboldSteps( } /** - * `host.total` is the turn's total from OpenCode -- what the user waited, - * retries included -- and wins over the engine's prefill + decode time. + * The turn as labelled rows. `host.total` is the turn's total from OpenCode + * -- what the user waited, retries included -- and wins over the engine's + * prefill + decode time. TTFT is the host's, labelled as such: KoboldCpp + * reports none, and nothing on these rows takes its numerator from one + * source and its denominator from the other. */ +export function koboldView( + t: KoboldTurn, + hostTtft?: number, + host: { total?: number; retries?: number } = {} +): TurnView { + const rows: Row[] = [ + ...rowsOf("speed", [t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : ""]), + ...rowsOf("ttft", [hostTtft !== undefined ? `${nn(hostTtft, 2)}s (host)` : ""]), + ...rowsOf("prefill", [t.prefillTokS !== undefined ? `${ni(t.prefillTokS)} tok/s` : ""]), + ["tokens", nt(t.completionTokens)], + ...timeRows(host.total ?? t.prefillS + t.decodeS, host.retries), + ...rowsOf("draft", [t.draftAcceptRate !== undefined ? `${ni(t.draftAcceptRate * 100)}% accepted` : ""]), + ] + const notes = + 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 } +} + +/** The view as text; kept for tests that look for a figure. */ export function formatKoboldLine( t: KoboldTurn, - model: string, + _model: string, hostTtft?: number, host: { total?: number; retries?: number } = {} ): string { - // Host-derived, and labelled as such. No derived figure on this line takes - // its numerator from one source and its denominator from the other -- ttft - // is measured directly, so nothing crosses the seam. - const ttftLabel = hostTtft !== undefined ? ` ttft ${nn(hostTtft, 2)}s (host)` : "" - return [ - `KoboldCpp ${short(model)}`, - t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s${ttftLabel}` : ttftLabel.trim(), - t.prefillTokS !== undefined ? `prefill ${ni(t.prefillTokS)} tok/s` : "", - `${ni(t.completionTokens)} tok ${nn(host.total ?? t.prefillS + t.decodeS, 2)}s${ - (host.retries ?? 0) > 0 ? ` (${host.retries} ${host.retries === 1 ? "retry" : "retries"})` : "" - }`, - t.draftAcceptRate !== undefined ? `draft ${ni(t.draftAcceptRate * 100)}% accepted` : "", - t.generationsInWindow !== undefined && t.generationsInWindow > 1 - ? `${ni(t.generationsInWindow)} generations this turn (last shown only)` - : "", - ] - .filter(Boolean) - .join("\n") + return viewText(koboldView(t, hostTtft, host)) } export async function fetchKoboldPerf( diff --git a/adapters/llamacpp.ts b/adapters/llamacpp.ts index cc077c2..17a5013 100644 --- a/adapters/llamacpp.ts +++ b/adapters/llamacpp.ts @@ -21,7 +21,8 @@ import { sumLabeledMetric } from "../prometheus-text" import { httpText, type HttpOptions } from "../http" -import { nn, ni, short } from "../format" +import { nn, ni } from "../format" +import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows" export interface LlamaCppCounters { promptTokens: number @@ -121,30 +122,38 @@ export function llamaCppIsThisTurn(t: LlamaCppTurn, hostTokens: number | undefin } /** + * The turn as labelled rows under `label` (llama.cpp or llamafile). * `host.total` is the turn's total from OpenCode -- what the user waited, - * retries included -- and wins over the engine's own timings. + * retries included -- and wins over the engine's own timings. TTFT is the + * host's, labelled: llama.cpp reports none of its own. + * `host.includesSubagents`: the window also held same-engine sub-agents' + * requests, so the token count covers theirs too, and says so. */ +export function llamaCppView( + t: LlamaCppTurn, + label: string, + hostTtft?: number, + host: { total?: number; retries?: number; includesSubagents?: boolean } = {} +): TurnView { + const rows: Row[] = [ + ...rowsOf("speed", [t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : ""]), + ...rowsOf("ttft", [hostTtft !== undefined ? `${nn(hostTtft, 2)}s (host)` : ""]), + ...rowsOf("prefill", [t.prefillTokS !== undefined ? `${ni(t.prefillTokS)} tok/s` : ""]), + ...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 } +} + +/** The view as text; kept for tests that look for a figure. */ export function formatLlamaCppLine( t: LlamaCppTurn, label: string, - model: string, + _model: string, hostTtft?: number, host: { total?: number; retries?: number; includesSubagents?: boolean } = {} ): string { - // Host-derived, and labelled as such. No derived figure on this line takes - // its numerator from one source and its denominator from the other -- ttft - // is measured directly, so nothing crosses the seam. - const ttftLabel = hostTtft !== undefined ? ` ttft ${nn(hostTtft, 2)}s (host)` : "" - return [ - `${label} ${short(model)}`, - t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s${ttftLabel}` : ttftLabel.trim(), - t.prefillTokS !== undefined ? `prefill ${ni(t.prefillTokS)} tok/s` : "", - `${ni(t.completionTokens)} tok ${nn(host.total ?? t.decodeS + t.prefillS, 2)}s${ - (host.retries ?? 0) > 0 ? ` (${host.retries} ${host.retries === 1 ? "retry" : "retries"})` : "" - }${host.includesSubagents ? " incl. sub-agents" : ""}`, - ] - .filter(Boolean) - .join("\n") + return viewText(llamaCppView(t, label, hostTtft, host)) } export async function fetchLlamaCppCounters( diff --git a/adapters/mlxserve.ts b/adapters/mlxserve.ts index 20dc890..4b7e3f3 100644 --- a/adapters/mlxserve.ts +++ b/adapters/mlxserve.ts @@ -22,7 +22,8 @@ // No JSX/solid-js imports, so it stays unit-testable (test/mlxserve.test.mjs). import { httpJson, type HttpOptions } from "../http" -import { nn, ni, short } from "../format" +import { nn, ni } from "../format" +import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows" /** One record from /v1/metrics/requests, as the server names its fields. */ export interface MlxServeRequest { @@ -272,35 +273,38 @@ export function combineMlxServeSteps( } /** - * `host.total` is the turn's total from OpenCode -- what the user waited, - * retries included -- and wins over the record's request duration. + * The turn as labelled rows. `host.total` is the turn's total from OpenCode + * -- what the user waited, retries included -- and wins over the record's + * request duration. Only a streamed record's rate is shown: a non-streamed + * record's `tokens_per_second` is completion / whole duration, prefill + * included, which is not generation speed (OpenCode always streams). */ +export function mlxServeView( + t: MlxServeTurn, + host: { total?: number; retries?: number } = {} +): TurnView { + const rows: 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) : ""]), + ...timeRows(host.total ?? t.totalS, host.retries), + ] + const notes: string[] = [] + // A cold start loaded the model mid-request; without this the turn reads + // 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 } +} + +/** The view as text; kept for tests that look for a figure. */ export function formatMlxServeLine( t: MlxServeTurn, - model: string, + _model: string, host: { total?: number; retries?: number } = {} ): string { - // decodeTokS and overallTokS are never both set; they are not comparable, - // so the whole-request one is labelled rather than shown as a decode rate. - const rate = - t.decodeTokS !== undefined - ? `${nn(t.decodeTokS)} tok/s${t.ttft !== undefined ? ` ttft ${nn(t.ttft, 2)}s` : ""}` - : t.overallTokS !== undefined - ? `${nn(t.overallTokS)} tok/s (whole request)` - : "" - return [ - `mlx-serve ${short(model)}`, - rate, - `${ni(t.completionTokens)} tok${t.promptTokens !== undefined ? ` ${ni(t.promptTokens)} prompt` : ""} ${nn(host.total ?? t.totalS, 2)}s${ - (host.retries ?? 0) > 0 ? ` (${host.retries} ${host.retries === 1 ? "retry" : "retries"})` : "" - }`, - // A cold start loaded the model mid-request; without this the turn reads - // as a tenfold slowdown rather than a one-off load. - t.coldStart ? "cold start (model loaded)" : "", - t.requests > 1 && t.steps === undefined ? `${ni(t.requests)} requests this turn` : "", - ] - .filter(Boolean) - .join("\n") + return viewText(mlxServeView(t, host)) } export async function fetchMlxServeRequests( diff --git a/adapters/mtplx.ts b/adapters/mtplx.ts index 04e5eb8..7273e8d 100644 --- a/adapters/mtplx.ts +++ b/adapters/mtplx.ts @@ -15,7 +15,8 @@ // whose output nothing could assert against. import { httpJson, type HttpOptions } from "../http" -import { nn, ni, short } from "../format" +import { nn, ni } from "../format" +import { rowsOf, viewText, nt, type Row, type TurnView } from "../rows" /** * The fields this plugin reads. All optional — see the note above about @@ -99,66 +100,56 @@ export function combineMtplxSteps( } /** - * `host.total` is the turn's total from OpenCode -- what the user waited, - * retries included -- and wins over the receipt's `request_elapsed_s`, - * which is one request's duration. + * The turn as labelled rows. `host.total` is the turn's total from OpenCode + * -- what the user waited, retries included -- and wins over the receipt's + * `request_elapsed_s`, which is one request's duration. + * + * Interrupted turns lack ttft and prefill (the live capture has no such + * keys), so each figure is its own optional row rather than a placeholder. */ -export function formatMtplxLine( +export function mtplxView( l: MtplxLatest, - model: string, host: { total?: number; retries?: number } = {} -): string { +): TurnView { const decode = num(l.decode_tok_s) const ttft = num(l.ttft_s) const prefill = num(l.prefill_tok_s) const completion = num(l.completion_tokens) const elapsed = host.total ?? num(l.request_elapsed_s) - const r = host.retries ?? 0 - const retries = r > 0 ? ` (${r} ${r === 1 ? "retry" : "retries"})` : "" const verify = num(l.verify_calls) + const r = host.retries ?? 0 - // Rate and TTFT share a line but are independently available: an - // interrupted turn has the rate and not the TTFT. - const rate = [ - decode !== undefined ? `${nn(decode)} tok/s` : "", - ttft !== undefined ? `ttft ${nn(ttft, 2)}s` : "", + const rows: 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` : ""]), + // No think/answer split: the 342-key receipt holds no reasoning count + // (checked against a turn whose usage reported 23 of 64 as reasoning). + // completion_tokens already includes reasoning, so the total is right. + ...rowsOf("tokens", [completion !== undefined ? nt(completion) : ""]), + ...rowsOf("time", [ + elapsed !== undefined ? `${nn(elapsed, 2)}s` : "", + r > 0 ? `${r} ${r === 1 ? "retry" : "retries"}` : "", + ]), ] - .filter(Boolean) - .join(" ") - - // Speculative decoding: tokens committed per verify pass, with the - // per-depth acceptance probabilities MTPLX reports alongside. - let mtp = "" + // Speculative decoding: tokens committed per verify pass, and MTPLX's + // per-depth acceptance probabilities. if (verify !== undefined && verify > 0 && completion !== undefined) { - const acc = Array.isArray(l.mean_accept_probability_by_depth) - ? l.mean_accept_probability_by_depth.map((p) => Math.round(p * 100)).join("/") - : null - mtp = `MTP ${nn(completion / verify, 2)}x${acc ? ` ${acc}%` : ""}` + rows.push(["MTP", `${nn(completion / verify, 2)}x`]) + if (Array.isArray(l.mean_accept_probability_by_depth)) { + 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 } +} - // No think/answer split: a live capture's `latest` was searched key by - // key, nested objects included, against a turn whose own response `usage` - // reported 23 of 64 completion tokens as reasoning, and no field anywhere - // in the 342-key receipt held that number. /metrics does not carry it, - // unlike the per-response `usage` block MTPLX returns from - // /v1/chat/completions — this adapter only ever sees the former. - // completion_tokens does follow the OpenAI convention (it already includes - // reasoning), so the total itself is correct; only the "(N think)" subset - // tokensLabel can render elsewhere is unavailable here. - const totals = - completion !== undefined - ? `${ni(completion)} tok${elapsed !== undefined ? ` ${nn(elapsed, 2)}s${retries}` : ""}` - : "" - - return [ - `MTPLX ${short(model)}`, - rate, - prefill !== undefined ? `prefill ${ni(prefill)} tok/s` : "", - totals, - mtp, - ] - .filter(Boolean) - .join("\n") +/** The view as text; kept for tests that look for a figure. */ +export function formatMtplxLine( + l: MtplxLatest, + _model: string, + host: { total?: number; retries?: number } = {} +): string { + return viewText(mtplxView(l, host)) } export async function fetchMtplxLatest( diff --git a/adapters/omlx.ts b/adapters/omlx.ts index baaefa7..5c7ff7c 100644 --- a/adapters/omlx.ts +++ b/adapters/omlx.ts @@ -22,7 +22,8 @@ // against, which is the gap that hid two earlier bugs. import { httpJson, type HttpOptions } from "../http" -import { nn, ni, short } from "../format" +import { nn, ni } from "../format" +import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows" /** Cumulative counters as this plugin reads them. */ export interface OmlxSample { @@ -110,24 +111,28 @@ export function omlxIsThisTurn( * yields only the server's all-time average, which is not this turn's speed. * `host.total` is the turn's total from OpenCode, retries included. */ -export function formatOmlxLine( +export function omlxView( now: OmlxSample, prev: OmlxSample | undefined, hostTtft?: number, host: { decodeTokS?: number; total?: number; retries?: number; includesSubagents?: boolean } = {} -): string { - const header = `oMLX ${short(now.model ?? "")}` - // Host-derived, and labelled as such. No derived figure on this line takes - // its numerator from one source and its denominator from the other -- ttft - // is measured directly, so nothing crosses the seam. - const ttftLabel = hostTtft !== undefined ? ` ttft ${nn(hostTtft, 2)}s (host)` : "" +): TurnView { + // Host-derived, and labelled as such. No derived figure here takes its + // numerator from one source and its denominator from the other -- ttft is + // measured directly, so nothing crosses the seam. + const ttft = rowsOf("ttft", [hostTtft !== undefined ? `${nn(hostTtft, 2)}s (host)` : ""]) if (!prev || prev.model !== now.model || now.requests <= prev.requests) { - return [ - header, - `${nn(now.avgGen)} tok/s (server avg)${ttftLabel}`, - `prefill ${ni(now.avgPrefill)} tok/s (avg)`, - ].join("\n") + return { + engine: "oMLX", + rows: [ + ...rowsOf("speed", [`${nn(now.avgGen)} tok/s`, "(server avg)"]), + ...ttft, + ...rowsOf("prefill", [`${ni(now.avgPrefill)} tok/s (avg)`]), + ], + notes: [], + key: `${nn(now.avgGen)} tok/s avg`, + } } // More than one request landed in the window (an agentic turn issuing @@ -155,16 +160,30 @@ export function formatOmlxLine( const promptTokens = now.prompt - prev.prompt const cached = now.cached - prev.cached - return [ - header, - `${nn(decode)} tok/s${decodeLabel}${ttftLabel}`, - `prefill ${ni(prefill)} tok/s${prefillLabel}`, - `${ni(completion)} tok (${ni(promptTokens)} prompt${cached > 0 ? `, ${ni(cached)} cached` : ""})${ - host.total !== undefined ? ` ${nn(host.total, 2)}s` : "" - }${ - (host.retries ?? 0) > 0 ? ` (${host.retries} ${host.retries === 1 ? "retry" : "retries"})` : "" - }${host.includesSubagents ? " incl. sub-agents" : ""}`, - ].join("\n") + return { + engine: "oMLX", + rows: [ + ["speed", `${nn(decode)} tok/s${decodeLabel}`], + ...ttft, + ["prefill", `${ni(prefill)} tok/s${prefillLabel}`], + ...rowsOf("tokens", [nt(completion), host.includesSubagents ? "incl. sub-agents" : ""]), + ...timeRows(host.total, host.retries), + ["prompt", nt(promptTokens)], + ...rowsOf("cached", [cached > 0 ? nt(cached) : ""]), + ], + notes: [], + key: `${nn(decode)} tok/s${decodeLabel}`, + } +} + +/** The view as text; kept for tests that look for a figure. */ +export function formatOmlxLine( + now: OmlxSample, + prev: OmlxSample | undefined, + hostTtft?: number, + host: { decodeTokS?: number; total?: number; retries?: number; includesSubagents?: boolean } = {} +): string { + return viewText(omlxView(now, prev, hostTtft, host)) } export async function fetchOmlxSample( diff --git a/adapters/prometheus.ts b/adapters/prometheus.ts index 386cda6..ab5633e 100644 --- a/adapters/prometheus.ts +++ b/adapters/prometheus.ts @@ -8,7 +8,8 @@ import { httpText, type HttpOptions } from "../http" import { sumLabeledMetric } from "../prometheus-text" -import { nn, ni, short } from "../format" +import { nn, ni } from "../format" +import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows" export interface PromSpec { prefix: string promptTokens: string @@ -296,7 +297,7 @@ export function diffPromSamples(prev: PromSample, now: PromSample): PromDiff | n * A `fallback.tokens` of 0 or absent means OpenCode has no count for the turn, * and the token check is skipped rather than failing every turn. */ -export function formatPromLine( +export function promView( diff: PromDiff, label: string, model: string, @@ -316,7 +317,7 @@ export function formatPromLine( */ includesSubagents?: boolean } -): string | null { +): TurnView | null { // One TTFT per step: a tool-using turn makes one request per step, so its // window legitimately holds several. Anything else is another request. const steps = fallback.steps ?? 1 @@ -335,18 +336,41 @@ export function formatPromLine( // The total is what the user waited: the host's, retries included. The // engine's request duration excludes retries and anything before the step. const total = fallback.total ?? (single ? diff.durationS : undefined) - const r = fallback.retries ?? 0 - const retries = r > 0 ? ` (${r} ${r === 1 ? "retry" : "retries"})` : "" const ttft = single ? diff.ttft : fallback.ttft - const ttftLabel = ttft !== undefined ? ` ttft ${nn(ttft, 2)}s` : "" - return [ - `${label} ${short(model)}`, - decodeTokS !== undefined ? `${nn(decodeTokS)} tok/s${overall}${ttftLabel}` : ttftLabel.trim(), - single && diff.prefillTokS !== undefined ? `prefill ${ni(diff.prefillTokS)} tok/s` : "", - `${ni(diff.completionTokens)} tok (${ni(diff.promptTokens)} prompt${ - diff.cachedTokens > 0 ? `, ${ni(diff.cachedTokens)} cached` : "" - })${fallback.includesSubagents ? " incl. sub-agents" : ""}${total !== undefined ? ` ${nn(total, 2)}s${retries}` : ""}`, + const rows: Row[] = [ + ...rowsOf("speed", [decodeTokS !== undefined ? `${nn(decodeTokS)} tok/s${overall}` : ""]), + ...rowsOf("ttft", [ttft !== undefined ? `${nn(ttft, 2)}s` : ""]), + ...rowsOf("prefill", [single && diff.prefillTokS !== undefined ? `${ni(diff.prefillTokS)} tok/s` : ""]), + ...rowsOf("tokens", [nt(diff.completionTokens), fallback.includesSubagents ? "incl. sub-agents" : ""]), + ...timeRows(total, fallback.retries), + ["prompt", nt(diff.promptTokens)], + ...rowsOf("cached", [diff.cachedTokens > 0 ? nt(diff.cachedTokens) : ""]), ] - .filter(Boolean) - .join("\n") + return { engine: label, rows, notes: [], key: decodeTokS !== undefined ? `${nn(decodeTokS)} tok/s${overall}` : undefined } +} + +/** The view as text, or null when declined; kept for tests that look for a figure. */ +export function formatPromLine( + diff: PromDiff, + label: string, + model: string, + fallback: { + decodeTokS?: number + ttft?: number + total?: number + rateWindow?: "decode" | "whole" + tokens?: number + /** Assistant messages in the turn; a tool-using turn is one per step. */ + steps?: number + retries?: number + /** + * The window also holds sub-agents' requests on this same engine; `tokens` + * and `steps` already include theirs. The counters can't separate them, + * so the engine's figures cover both, and the tokens line says so. + */ + includesSubagents?: boolean + } +): string | null { + const v = promView(diff, label, model, fallback) + return v ? viewText(v) : null } diff --git a/adapters/splash.ts b/adapters/splash.ts index d25b2d5..0cb91f9 100644 --- a/adapters/splash.ts +++ b/adapters/splash.ts @@ -17,7 +17,8 @@ import { sumLabeledMetric } from "../prometheus-text" import { httpText, type HttpOptions } from "../http" -import { nn, ni, short } from "../format" +import { nn, ni } from "../format" +import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows" /** * Names verified against the server's own metrics.py (Splash 1.0), which maps @@ -193,35 +194,40 @@ export function splashIsThisTurn(t: SplashTurn, host: { tokens?: number; steps?: } /** - * `host.total` is the turn's total from OpenCode -- what the user waited, - * retries included -- and wins over the engine's phase times. `host.steps` - * set means the window was checked against the turn's steps, so its - * requests are all accounted for and need no "requests this turn" note. + * The turn as labelled rows. `host.total` is the turn's total from OpenCode + * -- what the user waited, retries included -- and wins over the engine's + * phase times. TTFT is the host's, labelled. `host.steps` set means the + * window was checked against the turn's steps, so its requests are all + * accounted for and need no "requests this turn" note. The prompt is what + * Splash prefilled plus what its prefix cache served. */ +export function splashView( + t: SplashTurn, + hostTtft?: number, + host: { total?: number; retries?: number; steps?: number; includesSubagents?: boolean } = {} +): TurnView { + const rows: Row[] = [ + ...rowsOf("speed", [t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : ""]), + ...rowsOf("ttft", [hostTtft !== undefined ? `${nn(hostTtft, 2)}s (host)` : ""]), + ...rowsOf("prefill", [t.prefillTokS !== undefined ? `${ni(t.prefillTokS)} tok/s` : ""]), + ...rowsOf("tokens", [nt(t.completionTokens), host.includesSubagents ? "incl. sub-agents" : ""]), + ...timeRows(host.total ?? t.prefillS + t.decodeS, host.retries), + ["prompt", nt(t.promptTokens + t.cachedTokens)], + ...rowsOf("cached", [t.cachedTokens > 0 ? nt(t.cachedTokens) : ""]), + ...rowsOf("draft", [t.draftAcceptRate !== undefined ? `${ni(t.draftAcceptRate * 100)}% accepted` : ""]), + ] + // 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 } +} + +/** The view as text; kept for tests that look for a figure. */ export function formatSplashLine( t: SplashTurn, - model: string, + _model: string, hostTtft?: number, host: { total?: number; retries?: number; steps?: number; includesSubagents?: boolean } = {} ): string { - // Host-derived, and labelled as such. No derived figure on this line takes - // its numerator from one source and its denominator from the other -- ttft - // is measured directly, so nothing crosses the seam. - const ttftLabel = hostTtft !== undefined ? ` ttft ${nn(hostTtft, 2)}s (host)` : "" - const prompt = t.promptTokens + t.cachedTokens - return [ - `Splash ${short(model)}`, - t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s${ttftLabel}` : ttftLabel.trim(), - t.prefillTokS !== undefined ? `prefill ${ni(t.prefillTokS)} tok/s` : "", - `${ni(t.completionTokens)} tok ${nn(host.total ?? t.prefillS + t.decodeS, 2)}s${ - (host.retries ?? 0) > 0 ? ` (${host.retries} ${host.retries === 1 ? "retry" : "retries"})` : "" - }${host.includesSubagents ? " incl. sub-agents" : ""}`, - `${ni(prompt)} prompt${t.cachedTokens > 0 ? `, ${ni(t.cachedTokens)} cached` : ""}`, - t.draftAcceptRate !== undefined ? `draft ${ni(t.draftAcceptRate * 100)}% accepted` : "", - // Only when a turn spanned several requests (tool round trips), so the - // figures above read as sums rather than as one reply. - t.requests > 1 && host.steps === undefined ? `${ni(t.requests)} requests this turn` : "", - ] - .filter(Boolean) - .join("\n") + return viewText(splashView(t, hostTtft, host)) } diff --git a/package.json b/package.json index c0417e7..08e65ef 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/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/references.test.mjs" }, "files": [ "tui.tsx", @@ -36,6 +36,7 @@ "history.ts", "panels.ts", "session.ts", + "rows.ts", "adapters/", "README.md", "LICENSE" diff --git a/rows.ts b/rows.ts new file mode 100644 index 0000000..b3bc812 --- /dev/null +++ b/rows.ts @@ -0,0 +1,73 @@ +// The sidebar's layout unit: labelled rows, one figure per line. +// +// Pure, like history.ts and panels.ts. Adapters and the universal layer build +// a TurnView; the entry file draws it into a box. A value with parts +// continues on the next row under an empty label, so every row fits the +// sidebar's 38 columns: a 12-cell label column inside a box with a 1-cell +// margin and 1-cell padding leaves 22 cells for the value. + +/** A label and its value. An empty label continues the row above. */ +export type Row = readonly [label: string, value: string] + +export interface TurnView { + /** The heading: the engine (or provider id), never the model. */ + engine: string + rows: Row[] + /** Full-width notes below the rows, e.g. why engine figures are missing. */ + notes: string[] + /** The one figure a collapsed heading keeps, e.g. `34.4 tok/s`. */ + key?: string +} + +/** Width of the label column, in cells. */ +export const LABEL_WIDTH = 12 + +/** Numbers with thousands separators, as the mockup shows them. */ +export const nt = (v: unknown): string => + typeof v === "number" && isFinite(v) ? Math.round(v).toLocaleString("en-US") : "?" + +/** A row per part: the first carries the label, the rest continue it. */ +export function rowsOf(label: string, parts: readonly string[]): Row[] { + return parts.filter(Boolean).map((p, i) => [i === 0 ? label : "", p] as const) +} + +/** One line per row, label padded to its column. */ +export function rowLines(rows: readonly Row[]): string[] { + return rows.map(([label, value]) => `${label.padEnd(LABEL_WIDTH)}${value}`) +} + +/** + * A view as plain text: the engine, then `label value` per row, then notes. + * Used to store a view per session and by tests that look for a figure. + */ +export function viewText(v: TurnView): string { + return [v.engine, ...v.rows.map(([l, val]) => (l ? `${l} ${val}` : val)), ...v.notes].join("\n") +} + +/** Serialised for the per-session store, which holds strings. */ +export function encodeView(v: TurnView): string { + return JSON.stringify(v) +} + +/** The stored string back to a view; a legacy plain-text line becomes a note. */ +export function decodeView(s: string): TurnView { + try { + const v = JSON.parse(s) as TurnView + if (v && typeof v.engine === "string" && Array.isArray(v.rows)) return { ...v, notes: v.notes ?? [] } + } catch { + // not JSON: a placeholder or a line stored by an earlier version + } + const [engine = "", ...rest] = s.split("\n") + return { engine, rows: [], notes: rest } +} + +/** + * The turn's total -- what the user waited, from OpenCode -- and any + * retries on the row below it. + */ +export function timeRows(total: number | undefined, retries = 0): Row[] { + return rowsOf("time", [ + total !== undefined && isFinite(total) ? `${total.toFixed(2)}s` : "", + retries > 0 ? `${retries} ${retries === 1 ? "retry" : "retries"}` : "", + ]) +} diff --git a/session.ts b/session.ts index 230edaa..3ebde04 100644 --- a/session.ts +++ b/session.ts @@ -16,6 +16,7 @@ import { nn, ni, short, money } from "./format" import type { TurnRecord } from "./history" +import { rowsOf, nt, type Row, type TurnView } from "./rows" /** Recent turns shown in the generation trend. */ export const TREND_TURNS = 8 @@ -89,10 +90,13 @@ export function rollupSubagents( } } -/** The per-turn block's sub-agent line: sums only, never a rate. */ -export function formatSubagentLine(r: SubagentRollup): string { - const cost = money(r.cost) - return `+${ni(r.count)} sub-agent${r.count === 1 ? "" : "s"} ${ni(r.tokens)} tok ${nn(r.spanS, 2)}s${cost ? ` ${cost}` : ""}` +/** The per-turn box's sub-agent rows: sums only, never a rate. */ +export function subagentRows(r: SubagentRollup): Row[] { + return rowsOf(r.count === 1 ? "sub-agent" : "sub-agents", [ + r.count === 1 ? `${nt(r.tokens)} tok` : `${ni(r.count)} · ${nt(r.tokens)} tok`, + `${nn(r.spanS, 2)}s`, + money(r.cost), + ]) } /** A turn's streaming time: recorded, or derived from an older row's rate. */ @@ -232,55 +236,53 @@ export function sparkline(values: readonly number[]): string { .join("") } +const pct = (v: number): string => `${ni(v * 100)}%` + /** - * The section's heading. Collapsed, it keeps its key figure so the closed - * section is still useful; expanded, the figures follow below it. It names - * the model and turn range when the session changed model partway through. + * The section as a view, laid out like the per-turn box: a heading, then + * labelled rows, one figure per line. The heading names the model and turn + * range when the session changed model partway through; collapsed, it keeps + * the average generation speed as its one figure. */ -export function sessionHeading(s: SessionSummary, expanded: boolean): string { - const scope = +export function sessionView(s: SessionSummary): TurnView { + const engine = s.turns === s.totalTurns - ? `${ni(s.turns)} ${s.turns === 1 ? "turn" : "turns"}` - : `${short(s.model, 16)} · ${ni(s.turns)} of ${ni(s.totalTurns)} turns` - if (expanded) return `▾ Session · ${scope}` - const key = s.genTokS !== undefined ? ` · ${nn(s.genTokS)} tok/s avg` : "" - return `▸ Session · ${scope}${key}` -} - -const pct = (v: number): string => `${ni(v * 100)}%` - -/** The expanded section's rows, as label/value pairs; absent figures leave none. */ -export function sessionRows(s: SessionSummary): Array<[string, string]> { - const rows: Array<[string, string]> = [] + ? `Session · ${ni(s.turns)} ${s.turns === 1 ? "turn" : "turns"}` + : `Session · ${short(s.model, 14)} · ${ni(s.turns)}/${ni(s.totalTurns)}` + const rows: Row[] = [] if (s.genTokS !== undefined) { + rows.push(["speed", `${nn(s.genTokS)} tok/s avg`]) const spark = sparkline(s.trend) - rows.push(["generation", `${nn(s.genTokS)} tok/s avg${spark ? ` ${spark}` : ""}`]) + if (spark) rows.push(["trend", spark]) } if (s.ttftMedian !== undefined && s.ttftMax !== undefined) { - rows.push([ - "ttft", - s.turns > 1 ? `${nn(s.ttftMedian, 2)}s median · ${nn(s.ttftMax, 2)}s max` : `${nn(s.ttftMedian, 2)}s`, - ]) + rows.push( + ...(s.turns > 1 + ? rowsOf("ttft", [`${nn(s.ttftMedian, 2)}s median`, `${nn(s.ttftMax, 2)}s max`]) + : rowsOf("ttft", [`${nn(s.ttftMedian, 2)}s`])) + ) } if (s.cacheHit !== undefined) rows.push(["cache", `${pct(s.cacheHit)} hit`]) if (s.time) { - const parts = [`${pct(s.time.generating)} gen`, `${pct(s.time.waiting)} wait`] - if (s.time.subagents > 0) parts.push(`${pct(s.time.subagents)} sub-agents`) - parts.push(`${pct(s.time.other)} other`) - rows.push(["time", parts.join(" · ")]) + rows.push( + ...rowsOf("time", [ + `${pct(s.time.generating)} generating`, + `${pct(s.time.waiting)} waiting`, + s.time.subagents > 0 ? `${pct(s.time.subagents)} sub-agents` : "", + `${pct(s.time.other)} other`, + ]) + ) } if (s.engine) { - const e = [ - s.engine.mtpX !== undefined ? `MTP ${nn(s.engine.mtpX, 2)}x` : "", - s.engine.draftAccept !== undefined ? `draft ${pct(s.engine.draftAccept)}` : "", - s.engine.prefillTokS !== undefined ? `prefill ${ni(s.engine.prefillTokS)} tok/s` : "", - ].filter(Boolean) - if (e.length > 0) rows.push(["engine", e.join(" · ")]) + if (s.engine.mtpX !== undefined) rows.push(["MTP", `${nn(s.engine.mtpX, 2)}x avg`]) + if (s.engine.draftAccept !== undefined) rows.push(["draft", `${pct(s.engine.draftAccept)} avg`]) + if (s.engine.prefillTokS !== undefined) rows.push(["prefill", `${ni(s.engine.prefillTokS)} tok/s avg`]) } if (s.subagents) { - const cost = money(s.subagents.cost) - rows.push(["sub-agents", `${ni(s.subagents.count)} · ${ni(s.subagents.tokens)} tok${cost ? ` · ${cost}` : ""}`]) + rows.push( + ...rowsOf("sub-agents", [`${ni(s.subagents.count)} · ${nt(s.subagents.tokens)} tok`, money(s.subagents.cost)]) + ) } if (s.retries > 0) rows.push(["retries", ni(s.retries)]) - return rows + return { engine, rows, notes: [], key: s.genTokS !== undefined ? `${nn(s.genTokS)} tok/s` : undefined } } diff --git a/test/koboldcpp.test.mjs b/test/koboldcpp.test.mjs index c61a737..f111840 100644 --- a/test/koboldcpp.test.mjs +++ b/test/koboldcpp.test.mjs @@ -178,7 +178,9 @@ test("KoboldCpp: renders a note when several generations landed in one window", const after = parseKoboldPerf(raw("koboldcpp-multigen-after.json")) const t = koboldTurn(after, before.total_gens) const out = formatKoboldLine(t, "qwen2.5-0.5b-instruct-q4_k_m") - assert.ok(out.includes("2 generations this turn (last shown only)"), out) + // A full-width note, split to fit the sidebar: 2 generations this turn, + // last shown only. + assert.ok(out.includes("2 generations this turn\n(last shown only)"), out) }) test("KoboldCpp: a normal single-generation turn carries no such note", () => { @@ -234,7 +236,7 @@ test("KoboldCpp steps: nothing is dropped, so no 'last shown only' note", () => const t = combineKoboldSteps([{ perf: stepA, hostTokens: 40 }, { perf: stepB, hostTokens: 83 }]) const out = formatKoboldLine(t, "m", 0.4, { total: 9.5, retries: 0 }) assert.ok(!out.includes("last shown only"), out) - assert.ok(out.includes("123 tok 9.50s"), `OpenCode's total, not the engine's phases:\n${out}`) + assert.ok(out.includes("tokens 123\ntime 9.50s"), `OpenCode's total, not the engine's phases:\n${out}`) }) console.log(`\n${passed} passed`) diff --git a/test/llamacpp.test.mjs b/test/llamacpp.test.mjs index 8a46a90..a9a6734 100644 --- a/test/llamacpp.test.mjs +++ b/test/llamacpp.test.mjs @@ -14,6 +14,7 @@ import { diffLlamaCppCounters, formatLlamaCppLine, llamaCppIsThisTurn, + llamaCppView, } from "../adapters/llamacpp.ts" const dir = path.dirname(fileURLToPath(import.meta.url)) @@ -120,16 +121,16 @@ test("a near-total prefix-cache hit yields a plausible rate, not an absurd one", }) // ---- rendering -------------------------------------------------------------- -test("renders four lines, labelled for whichever server it is", () => { +test("renders a row per figure, headed by whichever server it is", () => { const t = diffLlamaCppCounters(before, after) - const out = formatLlamaCppLine(t, "llama.cpp", "qwen2.5-0.5b-instruct-q4_k_m").split("\n") - assert.equal(out.length, 4) - assert.ok(out[0].startsWith("llama.cpp ")) - assert.ok(/^\d+\.\d tok\/s$/.test(out[1]), out[1]) - assert.ok(out[2].startsWith("prefill ")) - assert.ok(out[3].startsWith("32 tok")) - // llamafile shares this adapter; only the label changes. - assert.ok(formatLlamaCppLine(t, "llamafile", "m").startsWith("llamafile ")) + const v = llamaCppView(t, "llama.cpp") + assert.equal(v.engine, "llama.cpp") + assert.deepEqual(v.rows.map(([l]) => l), ["speed", "prefill", "tokens", "time"]) + const rows = Object.fromEntries(v.rows) + assert.ok(/^\d+\.\d tok\/s$/.test(rows.speed), rows.speed) + assert.equal(rows.tokens, "32") + // llamafile shares this adapter; only the heading changes. + assert.equal(llamaCppView(t, "llamafile").engine, "llamafile") }) test("missing rates are omitted, never rendered as placeholders", () => { @@ -140,7 +141,8 @@ test("missing rates are omitted, never rendered as placeholders", () => { }) const out = formatLlamaCppLine(t, "llama.cpp", "m") assert.ok(!out.includes("?"), out) - assert.equal(out.split("\n").length, 2) + const labels = llamaCppView(t, "llama.cpp").rows.map(([l]) => l) + assert.ok(!labels.includes("speed") && !labels.includes("prefill"), labels.join(",")) }) // ---- is the window this turn's? --------------------------------------------- @@ -166,13 +168,13 @@ test("with no host count to compare, the check does not apply", () => { test("the total is OpenCode's -- what you waited -- with retries named", () => { const t = diffLlamaCppCounters(before, after) const out = formatLlamaCppLine(t, "llama.cpp", "m", 0.3, { total: 7.0, retries: 1 }) - assert.ok(out.includes("32 tok 7.00s (1 retry)"), out) + assert.ok(out.includes("tokens 32\ntime 7.00s\n1 retry"), out) }) test("figures that include a same-engine sub-agent say so", () => { const t = diffLlamaCppCounters(before, after) const out = formatLlamaCppLine(t, "llama.cpp", "m", 0.3, { total: 7.0, includesSubagents: true }) - assert.ok(out.includes("32 tok 7.00s incl. sub-agents"), out) + assert.ok(out.includes("tokens 32\nincl. sub-agents"), out) }) console.log(`\n${passed} passed`) diff --git a/test/mlxserve.test.mjs b/test/mlxserve.test.mjs index 0234957..98b0b86 100644 --- a/test/mlxserve.test.mjs +++ b/test/mlxserve.test.mjs @@ -179,7 +179,7 @@ test("mlx-serve: renders a note when several records were summed into one turn", const t = mlxServeTurn(recs, oldest) const out = formatMlxServeLine(t, "qwen05") assert.ok(out.includes("2 requests this turn"), out) - assert.ok(out.includes("194 tok"), out) + assert.ok(out.includes("tokens 194"), out) }) test("mlx-serve: a normal single-record turn carries no such note", () => { @@ -224,7 +224,7 @@ test("mlx-serve steps: every request accounted for, so no 'requests this turn' n const t = combineMlxServeSteps([{ records: afterStep1, hostTokens: 100 }, { records: afterStep2, hostTokens: 94 }]) const out = formatMlxServeLine(t, "qwen05", { total: 12.0, retries: 1 }) assert.ok(!out.includes("requests this turn"), out) - assert.ok(out.includes("194 tok 12.00s (1 retry)"), out) + assert.ok(out.includes("tokens 194\ntime 12.00s\n1 retry"), out) }) console.log(`\n${passed} passed`) diff --git a/test/mtplx.test.mjs b/test/mtplx.test.mjs index 2a2cf71..a003f3b 100644 --- a/test/mtplx.test.mjs +++ b/test/mtplx.test.mjs @@ -27,7 +27,7 @@ import { strict as assert } from "node:assert" import { readFileSync } from "node:fs" import { fileURLToPath } from "node:url" import path from "node:path" -import { formatMtplxLine, combineMtplxSteps } from "../adapters/mtplx.ts" +import { formatMtplxLine, combineMtplxSteps, mtplxView } from "../adapters/mtplx.ts" const dir = path.dirname(fileURLToPath(import.meta.url)) const fixture = (name) => JSON.parse(readFileSync(path.join(dir, "..", "fixtures", name), "utf8")) @@ -55,16 +55,18 @@ test("a completed turn's completion_tokens matches the response's own usage", () assert.equal(completed.latest.completion_tokens, usage.completion_tokens) }) -test("a completed turn renders all five lines from the live receipt", () => { - const out = formatMtplxLine(completed.latest, MODEL).split("\n") - assert.equal(out.length, 5, out.join(" | ")) - assert.ok(out[0].startsWith("MTPLX ")) - // The ttft beside the rate is what reconciles it with OpenCode's own - // whole-turn figure; the rate itself carries no qualifier. - assert.ok(/^\d+\.\d tok\/s {2}ttft \d\.\d{2}s$/.test(out[1]), out[1]) - assert.ok(/^prefill \d+ tok\/s$/.test(out[2]), out[2]) - assert.ok(out[3].startsWith(`${completed.latest.completion_tokens} tok`)) - assert.ok(/^MTP \d+\.\d{2}x( \d+(\/\d+)*%)?$/.test(out[4]), out[4]) +test("a completed turn renders a row per figure from the live receipt", () => { + const v = mtplxView(completed.latest) + assert.equal(v.engine, "MTPLX", "the heading names the engine, not the model") + const rows = Object.fromEntries(v.rows) + // The rate carries no qualifier; the ttft beside it explains the rest. + assert.ok(/^\d+\.\d tok\/s$/.test(rows.speed), rows.speed) + assert.ok(/^\d\.\d{2}s$/.test(rows.ttft), rows.ttft) + assert.ok(/^\d+ tok\/s$/.test(rows.prefill), rows.prefill) + assert.equal(rows.tokens, String(completed.latest.completion_tokens)) + assert.ok(/^\d+\.\d{2}x$/.test(rows.MTP), rows.MTP) + assert.ok(/^\d+(\/\d+)*%$/.test(rows.accepted), rows.accepted) + assert.equal(v.key, rows.speed, "collapsed, the heading keeps the speed") }) test("completion_tokens already includes reasoning — no separate think subset is claimed", () => { @@ -72,7 +74,7 @@ test("completion_tokens already includes reasoning — no separate think subset // field carrying that split, so the topline is the bare total, matching // what tokensLabel(total, 0) would render — never a fabricated "(N think)". const out = formatMtplxLine(completed.latest, MODEL) - assert.ok(out.includes(`${completed.latest.completion_tokens} tok`), out) + assert.ok(out.includes(`tokens ${completed.latest.completion_tokens}`), out) assert.ok(!out.includes("think"), "no think breakdown is available from /metrics") }) @@ -92,10 +94,11 @@ test("an interrupted turn omits the missing figures instead of printing ?", () = }) test("an interrupted turn still shows decode rate, tokens and elapsed time", () => { - const out = formatMtplxLine(interrupted.latest, MODEL).split("\n") + const rows = Object.fromEntries(mtplxView(interrupted.latest).rows) const l = interrupted.latest - assert.ok(out[1].startsWith(`${l.decode_tok_s.toFixed(1)} tok/s`)) - assert.ok(out.some((line) => line.startsWith(`${l.completion_tokens} tok`))) + assert.equal(rows.speed, `${l.decode_tok_s.toFixed(1)} tok/s`) + assert.equal(rows.tokens, String(l.completion_tokens)) + assert.ok(rows.time, "elapsed time survives the interruption") }) test("no verify_calls on the interrupted turn means no MTP line", () => { @@ -121,9 +124,10 @@ test("no verify passes means no MTP line, not a division by zero", () => { }) test("an empty receipt renders the header alone, with no holes", () => { - const out = formatMtplxLine({}, MODEL) - assert.equal(out, "MTPLX arsis-dev-ukisai-swift-…") - assert.ok(!out.includes("?")) + const v = mtplxView({}) + assert.equal(v.rows.length, 0) + assert.equal(formatMtplxLine({}, MODEL), "MTPLX") + assert.equal(v.key, undefined) }) test("NaN is treated as absent, not rendered", () => { @@ -202,7 +206,9 @@ test("the total shown is OpenCode's -- what you waited -- with retries named", ( // step and the tool time between them, which only the host has. const c = combineMtplxSteps([{ receipt: stepA, hostTokens: 64 }, { receipt: stepB, hostTokens: 138 }]) const out = formatMtplxLine(c, MODEL, { total: 7.0, retries: 2 }) - assert.ok(out.includes("202 tok 7.00s (2 retries)"), out) + const v = mtplxView(c, { total: 7.0, retries: 2 }) + assert.deepEqual(v.rows.filter(([l]) => l === "tokens" || l === "time" || l === ""), + [["tokens", "202"], ["time", "7.00s"], ["", "2 retries"]]) assert.ok(!out.includes("4.48"), out) }) diff --git a/test/omlx.test.mjs b/test/omlx.test.mjs index 635f4b6..76b106e 100644 --- a/test/omlx.test.mjs +++ b/test/omlx.test.mjs @@ -27,7 +27,7 @@ import { strict as assert } from "node:assert" import { readFileSync } from "node:fs" import { fileURLToPath } from "node:url" import path from "node:path" -import { recoverLatest, formatOmlxLine, toOmlxSample, omlxIsThisTurn } from "../adapters/omlx.ts" +import { recoverLatest, formatOmlxLine, toOmlxSample, omlxIsThisTurn, omlxView } from "../adapters/omlx.ts" const dir = path.dirname(fileURLToPath(import.meta.url)) const fixture = (name) => JSON.parse(readFileSync(path.join(dir, "..", "fixtures", name), "utf8")) @@ -87,11 +87,10 @@ test("with no prior request this session, shows the server average, labelled", ( test("one real request recovers the exact rate the server measured", () => { // before: requests 0, avgGen 0. after: requests 1, avgGen 80, avgPrefill 72.3. // recoverLatest(0,0,80,1) = 80*1 - 0*0 = 80 exactly. - const out = formatOmlxLine(afterOne, before).split("\n") - assert.equal(out.length, 4, out.join(" | ")) - assert.equal(out[1], "80.0 tok/s") - assert.equal(out[2], "prefill 72 tok/s") - assert.ok(!out.some((l) => l.includes("avg")), "a single recovered request carries no avg label") + const rows = Object.fromEntries(omlxView(afterOne, before).rows) + assert.equal(rows.speed, "80.0 tok/s") + assert.equal(rows.prefill, "72 tok/s") + assert.ok(!formatOmlxLine(afterOne, before).includes("avg"), "a single recovered request carries no avg label") }) test("the single-request token deltas match the response's own usage", () => { @@ -104,9 +103,9 @@ test("the single-request token deltas match the response's own usage", () => { test("two real requests in one window fall back to the lifetime average, labelled", () => { // 3 - 1 = 2 new requests: recovery refuses, so this exercises the fallback // this fixture pair exists for. - const out = formatOmlxLine(afterTwo, afterOne).split("\n") - assert.equal(out[1], "70.9 tok/s (avg)", out.join(" | ")) - assert.equal(out[2], "prefill 66 tok/s (avg)") + const rows = Object.fromEntries(omlxView(afterTwo, afterOne).rows) + assert.equal(rows.speed, "70.9 tok/s (avg)") + assert.equal(rows.prefill, "66 tok/s (avg)") }) test("even in the fallback, the token counts are the window's own exact deltas", () => { @@ -117,7 +116,7 @@ test("even in the fallback, the token counts are the window's own exact deltas", assert.equal(afterTwo.completion - afterOne.completion, wantCompletion) assert.equal(afterTwo.prompt - afterOne.prompt, wantPrompt) const out = formatOmlxLine(afterTwo, afterOne) - assert.ok(out.includes(`${wantCompletion} tok (${wantPrompt} prompt)`), out) + assert.ok(out.includes(`tokens ${wantCompletion}`) && out.includes(`prompt ${wantPrompt}`), out) }) // ---- synthetic edge cases: real data can't exercise these on demand -------- @@ -179,7 +178,7 @@ test("a multi-step turn uses OpenCode's generation rate, not the server's all-ti test("figures that include a same-engine sub-agent say so", () => { const out = formatOmlxLine(afterTwo, afterOne, 0.5, { decodeTokS: 42.5, total: 12.0, includesSubagents: true }) - assert.ok(out.includes("12.00s incl. sub-agents"), out) + assert.ok(out.includes("incl. sub-agents"), out) }) console.log(`\n${passed} passed`) diff --git a/test/prometheus.test.mjs b/test/prometheus.test.mjs index adee9fe..3c96ef2 100644 --- a/test/prometheus.test.mjs +++ b/test/prometheus.test.mjs @@ -382,8 +382,8 @@ test("Prometheus: a rejected title request leaves the turn's engine figures inta const diff = diffPromSamples(before, now) const out = formatPromLine(diff, "vllm-mlx", "m", { decodeTokS: 61.9, total: 5.86, rateWindow: "decode", tokens: 45 }) assert.ok(out !== null, "a matching token count keeps the engine line") - assert.ok(out.includes("45 tok"), out) - assert.ok(out.includes("8200 prompt"), out) + assert.ok(out.includes("tokens 45"), out) + assert.ok(out.includes("prompt 8,200"), out) }) test("Prometheus: a duration averaged over two requests is never shown as the turn's", () => { @@ -438,8 +438,8 @@ test("Prometheus: a clean two-step tool turn keeps its engine tokens", () => { const diff = diffPromSamples(before, now) const out = formatPromLine(diff, "vllm-mlx", "m", { decodeTokS: 20.1, ttft: 3.2, total: 60.0, rateWindow: "decode", tokens: 84, steps: 2 }) assert.ok(out !== null, "two steps, two TTFTs, matching tokens: this turn's") - assert.ok(out.includes("84 tok"), out) - assert.ok(out.includes("21423 prompt"), out) + assert.ok(out.includes("tokens 84"), out) + assert.ok(out.includes("prompt 21,423"), out) // TTFT: the host's first step, not the engine's mean over both steps (4.5s). assert.ok(out.includes("ttft 3.20s"), out) assert.ok(!out.includes("4.50"), out) @@ -508,14 +508,14 @@ test("Prometheus: with neither rate available, no rate line is invented", () => const out = formatPromLine(diff, "vLLM", "m", NO_FALLBACK) assert.ok(!out.includes("?"), out) assert.ok(!out.includes("tok/s"), "no rate at all rather than a placeholder") - assert.ok(out.includes("50 tok"), "exact token counts still survive") + assert.ok(out.includes("tokens 50"), "exact token counts still survive") }) test("Prometheus: cached tokens are named only when some were reused", () => { const cold = { completionTokens: 50, promptTokens: 33, cachedTokens: 0, ttftExact: true } assert.ok(!formatPromLine(cold, "vLLM", "m", NO_FALLBACK).includes("cached")) const warm = { ...cold, cachedTokens: 34 } - assert.ok(formatPromLine(warm, "vLLM", "m", NO_FALLBACK).includes("34 cached")) + assert.ok(formatPromLine(warm, "vLLM", "m", NO_FALLBACK).includes("cached 34")) }) test("Prometheus: a prefill rate appears only for an engine that times it", () => { @@ -531,10 +531,10 @@ test("Prometheus: renders from a real live vLLM capture", () => { parsePromSample(fixture("vllm-metal-after.prom"), VLLM_SPEC) ) const out = formatPromLine(diff, "vLLM", "Qwen2.5-0.5B", { decodeTokS: 18.2, total: 1.9 }) - assert.ok(out.split("\n")[0].startsWith("vLLM "), out) + assert.equal(out.split("\n")[0], "vLLM", "the heading names the engine, not the model") // The fixture header records usage {prompt_tokens: 35, completion_tokens: 35}. - assert.ok(out.includes("35 tok"), out) - assert.ok(out.includes("35 prompt"), out) + assert.ok(out.includes("tokens 35"), out) + assert.ok(out.includes("prompt 35"), out) assert.ok(!out.includes("?"), out) }) @@ -548,7 +548,7 @@ test("Prometheus: a window with a same-engine sub-agent is accepted and labelled const out = formatPromLine(diff, "vllm-mlx", "m", { decodeTokS: 30, ttft: 1.2, total: 207.4, tokens: 1424, steps: 7, includesSubagents: true }) assert.ok(out !== null, "5 parent steps + 2 sub-agent steps, 1233 + 191 tokens") - assert.ok(out.includes("1424 tok (30000 prompt) incl. sub-agents"), out) + assert.ok(out.includes("tokens 1,424\nincl. sub-agents") && out.includes("prompt 30,000"), out) }) test("Prometheus: without the flag, the same line carries no such label", () => { diff --git a/test/rows.test.mjs b/test/rows.test.mjs new file mode 100644 index 0000000..1cb23c0 --- /dev/null +++ b/test/rows.test.mjs @@ -0,0 +1,56 @@ +// Validates rows.ts -- the labelled-row layout the sidebar draws. +// Run with: bun test/rows.test.mjs +import { strict as assert } from "node:assert" +import { rowsOf, rowLines, viewText, encodeView, decodeView, nt, LABEL_WIDTH } from "../rows.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 + } +} + +test("a value with parts continues on rows with an empty label", () => { + assert.deepEqual(rowsOf("ttft", ["17.64s median", "17.64s max"]), [["ttft", "17.64s median"], ["", "17.64s max"]]) +}) + +test("empty parts leave no rows", () => { + assert.deepEqual(rowsOf("time", ["", "207.37s", ""]), [["time", "207.37s"]]) + assert.deepEqual(rowsOf("time", []), []) +}) + +test("rows fit the sidebar: 12-cell labels, and a 34-cell box interior", () => { + // 38 columns, less a 1-cell margin and 1-cell padding each side. + const lines = rowLines([["sub-agents", "1 · 191 tok"], ["", "23.91s"]]) + assert.equal(lines[0], "sub-agents 1 · 191 tok") + assert.equal(lines[1], " ".repeat(LABEL_WIDTH) + "23.91s") + lines.forEach((l) => assert.ok(l.length <= 34, l)) +}) + +test("numbers carry thousands separators", () => { + assert.equal(nt(1233), "1,233") + assert.equal(nt(26264.4), "26,264") + assert.equal(nt(undefined), "?") +}) + +test("a view round-trips through the per-session store", () => { + const v = { engine: "MTPLX", rows: [["speed", "34.4 tok/s"]], notes: ["engine data skipped"], key: "34.4 tok/s" } + assert.deepEqual(decodeView(encodeView(v)), v) +}) + +test("a legacy plain-text line decodes to its heading and notes, never throws", () => { + assert.deepEqual(decodeView("inference · —"), { engine: "inference · —", rows: [], notes: [] }) + assert.deepEqual(decodeView("vllm-mlx m\n60.4 tok/s"), { engine: "vllm-mlx m", rows: [], notes: ["60.4 tok/s"] }) +}) + +test("as text, a labelled row reads 'label value' and a continuation just its value", () => { + const t = viewText({ engine: "MTPLX", rows: [["ttft", "0.31s (avg)"], ["", "2.00s max"]], notes: ["note"] }) + assert.equal(t, "MTPLX\nttft 0.31s (avg)\n2.00s max\nnote") +}) + +console.log(`\n${passed} passed`) diff --git a/test/session.test.mjs b/test/session.test.mjs index 6efbd26..8682d9c 100644 --- a/test/session.test.mjs +++ b/test/session.test.mjs @@ -6,7 +6,7 @@ // models, and an engine-only figure is averaged only over turns that had it. // Run with: bun test/session.test.mjs import { strict as assert } from "node:assert" -import { summariseSession, sessionHeading, sessionRows, sparkline, rollupSubagents, formatSubagentLine } from "../session.ts" +import { summariseSession, sessionView, sparkline, rollupSubagents, subagentRows } from "../session.ts" let passed = 0 function test(name, fn) { @@ -114,27 +114,48 @@ test("retries are summed", () => { assert.equal(summariseSession([row({ retries: 2 }), row({ retries: 1 }), row()], SID).retries, 3) }) -test("the collapsed heading keeps its key figure", () => { - const s = summariseSession([row(), row()], SID) - assert.equal(sessionHeading(s, false), "▸ Session · 2 turns · 50.0 tok/s avg") - assert.equal(sessionHeading(s, true), "▾ Session · 2 turns") +// Row values in the mockup: one figure per line, a value with parts +// continuing under an empty label, every row within the box's 34 cells. +const rowsOfView = (s) => sessionView(s).rows +const labelled = (s) => Object.fromEntries(rowsOfView(s).filter(([l]) => l)) +const continuation = (s, label) => { + const rows = rowsOfView(s) + const i = rows.findIndex(([l]) => l === label) + const out = [rows[i][1]] + for (let j = i + 1; j < rows.length && rows[j][0] === ""; j++) out.push(rows[j][1]) + return out +} + +test("the heading names the session, and collapsed it keeps the average speed", () => { + const v = sessionView(summariseSession([row(), row()], SID)) + assert.equal(v.engine, "Session · 2 turns") + assert.equal(v.key, "50.0 tok/s") }) test("a heading says which turns count when the model changed", () => { - const s = summariseSession([row({ model: "b" }), row(), row()], SID) - assert.equal(sessionHeading(s, true), "▾ Session · b · 1 of 3 turns") + const v = sessionView(summariseSession([row({ model: "b" }), row(), row()], SID)) + assert.equal(v.engine, "Session · b · 1/3") }) test("one turn is singular", () => { - assert.equal(sessionHeading(summariseSession([row()], SID), true), "▾ Session · 1 turn") + assert.equal(sessionView(summariseSession([row()], SID)).engine, "Session · 1 turn") +}) + +test("every row fits the box's 34 cells", () => { + const s = summariseSession( + [row({ ttft: 0.5, retries: 2, engine: { mtpX: 3.4, prefillTokS: 449, draftAccept: 0.7 }, + subagents: { count: 2, tokens: 12345, spanS: 30, cost: 0.012 } }), row({ ttft: 17.6 })], + SID + ) + rowsOfView(s).forEach(([l, v]) => assert.ok(12 + v.length <= 34, `${l}: ${v}`)) }) test("rows are label/value pairs, and an absent figure leaves no row", () => { const s = summariseSession([row({ ttft: undefined, cached: undefined, promptTokens: undefined })], SID) - const labels = sessionRows(s).map(([l]) => l) + const labels = rowsOfView(s).map(([l]) => l) assert.ok(!labels.includes("ttft"), labels.join(",")) assert.ok(!labels.includes("cache"), labels.join(",")) - assert.ok(labels.includes("generation")) + assert.ok(labels.includes("speed")) }) test("rows read as aggregates: avg, median, max, %", () => { @@ -142,12 +163,13 @@ test("rows read as aggregates: avg, median, max, %", () => { [row({ ttft: 0.5, retries: 2, engine: { mtpX: 3.4, prefillTokS: 449 } }), row({ ttft: 17.6 })], SID ) - const rows = Object.fromEntries(sessionRows(s)) - assert.ok(rows.generation.startsWith("50.0 tok/s avg"), rows.generation) - assert.equal(rows.ttft, "9.05s median · 17.60s max") + const rows = labelled(s) + assert.equal(rows.speed, "50.0 tok/s avg") + assert.deepEqual(continuation(s, "ttft"), ["9.05s median", "17.60s max"]) assert.equal(rows.cache, "80% hit") - assert.equal(rows.time, "20% gen · 10% wait · 70% other") - assert.equal(rows.engine, "MTP 3.40x · prefill 449 tok/s") + assert.deepEqual(continuation(s, "time"), ["20% generating", "10% waiting", "70% other"]) + assert.equal(rows.MTP, "3.40x avg") + assert.equal(rows.prefill, "449 tok/s avg") assert.equal(rows.retries, "2") }) @@ -191,9 +213,8 @@ test("sub-agent time is the span they ran, not a sum -- they can run in parallel test("the per-turn line sums tokens, time and cost but never a rate", () => { const r = rollupSubagents([child()], ["ses_child"], 0, 40_000) - const line = formatSubagentLine(r) - assert.equal(line, "+1 sub-agent 228 tok 25.00s $0.0040") - assert.ok(!line.includes("tok/s")) + assert.deepEqual(subagentRows(r), [["sub-agent", "228 tok"], ["", "25.00s"], ["", "$0.0040"]]) + assert.ok(!subagentRows(r).some(([, v]) => v.includes("tok/s"))) }) test("the session section sums each turn's sub-agents", () => { @@ -202,7 +223,7 @@ test("the session section sums each turn's sub-agents", () => { SID ) assert.deepEqual(s.subagents, { count: 3, tokens: 600, cost: 0.01 }) - assert.equal(Object.fromEntries(sessionRows(s))["sub-agents"], "3 · 600 tok · $0.010") + assert.deepEqual(continuation(s, "sub-agents"), ["3 · 600 tok", "$0.010"]) }) test("sub-agent time is split out of other, not added on top", () => { @@ -216,10 +237,9 @@ test("sub-agent time is split out of other, not added on top", () => { }) test("the time row names sub-agents only when some ran", () => { - const with_ = Object.fromEntries(sessionRows(summariseSession([row({ subagents: { count: 1, tokens: 50, spanS: 6 } }), row()], SID))) - assert.equal(with_.time, "20% gen · 10% wait · 30% sub-agents · 40% other") - const without = Object.fromEntries(sessionRows(summariseSession([row(), row()], SID))) - assert.equal(without.time, "20% gen · 10% wait · 70% other") + const withSub = summariseSession([row({ subagents: { count: 1, tokens: 50, spanS: 6 } }), row()], SID) + assert.deepEqual(continuation(withSub, "time"), ["20% generating", "10% waiting", "30% sub-agents", "40% other"]) + assert.deepEqual(continuation(summariseSession([row(), row()], SID), "time"), ["20% generating", "10% waiting", "70% other"]) }) test("the roll-up carries its sub-agents' steps -- one engine request each", () => { diff --git a/test/splash.test.mjs b/test/splash.test.mjs index 3c7035c..98a58f4 100644 --- a/test/splash.test.mjs +++ b/test/splash.test.mjs @@ -174,7 +174,7 @@ test("Splash: renders the full line from a real captured turn", () => { parseSplashSample(fixture("splash-after.prom")) ) const out = formatSplashLine(t, "incoai/Qwen3.8-27B-Splash").split("\n") - assert.ok(out[0].startsWith("Splash "), out[0]) + assert.equal(out[0], "Splash", "the heading names the engine, not the model") // Every line must be a real figure; a "?" means a caller should have // dropped the line instead of printing it. assert.ok(!out.some((l) => l.includes("?")), out.join(" | ")) @@ -190,7 +190,7 @@ test("Splash: the prompt line sums prefilled and cached, not just prefilled", () ) const out = formatSplashLine(t, "m") const total = t.promptTokens + t.cachedTokens - assert.ok(out.includes(`${total} prompt`), `expected ${total} prompt in: ${out}`) + assert.ok(out.includes(`prompt ${total}`), `expected prompt ${total} in: ${out}`) }) test("Splash: cached is named only when some was actually reused", () => { @@ -198,7 +198,7 @@ test("Splash: cached is named only when some was actually reused", () => { decodeS: 5, prefillS: 0.27, decodeTokS: 40, prefillTokS: 233, requests: 1 } assert.ok(!formatSplashLine(cold, "m").includes("cached"), "cold prompt must not mention cache") const warm = { ...cold, promptTokens: 31, cachedTokens: 32 } - assert.ok(formatSplashLine(warm, "m").includes("32 cached"), "a real cache hit must be named") + assert.ok(formatSplashLine(warm, "m").includes("cached 32"), "a real cache hit must be named") }) test("Splash: an absent rate is omitted, never rendered as a placeholder", () => { @@ -207,7 +207,7 @@ test("Splash: an absent rate is omitted, never rendered as a placeholder", () => const out = formatSplashLine(t, "m") assert.ok(!out.includes("?"), out) assert.ok(!out.includes("tok/s"), "no rate should appear at all") - assert.ok(out.includes("200 tok"), "the token count still survives") + assert.ok(out.includes("tokens 200"), "the token count still survives") }) test("Splash: no speculation means no accept line, not 0% accepted", () => { @@ -284,7 +284,7 @@ test("Splash: a verified multi-step turn needs no 'requests this turn' note", () const t = { ...diffSplashSamples(b, a), requests: 2 } const verified = formatSplashLine(t, "m", 0.4, { total: 9.0, steps: 2 }) assert.ok(!verified.includes("requests this turn"), verified) - assert.ok(verified.includes("200 tok 9.00s"), verified) + assert.ok(verified.includes("tokens 200\ntime 9.00s"), verified) // Unverified (no step count), the note still says the figures are sums. assert.ok(formatSplashLine(t, "m").includes("2 requests this turn")) }) @@ -293,7 +293,7 @@ test("Splash: figures that include a same-engine sub-agent say so", () => { const [b, a] = pair("splash") const t = { ...diffSplashSamples(b, a), requests: 2 } const out = formatSplashLine(t, "m", 0.4, { total: 9.0, steps: 2, includesSubagents: true }) - assert.ok(out.includes("200 tok 9.00s incl. sub-agents"), out) + assert.ok(out.includes("tokens 200\nincl. sub-agents\ntime 9.00s"), out) }) console.log(`\n${passed} passed`) diff --git a/test/universal.test.mjs b/test/universal.test.mjs index 234feb7..41f6b57 100644 --- a/test/universal.test.mjs +++ b/test/universal.test.mjs @@ -8,7 +8,7 @@ // counts never disagreed — only the rate's numerator did. // Run with: bun test/universal.test.mjs import { strict as assert } from "node:assert" -import { turnRate, universalLine, turnSteps, aggregateTurn, DEFAULT_DISPLAY } from "../universal.ts" +import { turnRate, universalLine, universalView, turnSteps, aggregateTurn, DEFAULT_DISPLAY } from "../universal.ts" let passed = 0 function test(name, fn) { @@ -52,11 +52,11 @@ test("universalLine reports the engine's rate, not the visible-only one", () => test("the totals line is the topline total with thinking as a subset", () => { const line = universalLine("splash", "incoai/Qwen3.8-27B-Splash", splashInfo, splashTurn) // Topline is everything decoded, matching the engine's own `output 1,247`. - assert.ok(line.includes("1247 tok (889 think)"), line) + assert.ok(line.includes("tokens 1,247\n889 thinking"), line) // Never the additive form: `(+889 think)` invites summing to 2136. assert.ok(!line.includes("(+"), line) // And never the old visible-only topline. - assert.ok(!line.includes("358 tok"), line) + assert.ok(!line.includes("tokens 358"), line) assert.ok(line.includes("ttft 0.66s"), line) }) @@ -113,7 +113,7 @@ test("with no stream marks at all there is no rate, and nothing says overall", ( assert.equal(r.decodeTokS, undefined) const line = universalLine("mtplx", "m", info, undefined) assert.ok(!line.includes("tok/s"), line) - assert.ok(line.includes("37 tok 10.03s"), line) + assert.ok(line.includes("tokens 37\ntime 10.03s"), line) }) test("ttft falls back to the message's created, not only turn.startAt", () => { @@ -139,7 +139,7 @@ const metered = { test("a metered turn shows this turn's cost and its cache reuse", () => { const out = universalLine("opencode-go", "mimo-v2.6-flash", metered, { firstAt: 1500, lastAt: 2662 }) assert.ok(out.includes("$0.0006"), out) - assert.ok(out.includes("2048 cached"), out) + assert.ok(out.includes("cached 2,048"), out) }) test("a free model shows no cost line rather than $0.00", () => { @@ -147,7 +147,7 @@ test("a free model shows no cost line rather than $0.00", () => { const out = universalLine("mtplx", "local", free, { firstAt: 1500, lastAt: 2662 }) assert.ok(!out.includes("$"), out) // the cache figure is independent and survives - assert.ok(out.includes("2048 cached"), out) + assert.ok(out.includes("cached 2,048"), out) }) test("a cold prompt shows no cache line rather than 0 cached", () => { @@ -157,11 +157,12 @@ test("a cold prompt shows no cache line rather than 0 cached", () => { assert.ok(out.includes("$0.0006"), "cost is independent and survives") }) -test("with neither, the line is exactly the three it always was", () => { +test("with neither, the rows are exactly speed, ttft, tokens and time", () => { const bare = { time: { created: 1000, completed: 2662 }, tokens: { input: 10, output: 11, reasoning: 0, cache: { read: 0, write: 0 } } } - const out = universalLine("mtplx", "m", bare, { firstAt: 1500, lastAt: 2662 }) - assert.equal(out.split("\n").length, 3, out) + const v = universalView("mtplx", bare, { firstAt: 1500, lastAt: 2662 }) + assert.deepEqual(v.rows.map(([l]) => l), ["speed", "ttft", "tokens", "time"]) + assert.equal(v.engine, "mtplx", "the heading names the provider, not the model") }) test("the per-turn cost is used, never a running session total", () => { @@ -265,7 +266,7 @@ test("the turn spans the first step's start to the last step's end", () => { assert.equal(info.time.completed - info.time.created, 37_130) const line = universalLine("vllmmlx", "m", info, aggregateTurn(turnSteps(toolTurn), marks).turn) assert.ok(line.includes("37.13s"), line) - assert.ok(line.includes("316 tok"), line) + assert.ok(line.includes("tokens 316"), line) }) test("ttft is the first step's, not the last step's", () => { @@ -323,14 +324,14 @@ test("retries are counted per step and shown beside the total", () => { const { info, turn } = aggregateTurn(turnSteps(toolTurn), retried, { execStart: T0 - 23_000 }) assert.equal(turn.retries, 6) const line = universalLine("vllmmlx", "m", info, turn) - assert.ok(line.includes("60.13s (6 retries)"), line) + assert.ok(line.includes("time 60.13s\n6 retries"), line) }) test("one retry is singular, and none says nothing", () => { const once = new Map(marks) once.set("a2", { ...marks.get("a2"), attempts: 2 }) const r1 = aggregateTurn(turnSteps(toolTurn), once) - assert.ok(universalLine("x", "m", r1.info, r1.turn).includes("(1 retry)")) + assert.ok(universalLine("x", "m", r1.info, r1.turn).includes("\n1 retry")) const r0 = aggregateTurn(turnSteps(toolTurn), marks) assert.ok(!universalLine("x", "m", r0.info, r0.turn).includes("retr")) }) diff --git a/tui.tsx b/tui.tsx index 8704dd7..ea37513 100644 --- a/tui.tsx +++ b/tui.tsx @@ -29,33 +29,34 @@ import { appendFileSync } from "node:fs" import { short } from "./format" import type { HttpOptions } from "./http" -import { universalLine, turnRate, turnSteps, aggregateTurn, type Turn, type Display, DEFAULT_DISPLAY } from "./universal" -import { record, formatHistory, formatCollapsedLine, latestFor, type History, type TurnRecord } from "./history" -import { emptyPanels, lineFor, keyFor, setLine, LatestPerKey, type Panels } from "./panels" -import { summariseSession, sessionHeading, sessionRows, rollupSubagents, formatSubagentLine } from "./session" +import { universalView, turnRate, turnSteps, aggregateTurn, type Turn, type Display, DEFAULT_DISPLAY } from "./universal" +import { record, formatHistory, 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 { fetchMtplxLatest, formatMtplxLine, combineMtplxSteps, type MtplxLatest } from "./adapters/mtplx" -import { fetchOmlxSample, formatOmlxLine, omlxIsThisTurn, type OmlxSample } from "./adapters/omlx" +import { fetchMtplxLatest, mtplxView, combineMtplxSteps, type MtplxLatest } from "./adapters/mtplx" +import { fetchOmlxSample, omlxView, omlxIsThisTurn, type OmlxSample } from "./adapters/omlx" import { fetchLlamaCppCounters, diffLlamaCppCounters, - formatLlamaCppLine, + llamaCppView, llamaCppIsThisTurn, type LlamaCppCounters, } from "./adapters/llamacpp" -import { fetchMlxServeRequests, mlxServeTurn, formatMlxServeLine, combineMlxServeSteps, type MlxServeRequest } from "./adapters/mlxserve" +import { fetchMlxServeRequests, mlxServeTurn, mlxServeView, combineMlxServeSteps, type MlxServeRequest } from "./adapters/mlxserve" import { fetchSplashSample, diffSplashSamples, - formatSplashLine, + splashView, splashIsThisTurn, type SplashSample, } from "./adapters/splash" -import { fetchKoboldPerf, koboldTurn, formatKoboldLine, combineKoboldSteps, type KoboldPerf } from "./adapters/koboldcpp" +import { fetchKoboldPerf, koboldTurn, koboldView, combineKoboldSteps, type KoboldPerf } from "./adapters/koboldcpp" import { fetchPromSample, diffPromSamples, - formatPromLine, + promView, VLLM_SPEC, SGLANG_SPEC, VLLM_MLX_SPEC, @@ -123,7 +124,7 @@ function readConfig(options: Readonly>): Config { mlxServeKey: str(options["mlxServeApiKey"], "MLX_API_KEY", ""), display: { context: bool(options["showContext"], DEFAULT_DISPLAY.context), - sessionBackground: bool(options["sessionBackground"], DEFAULT_DISPLAY.sessionBackground), + background: bool(options["background"], DEFAULT_DISPLAY.background), }, } } @@ -212,6 +213,45 @@ export default Plugin.define({ }).catch((e: unknown) => dbg(`ui write failed: ${String(e)}`)) } + // One box. Theme colours throughout, so it follows the user's theme: + // bold heading, subdued labels and notes, default-coloured values. + const drawBox = (view: TurnView, suffix: string, open: boolean, toggle: () => void, first: boolean) => { + const subdued = ctx.theme.text.subdued + return ( + + + {`${open ? "▾" : "▸"} ${view.engine}${suffix}`} + {!open && view.key ? {` ${view.key}`} : null} + + {open && (view.rows.length > 0 || view.notes.length > 0) ? ( + + {view.rows.map(([label, value]) => ( + + {label.padEnd(LABEL_WIDTH)} + {value} + + ))} + {view.notes.map((note) => ( + + {note} + + ))} + + ) : null} + + ) + } + const show = (text: string, sessionID: string, key: string): void => { setPanel((d) => { const next = setLine(d, sessionID, text, key) @@ -296,7 +336,7 @@ export default Plugin.define({ * check expects the turn's tokens and steps plus theirs. */ sameEngine?: TurnRecord["subagents"] - ): Promise { + ): Promise { // 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 @@ -321,7 +361,7 @@ export default Plugin.define({ spec: PromSpec, url: string, label: string - ): Promise => { + ): Promise => { const now = await fetchPromSample(url, spec, http) if (!now) return null const prev = base.prom[id] @@ -346,7 +386,7 @@ export default Plugin.define({ // by the adapter, so adapters stay leaves. // The fallback rate is this turn's own generation: OpenCode's count // over its streaming, never the window's, which can include sub-agents. - const line = formatPromLine(diff, label, model, { + const line = promView(diff, label, model, { ...turnRate(hostTok, info, turn), tokens: windowTokens, steps: windowSteps, @@ -402,7 +442,7 @@ export default Plugin.define({ prefillTokS: combined.prefill_tok_s ?? undefined, mtpX: verifies > 0 && combined.completion_tokens ? combined.completion_tokens / verifies : undefined, } - return formatMtplxLine(combined, model, hostFigures) + return mtplxView(combined, hostFigures) } case "omlx": { @@ -422,7 +462,7 @@ export default Plugin.define({ return null } } - return formatOmlxLine(now, prev, hostTtft, { + return omlxView(now, prev, hostTtft, { ...hostFigures, decodeTokS: turnRate(hostTokens, info, turn).decodeTokS, includesSubagents, @@ -454,7 +494,7 @@ export default Plugin.define({ return null } tier2.engine = { prefillTokS: t.prefillTokS } - return formatLlamaCppLine(t, label, model, hostTtft, { ...hostFigures, includesSubagents }) + return llamaCppView(t, label, hostTtft, { ...hostFigures, includesSubagents }) } case "splash": { @@ -476,7 +516,7 @@ export default Plugin.define({ return null } tier2.engine = { prefillTokS: t.prefillTokS, draftAccept: t.draftAcceptRate } - return formatSplashLine(t, model, hostTtft, { ...hostFigures, steps: windowSteps, includesSubagents }) + return splashView(t, hostTtft, { ...hostFigures, steps: windowSteps, includesSubagents }) } case "koboldcpp": @@ -499,7 +539,7 @@ export default Plugin.define({ return null } tier2.engine = { prefillTokS: combined.prefillTokS, draftAccept: combined.draftAcceptRate } - return formatKoboldLine(combined, model, hostTtft, hostFigures) + return koboldView(combined, hostTtft, hostFigures) } // No per-step reads: one read now, which can only describe the // last request -- labelled as such when several landed. @@ -511,7 +551,7 @@ export default Plugin.define({ }) const t = koboldTurn(perf, prev) if (t) match(t.completionTokens, `; generations ${t.generationsInWindow ?? "?"}`) - return t ? formatKoboldLine(t, model, hostTtft) : null + return t ? koboldView(t, hostTtft, hostFigures) : null } case "mlxserve": @@ -531,7 +571,7 @@ export default Plugin.define({ setBase((d) => { d.mlxServeId[cfg.mlxServeBase] = combined.requestId }) - return formatMlxServeLine(combined, model, hostFigures) + return mlxServeView(combined, hostFigures) } const recs = await fetchMlxServeRequests( cfg.mlxServeBase, @@ -546,7 +586,7 @@ export default Plugin.define({ setBase((d) => { d.mlxServeId[cfg.mlxServeBase] = t.requestId }) - return formatMlxServeLine(t, model) + return mlxServeView(t, hostFigures) } case "vllm": @@ -634,7 +674,7 @@ export default Plugin.define({ // A model or provider switch replaces this session's line rather than // blending two engines' figures into one reading. Per session, so a // different model in another tab is not a switch here. - show(`${provider} ${short(model)}\n…`, sessionID, key) + show(encodeView({ engine: provider, rows: [], notes: ["…"] }), sessionID, key) } // One signal for every fetch this turn. Each request still gets its own @@ -676,7 +716,7 @@ export default Plugin.define({ pendingBaseline: false, sharedWindow: false, } - let line: string | null = null + let line: TurnView | null = null try { line = await enrich(provider, model, info, turn, http, tier2, steps, sameEngine) // Recorded before the fallback overwrites it, so history knows which @@ -691,9 +731,8 @@ export default Plugin.define({ } const enriched = line !== null if (!line) { - line = universalLine( + line = universalView( provider, - model, info, turn, cfg.display, @@ -702,12 +741,12 @@ export default Plugin.define({ // Say why this turn looks different from the next one. The figures // 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. - if (tier2.pendingBaseline) line += "\nengine telemetry from the next turn" - else if (tier2.sharedWindow) line += "\nengine data skipped: overlapping requests" + // magnitude when it does. Split to fit the box's 34 cells. + if (tier2.pendingBaseline) line.notes.push("engine telemetry", "from the next turn") + else if (tier2.sharedWindow) line.notes.push("engine data skipped:", "overlapping requests") } - if (subagents) line += `\n${formatSubagentLine(subagents)}` + if (subagents) line.rows.push(...subagentRows(subagents)) // Keep the turn for the drill-down. Every figure below is OpenCode's own, // whatever tier drew the sidebar line; `source` records only which tier @@ -760,7 +799,7 @@ export default Plugin.define({ dbg(`report ${seq} for ${sessionID} superseded, not rendering`) return } - show(line, sessionID, key) + show(encodeView(line), sessionID, key) } // ---- subscriptions ------------------------------------------------------ @@ -1027,46 +1066,22 @@ export default Plugin.define({ // highlight) instead of just toggling. This is a footer we // render, not a passage a user would want to copy, so turning // selection off is the right default rather than a workaround. - if (ui.collapsed) { - return ( - toggleCollapsed()}> - {formatCollapsedLine(latestFor(history.turns, input.sessionID), input.sessionID)} - - ) - } - // The per-turn block keeps OpenCode's own sidebar style: its first - // line (engine and model) bold as a title, the figures below. - const [title, ...figures] = lineFor(panel, input.sessionID).split("\n") - // The Session section: below the per-turn block, collapsed by - // default, set apart by a blank line, a bold clickable heading - // and subdued label/value rows -- so the two never read as one - // list. Absent until the session has a recorded turn. + // Two independent boxes, each opened and closed by its heading: + // the last turn, and the session. Laid out as labelled rows, one + // figure per line, inside a 1-cell margin and 1-cell / 1-row + // padding on the theme's offset shade -- so they read as this + // plugin's own blocks, not as more lines of OpenCode's sidebar. + // The heading names the engine only: the model is already shown + // under the prompt box. + const stored = lineFor(panel, input.sessionID) + const turnView: TurnView = + stored === PLACEHOLDER ? { engine: "last turn", rows: [], notes: ["no turn yet"] } : decodeView(stored) + const suffix = stored === PLACEHOLDER ? "" : " · last turn" const summary = summariseSession(history.turns, input.sessionID) - const open = ui.sessionOpen === true return ( - toggleCollapsed()}> - {title} - {figures.length > 0 ? `\n${figures.join("\n")}` : ""} - - {summary ? ( - - toggleSession()}> - {sessionHeading(summary, open)} - - {open ? ( - - {sessionRows(summary) - .map(([label, value]) => ` ${label.padEnd(11)}${value}`) - .join("\n")} - - ) : null} - - ) : null} + {drawBox(turnView, suffix, !ui.collapsed, toggleCollapsed, true)} + {summary ? drawBox(sessionView(summary), "", ui.sessionOpen === true, toggleSession, false) : null} ) }, diff --git a/universal.ts b/universal.ts index 485afea..3e0a13d 100644 --- a/universal.ts +++ b/universal.ts @@ -8,7 +8,8 @@ // that also covered the model's thinking, understating reasoning-model rates // several-fold. See test/universal.test.mjs. -import { nn, ni, short, tokensLabel, money } from "./format" +import { nn, ni, money } from "./format" +import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "./rows" // ---- Tier 1: universal, from OpenCode's own per-turn events ----------------- import type { SessionMessageAssistant } from "@opencode-ai/client" @@ -243,24 +244,23 @@ export function aggregateTurn( export interface Display { context: boolean /** - * Put the theme's offset panel shade behind the Session section. Off by - * default: on themes and terminals with a transparent background the shade - * can vanish, and the section already reads as separate by its heading, - * spacing and subdued rows. Judged live, in the user's own theme. + * The theme's offset panel shade behind each box (last turn, Session). + * On by default: it is what sets the boxes apart from OpenCode's own + * sidebar sections. Off for a theme or terminal with a transparent + * background, where the shade can vanish. */ - sessionBackground: boolean + background: boolean } -export const DEFAULT_DISPLAY: Display = { context: false, sessionBackground: false } +export const DEFAULT_DISPLAY: Display = { context: false, background: true } -export function universalLine( +export function universalView( provider: string, - model: string, info: SessionMessageAssistant | undefined, turn?: Turn, display: Display = DEFAULT_DISPLAY, contextLimit?: number -): string { +): TurnView { const out: number = info?.tokens?.output ?? 0 const reason: number = info?.tokens?.reasoning ?? 0 // Reasoning tokens are decoded tokens: they are produced one at a time @@ -271,55 +271,48 @@ export function universalLine( // where the engine's own log said 39.7 over the same 31.4s window. const generated = out + reason const { decodeTokS, ttft, total, rateWindow } = turnRate(generated, info, turn) - - // A decode rate is left unqualified: the ttft beside it is what explains - // why the whole turn was slower, without asserting where that time went - // (ttft is queue + network + prefill + first-token compute, and only some - // engines can tell those apart). - // - // The FALLBACK rate is qualified, because it is a different measurement — - // tokens over the whole turn, not over the stream window. On a turn with a - // long wait those differ ~10x, so letting it pass as a decode rate would be - // wrong rather than merely terse. + // A rate recorded as whole-turn can only come from an older history row; + // it stays labelled so it is never read as generation speed. const overall = rateWindow === "whole" ? " overall" : "" - const ttftLabel = ttft !== undefined ? ` ttft ${nn(ttft, 2)}s` : "" - const rate = - decodeTokS !== undefined - ? `${nn(decodeTokS)} tok/s${overall}${ttftLabel}` - : ttftLabel.trim() - // OpenCode's output count excludes reasoning, so the topline adds them back. - // Retries are part of the total the user waited, so they are named beside - // it; without that, a 60s total over 15s of model work reads as a slow model. - const r = turn?.retries ?? 0 - const retries = r > 0 ? ` (${r} ${r === 1 ? "retry" : "retries"})` : "" - const totals = `${tokensLabel(generated, reason)}${total !== undefined ? ` ${nn(total, 2)}s${retries}` : ""}` - // Cost and cache reuse, both from the host rather than any engine — so - // every provider gets them, including cloud models where Tier 2 never - // fires. This is the one place a cloud user sees a cache signal at all. - // - // `info.cost` is THIS turn's cost. `ctx.data.session.cost()` is a running - // session total and would grow every turn while appearing to describe one - // (measured: they differ by exactly the previous turn's cost). Per-turn is - // what every other figure on this panel means, so per-turn is what is used. - // - // Both are omitted entirely when absent or zero: a free model showing - // "$0.00" and a cold prompt showing "0 cached" are the same absent-is-not- - // zero mistake the counters already avoid. + // Cost and cache reuse come from the host, so every provider gets them, + // including cloud models where no engine is read. `info.cost` is THIS + // turn's cost, never the running session total. Both are omitted when + // absent or zero: "$0.00" and "0 cached" would be absent-as-zero. const cost = money(info?.cost) const cacheRead = info?.tokens?.cache?.read ?? 0 - const cacheLabel = cacheRead > 0 ? `${ni(cacheRead)} cached` : "" - const extras = [cost, cacheLabel].filter(Boolean).join(" ") - // Opt-in only (see Display.context). `prompt/limit`, never `context used` - // or a bare percentage — the wording itself is the caveat that this may - // not agree with the host's own figure, which uses data and a formula - // this plugin cannot see. + // Opt-in only (see Display.context). `prompt/limit`, never `context used`: + // the wording is the caveat that this may not agree with the host's own + // figure, which uses data and a formula this plugin cannot see. const context = display.context && contextLimit !== undefined && contextLimit > 0 - ? `${ni((info?.tokens?.input ?? 0) / contextLimit * 100)}% prompt/limit` + ? `${ni(((info?.tokens?.input ?? 0) / contextLimit) * 100)}% prompt/limit` : "" - return [`${provider} ${short(model)}`, rate, totals, extras, context].filter(Boolean).join("\n") + const rows: Row[] = [ + ...rowsOf("speed", [decodeTokS !== undefined ? `${nn(decodeTokS)} tok/s${overall}` : ""]), + ...rowsOf("ttft", [ttft !== undefined ? `${nn(ttft, 2)}s` : ""]), + // OpenCode's output count excludes reasoning, so tokens adds it back; + // thinking is shown as the subset it is, never as an addition. + ...rowsOf("tokens", [nt(generated), reason > 0 ? `${nt(reason)} thinking` : ""]), + ...timeRows(total, turn?.retries ?? 0), + ...rowsOf("cost", [cost]), + ...rowsOf("cached", [cacheRead > 0 ? nt(cacheRead) : ""]), + ...rowsOf("context", [context]), + ] + return { engine: provider, rows, notes: [], key: decodeTokS !== undefined ? `${nn(decodeTokS)} tok/s${overall}` : undefined } +} + +/** The view as text; kept for tests that look for a figure. */ +export function universalLine( + provider: string, + _model: string, + info: SessionMessageAssistant | undefined, + turn?: Turn, + display: Display = DEFAULT_DISPLAY, + contextLimit?: number +): string { + return viewText(universalView(provider, info, turn, display, contextLimit)) } From c30909bb667dffb09b7f368824bd2276e5d22bb2 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 19:14:33 -0700 Subject: [PATCH 10/17] Resolve theme colours defensively; the runtime theme differs from its types --- tui.tsx | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/tui.tsx b/tui.tsx index ea37513..389924c 100644 --- a/tui.tsx +++ b/tui.tsx @@ -213,10 +213,47 @@ export default Plugin.define({ }).catch((e: unknown) => dbg(`ui write failed: ${String(e)}`)) } + // Theme colours, looked up defensively. The runtime theme's shape does + // not match the installed types: on OpenCode 2.0.12 `ctx.theme.background` + // was undefined and reading `.surface.offset` crashed the slot. Other + // plugins use at least three shapes (text.subdued; text.muted/text.base; + // textMuted; background.raised.base), so each known path is tried and a + // missing colour means "no colour", never a throw. + const themeColor = (...paths: string[]): unknown => { + for (const path of paths) { + let v: unknown = ctx.theme + for (const k of path.split(".")) v = v && typeof v === "object" ? (v as Record)[k] : undefined + if (v !== undefined && v !== null && typeof v !== "function") return v + } + return undefined + } + type Color = Parameters[0] + const subduedColor = (): Color | undefined => + themeColor("text.subdued", "text.muted", "textMuted", "text.subtle") as Color | undefined + const panelColor = (): Color | undefined => + themeColor("background.surface.offset", "background.raised.base", "backgroundPanel", "backgroundElement") as + | Color + | undefined + if (HUD_DEBUG) { + try { + const shape = (o: unknown, depth: number): string => + o && typeof o === "object" && depth > 0 + ? `{${Object.keys(o as object) + .slice(0, 24) + .map((k) => `${k}:${shape((o as Record)[k], depth - 1)}`) + .join(",")}}` + : typeof o + dbg(`theme shape: ${shape(ctx.theme, 3)}`) + dbg(`theme picks: subdued ${subduedColor() !== undefined}, panel ${panelColor() !== undefined}`) + } catch (e: unknown) { + dbg(`theme inspection threw: ${String(e)}`) + } + } + // One box. Theme colours throughout, so it follows the user's theme: // bold heading, subdued labels and notes, default-coloured values. const drawBox = (view: TurnView, suffix: string, open: boolean, toggle: () => void, first: boolean) => { - const subdued = ctx.theme.text.subdued + const subdued = subduedColor() return ( {`${open ? "▾" : "▸"} ${view.engine}${suffix}`} From 0612d88b248cddfe4aa3bcfca37c594d7609d351 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 19:18:37 -0700 Subject: [PATCH 11/17] Shade boxes with the theme's raised.high, one step above the sidebar --- tui.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tui.tsx b/tui.tsx index 389924c..73a452a 100644 --- a/tui.tsx +++ b/tui.tsx @@ -228,10 +228,15 @@ export default Plugin.define({ return undefined } type Color = Parameters[0] + // Measured on OpenCode 2.0.12: text is {base, muted, ...}; `muted` is + // the grey. `subdued` is what the installed types declare. const subduedColor = (): Color | undefined => - themeColor("text.subdued", "text.muted", "textMuted", "text.subtle") as Color | undefined + themeColor("text.muted", "text.subdued", "textMuted") as Color | undefined const panelColor = (): Color | undefined => - themeColor("background.surface.offset", "background.raised.base", "backgroundPanel", "backgroundElement") as + // Measured on OpenCode 2.0.12: background is {base, raised:{base, high, + // max}}. raised.base is the sidebar's own colour (the boxes rendered + // unshaded on it), so the box takes the next step up. + themeColor("background.raised.high", "background.surface.offset", "backgroundElement", "backgroundPanel") as | Color | undefined if (HUD_DEBUG) { From a5ca89f2c5f298ff3152e9ef4eb868bc46d73635 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 19:22:13 -0700 Subject: [PATCH 12/17] Pad boxes 2 columns at the sides so padding reads even --- rows.ts | 2 +- test/rows.test.mjs | 6 +++--- test/session.test.mjs | 4 ++-- tui.tsx | 7 +++++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/rows.ts b/rows.ts index b3bc812..8654c03 100644 --- a/rows.ts +++ b/rows.ts @@ -4,7 +4,7 @@ // a TurnView; the entry file draws it into a box. A value with parts // continues on the next row under an empty label, so every row fits the // sidebar's 38 columns: a 12-cell label column inside a box with a 1-cell -// margin and 1-cell padding leaves 22 cells for the value. +// margin and 2-cell side padding leaves 20 cells for the value. /** A label and its value. An empty label continues the row above. */ export type Row = readonly [label: string, value: string] diff --git a/test/rows.test.mjs b/test/rows.test.mjs index 1cb23c0..508428c 100644 --- a/test/rows.test.mjs +++ b/test/rows.test.mjs @@ -24,12 +24,12 @@ test("empty parts leave no rows", () => { assert.deepEqual(rowsOf("time", []), []) }) -test("rows fit the sidebar: 12-cell labels, and a 34-cell box interior", () => { - // 38 columns, less a 1-cell margin and 1-cell padding each side. +test("rows fit the sidebar: 12-cell labels, and a 32-cell box interior", () => { + // 38 columns, less a 1-cell margin and 2-cell padding each side. const lines = rowLines([["sub-agents", "1 · 191 tok"], ["", "23.91s"]]) assert.equal(lines[0], "sub-agents 1 · 191 tok") assert.equal(lines[1], " ".repeat(LABEL_WIDTH) + "23.91s") - lines.forEach((l) => assert.ok(l.length <= 34, l)) + lines.forEach((l) => assert.ok(l.length <= 32, l)) }) test("numbers carry thousands separators", () => { diff --git a/test/session.test.mjs b/test/session.test.mjs index 8682d9c..b74cef0 100644 --- a/test/session.test.mjs +++ b/test/session.test.mjs @@ -141,13 +141,13 @@ test("one turn is singular", () => { assert.equal(sessionView(summariseSession([row()], SID)).engine, "Session · 1 turn") }) -test("every row fits the box's 34 cells", () => { +test("every row fits the box's 32 cells", () => { const s = summariseSession( [row({ ttft: 0.5, retries: 2, engine: { mtpX: 3.4, prefillTokS: 449, draftAccept: 0.7 }, subagents: { count: 2, tokens: 12345, spanS: 30, cost: 0.012 } }), row({ ttft: 17.6 })], SID ) - rowsOfView(s).forEach(([l, v]) => assert.ok(12 + v.length <= 34, `${l}: ${v}`)) + rowsOfView(s).forEach(([l, v]) => assert.ok(12 + v.length <= 32, `${l}: ${v}`)) }) test("rows are label/value pairs, and an absent figure leaves no row", () => { diff --git a/tui.tsx b/tui.tsx index 73a452a..e592a0b 100644 --- a/tui.tsx +++ b/tui.tsx @@ -265,8 +265,11 @@ export default Plugin.define({ marginLeft={1} marginRight={1} marginTop={first ? 0 : 1} - paddingLeft={1} - paddingRight={1} + // 2 columns at the sides against 1 row top and bottom: a terminal + // cell is about twice as tall as it is wide, so this reads as even + // padding all round (chosen from the mockup, option A). + paddingLeft={2} + paddingRight={2} paddingTop={open ? 1 : 0} paddingBottom={open ? 1 : 0} backgroundColor={cfg.display.background ? panelColor() : undefined} From 7c21eca74eadb4abefb8c921f1bd9795a4e1e87b Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 19:23:52 -0700 Subject: [PATCH 13/17] Correct two comments to the 32-cell box interior --- test/session.test.mjs | 2 +- tui.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/session.test.mjs b/test/session.test.mjs index b74cef0..512ec2a 100644 --- a/test/session.test.mjs +++ b/test/session.test.mjs @@ -115,7 +115,7 @@ test("retries are summed", () => { }) // Row values in the mockup: one figure per line, a value with parts -// continuing under an empty label, every row within the box's 34 cells. +// continuing under an empty label, every row within the box's 32 cells. const rowsOfView = (s) => sessionView(s).rows const labelled = (s) => Object.fromEntries(rowsOfView(s).filter(([l]) => l)) const continuation = (s, label) => { diff --git a/tui.tsx b/tui.tsx index e592a0b..d32163b 100644 --- a/tui.tsx +++ b/tui.tsx @@ -786,7 +786,7 @@ export default Plugin.define({ // Say why this turn looks different from the next one. The figures // 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 34 cells. + // magnitude when it does. Split to fit the box's 32 cells. if (tier2.pendingBaseline) line.notes.push("engine telemetry", "from the next turn") else if (tier2.sharedWindow) line.notes.push("engine data skipped:", "overlapping requests") } From d36f12456de84a76c922b2b4a62e1eeb2a984c8d Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 19:24:04 -0700 Subject: [PATCH 14/17] Keep a padding row above and below a collapsed box --- tui.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tui.tsx b/tui.tsx index d32163b..6ad8a86 100644 --- a/tui.tsx +++ b/tui.tsx @@ -270,8 +270,8 @@ export default Plugin.define({ // padding all round (chosen from the mockup, option A). paddingLeft={2} paddingRight={2} - paddingTop={open ? 1 : 0} - paddingBottom={open ? 1 : 0} + paddingTop={1} + paddingBottom={1} backgroundColor={cfg.display.background ? panelColor() : undefined} > From ab3f7d6f1d7e9fc6786f440d0686da940abd9a24 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 19:29:40 -0700 Subject: [PATCH 15/17] Cut history rows at the panel edge; pool the headline rate per model --- CHANGELOG.md | 6 +++++ history.ts | 53 ++++++++++++++++++++++++++++++------------- session.ts | 11 +-------- test/history.test.mjs | 31 +++++++++++++++---------- tui.tsx | 15 ++++++++++-- 5 files changed, 76 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f99c0c..4e5d213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,12 @@ declined with `engine data skipped: overlapping requests`. Measured on MTPLX: 194 tokens (the step's own) at the end of streaming, 163 (the sub-agent's) at "ended". +- The history panel no longer wraps a long row onto a second line. Each row + is cut at the panel's edge, the least important figures last, and + widening the panel brings them back. +- The history panel's headline rate is generation tok/s on the newest + turn's model (tokens over streaming time, as in the Session box), instead + of a mean of per-turn rates across every model. ## [0.2.4] – 2026-09-23 ### Changed diff --git a/history.ts b/history.ts index 0109ebc..46d9f72 100644 --- a/history.ts +++ b/history.ts @@ -108,20 +108,35 @@ export interface Summary { tokens: number /** Total cost, or undefined when no turn in the window had one at all. */ cost?: number - /** Mean of the per-turn decode rates — whole-turn rates are excluded, - * because averaging the two together would compare different windows. */ - meanDecodeTokS?: number + /** + * Generation tok/s on the newest turn's model: its turns' tokens over + * their streaming time, the Session box's rule. Never a mean of per-turn + * rates, never a whole-turn rate, and never across two models. + */ + genTokS?: number + /** The model `genTokS` is for. */ + genModel?: string /** How many rows came from an engine rather than from the host. */ engineRows: number } +/** A turn's streaming time: recorded, or derived from an older row's rate. */ +export function streamOf(t: TurnRecord): number | undefined { + if (t.streamS !== undefined && t.streamS > 0) return t.streamS + // Rows recorded before streamS existed carry a generation rate whose + // window is tokens / rate. A whole-turn rate is not generation, so no. + if (t.rate !== undefined && t.rate > 0 && t.rateWindow !== "whole") return t.tokens / t.rate + return undefined +} + export function summarise(turns: readonly TurnRecord[]): Summary { let tokens = 0 let cost = 0 let sawCost = false - let rateSum = 0 - let rateCount = 0 + let genTokens = 0 + let streamS = 0 let engineRows = 0 + const model = turns[0]?.model for (const t of turns) { tokens += t.tokens @@ -129,11 +144,10 @@ export function summarise(turns: readonly TurnRecord[]): Summary { cost += t.cost sawCost = true } - // Only decode rates. Mixing in a whole-turn rate would average two - // different measurements into one meaningless number. - if (t.rate !== undefined && t.rateWindow !== "whole") { - rateSum += t.rate - rateCount++ + const s = streamOf(t) + if (t.model === model && s !== undefined) { + genTokens += t.tokens + streamS += s } if (t.source === "engine") engineRows++ } @@ -142,7 +156,8 @@ export function summarise(turns: readonly TurnRecord[]): Summary { turns: turns.length, tokens, cost: sawCost ? cost : undefined, - meanDecodeTokS: rateCount > 0 ? rateSum / rateCount : undefined, + genTokS: streamS > 0 ? genTokens / streamS : undefined, + genModel: streamS > 0 ? model : undefined, engineRows, } } @@ -209,7 +224,9 @@ export function formatCollapsedLine( } /** - * The whole panel body: a summary, then the rows. + * The whole panel body, one entry per line: a summary, then the rows. Each + * line is drawn unwrapped and cut at the panel's edge, so a row's figures run + * most important first -- what a narrow panel loses is the tail. * * `*` marks a row whose sidebar line came from the serving engine's own * metrics. The row itself is OpenCode's figures either way, which the legend @@ -217,15 +234,15 @@ export function formatCollapsedLine( * The legend is only printed when the distinction actually appears in the * window, because a legend for something absent is noise. */ -export function formatHistory(turns: readonly TurnRecord[], modelWidth = 18): string { +export function historyLines(turns: readonly TurnRecord[], modelWidth = 18): string[] { if (turns.length === 0) { - return "No turns recorded yet." + return ["No turns recorded yet."] } const s = summarise(turns) const head = [ `${ni(s.turns)} turns`, `${ni(s.tokens)} tok`, - s.meanDecodeTokS !== undefined ? `${nn(s.meanDecodeTokS)} tok/s mean` : "", + s.genTokS !== undefined ? `${nn(s.genTokS)} tok/s avg ${short(s.genModel ?? "", modelWidth)}` : "", money(s.cost), ] .filter(Boolean) @@ -234,5 +251,9 @@ export function formatHistory(turns: readonly TurnRecord[], modelWidth = 18): st const rows = turns.map((t) => formatRow(t, modelWidth)) const mixed = s.engineRows > 0 && s.engineRows < turns.length const legend = mixed ? ["", "* sidebar used engine telemetry; rows are OpenCode's figures"] : [] - return [head, "", ...rows, ...legend].join("\n") + return [head, "", ...rows, ...legend] +} + +export function formatHistory(turns: readonly TurnRecord[], modelWidth = 18): string { + return historyLines(turns, modelWidth).join("\n") } diff --git a/session.ts b/session.ts index 3ebde04..c37fbca 100644 --- a/session.ts +++ b/session.ts @@ -15,7 +15,7 @@ // and $ spent, so those are deliberately not repeated here. import { nn, ni, short, money } from "./format" -import type { TurnRecord } from "./history" +import { streamOf, type TurnRecord } from "./history" import { rowsOf, nt, type Row, type TurnView } from "./rows" /** Recent turns shown in the generation trend. */ @@ -99,15 +99,6 @@ export function subagentRows(r: SubagentRollup): Row[] { ]) } -/** A turn's streaming time: recorded, or derived from an older row's rate. */ -function streamOf(t: TurnRecord): number | undefined { - if (t.streamS !== undefined && t.streamS > 0) return t.streamS - // Rows recorded before streamS existed carry a generation rate whose - // window is tokens / rate. A whole-turn rate is not generation, so no. - if (t.rate !== undefined && t.rate > 0 && t.rateWindow !== "whole") return t.tokens / t.rate - return undefined -} - const mean = (xs: number[]): number | undefined => xs.length > 0 ? xs.reduce((a, b) => a + b, 0) / xs.length : undefined diff --git a/test/history.test.mjs b/test/history.test.mjs index cbf7ab0..b61a441 100644 --- a/test/history.test.mjs +++ b/test/history.test.mjs @@ -54,21 +54,28 @@ test("the default cap is a real number, not undefined", () => { }) // ---- summarising: the part that must not lie -------------------------------- -test("a whole-turn rate is excluded from the mean decode rate", () => { - // This is the whole reason rateWindow is carried per row. Averaging 38.1 - // (over a 0.97s decode window) with 3.7 (over the same turn's 10.03s - // total) produces a number that describes neither. - const s = summarise([ - turn({ rate: 38.1, rateWindow: "decode" }), - turn({ rate: 3.7, rateWindow: "whole" }), - turn({ rate: 38.7, rateWindow: "decode" }), - ]) - assert.ok(Math.abs(s.meanDecodeTokS - 38.4) < 0.05, String(s.meanDecodeTokS)) +test("the summary rate is tokens over streaming time, not a mean of rates", () => { + // 100 tok over 2s and 300 tok over 3s: 400 / 5 = 80, not the mean of 50 and 100. + const s = summarise([turn({ tokens: 300, streamS: 3 }), turn({ tokens: 100, streamS: 2 })]) + assert.equal(s.genTokS, 80) +}) + +test("a whole-turn rate never counts as generation", () => { + // Averaging 38.1 over a decode window with 3.7 over the whole turn + // produces a number that describes neither. + const s = summarise([turn({ rate: 38.1, tokens: 381 }), turn({ rate: 3.7, rateWindow: "whole", tokens: 1000 })]) + assert.ok(Math.abs(s.genTokS - 38.1) < 1e-9, String(s.genTokS)) +}) + +test("the summary rate is the newest turn's model only, and names it", () => { + const s = summarise([turn({ model: "a", tokens: 100, streamS: 1 }), turn({ model: "b", tokens: 10, streamS: 1 })]) + assert.equal(s.genTokS, 100) + assert.equal(s.genModel, "a") }) -test("no decode rates at all means no mean, not zero", () => { +test("no generation window at all means no rate, not zero", () => { const s = summarise([turn({ rate: 3.7, rateWindow: "whole" }), turn({ rate: undefined })]) - assert.equal(s.meanDecodeTokS, undefined) + assert.equal(s.genTokS, undefined) }) test("cost sums across turns, and is absent when nothing cost anything", () => { diff --git a/tui.tsx b/tui.tsx index 6ad8a86..67f4f71 100644 --- a/tui.tsx +++ b/tui.tsx @@ -30,7 +30,7 @@ import { appendFileSync } from "node:fs" import { short } from "./format" import type { HttpOptions } from "./http" import { universalView, turnRate, turnSteps, aggregateTurn, type Turn, type Display, DEFAULT_DISPLAY } from "./universal" -import { record, formatHistory, type History, type TurnRecord } from "./history" +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" @@ -1144,7 +1144,18 @@ export default Plugin.define({ ctx.ui.slot({ append: "session.panel", render: (input) => - input.name === PANEL_NAME ? {formatHistory(history.turns)} : null, + // A line per row, unwrapped and cut at the panel's edge rather + // than wrapped onto a line of its own; widening the panel brings + // the cut figures back. No width is guessed. + input.name === PANEL_NAME ? ( + + {historyLines(history.turns).map((l) => ( + + {l || " "} + + ))} + + ) : null, }) ) } catch (e: unknown) { From 03f7f2d04757c634e59ca0db0f8bc2d8df571408 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 19:33:33 -0700 Subject: [PATCH 16/17] Revert history rows to one wrapped text; truncation garbled them --- CHANGELOG.md | 3 --- tui.tsx | 17 +++++------------ 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e5d213..e69a629 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,9 +40,6 @@ declined with `engine data skipped: overlapping requests`. Measured on MTPLX: 194 tokens (the step's own) at the end of streaming, 163 (the sub-agent's) at "ended". -- The history panel no longer wraps a long row onto a second line. Each row - is cut at the panel's edge, the least important figures last, and - widening the panel brings them back. - The history panel's headline rate is generation tok/s on the newest turn's model (tokens over streaming time, as in the Session box), instead of a mean of per-turn rates across every model. diff --git a/tui.tsx b/tui.tsx index 67f4f71..b8322c6 100644 --- a/tui.tsx +++ b/tui.tsx @@ -1144,18 +1144,11 @@ export default Plugin.define({ ctx.ui.slot({ append: "session.panel", render: (input) => - // A line per row, unwrapped and cut at the panel's edge rather - // than wrapped onto a line of its own; widening the panel brings - // the cut figures back. No width is guessed. - input.name === PANEL_NAME ? ( - - {historyLines(history.turns).map((l) => ( - - {l || " "} - - ))} - - ) : null, + // One text, wrapped by the host. A line per row with + // wrapMode="none" and truncate was tried (OpenCode 2.0.12): it + // cut rows in the middle with "..." and left stale cells from + // earlier frames on resize, so rows read as garbage. + input.name === PANEL_NAME ? {historyLines(history.turns).join("\n")} : null, }) ) } catch (e: unknown) { From 02451c67d8d85c5fe527307619044507d4e1c307 Mon Sep 17 00:00:00 2001 From: charlesnutter Date: Wed, 23 Sep 2026 19:36:29 -0700 Subject: [PATCH 17/17] Bring the changelog's examples up to the box layout --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69a629..3c7c84b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### Added - A **Session** section below the per-turn figures, collapsed by default: click its heading to open it. Collapsed, it still shows the session's - generation speed (`▸ Session · 14 turns · 48.2 tok/s avg`). Open, it + generation speed (`▸ Session · 14 turns 48.2 tok/s`). Open, it shows generation tok/s with a trend of recent turns, TTFT median and worst, cache hit rate, how the time split between generating, waiting for the first token and everything else, the engine's own averages @@ -11,8 +11,8 @@ when the model changed partway through. The session's tokens, context and cost are left to OpenCode's own sidebar. - Sub-agent roll-ups. A turn that started sub-agents adds a line to the - per-turn figures, `+2 sub-agents 4210 tok 38.10s $0.012`: their tokens - and cost summed, and the time from the first starting to the last + per-turn figures (`sub-agents 2 · 4,210 tok`, then `38.10s` and + `$0.012` on the rows below): their tokens and cost summed, and the time from the first starting to the last finishing. Rates are never combined across them. The Session section totals them in a `sub-agents` row, and its time split gives the real time sub-agents were running its own share instead of folding it into `other`. @@ -22,8 +22,8 @@ turn's requests and tokens plus the sub-agents'. Those figures then cover both, and the line says `incl. sub-agents`. Rate and TTFT stay the turn's own. Built and tested against captures; not yet run live. -- `background` option (default on): the theme's offset shade behind each - box. +- `background` option (default on): shades each box one step above the + sidebar's own background. ### Changed - The sidebar is two boxes, the last turn and the session, each opened and