Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,34 @@
## [Unreleased]
### Added
- A **details dialog**, opened by `details ›` under the sidebar boxes,
`ctrl+shift+d` or `/headsup`, and closed the same way or with `esc`. Last
turn on the left, session on the right; one at a time below 110 columns,
switched with `tab`. The sidebar is unchanged.
- Last turn: where the time went, in seconds and shares that add up to the
total; every step with its tool calls and their times; tokens in all
five kinds and context used; the engine's own figures only, marked `◆`,
including MTPLX acceptance at every depth and per-step engine rates;
retry reasons in full.
- Session: speed and time-to-first-token spread (min, median, p90, max),
time split in seconds, tools by time, retries by reason, and coverage:
how many turns had engine figures and why the others did not.
- Compaction is named. OpenCode compacting the conversation mid-turn gets
its own share of the turn's time, and the step it delayed says so. On an
engine that publishes cumulative counters (Splash, llama.cpp, llamafile,
the vLLM family), the compaction's own request is read before and after
and taken out of the turn, so the turn keeps its engine figures; where it
can't be, the reason reads `compaction ran this turn`.

### Fixed
- A sub-agent on the same counter engine as its parent no longer makes the
parent's turn lose its engine figures. The sub-agent's own report moved
the engine-wide starting reading, so the parent's window held only its
last step and was declined as `overlapping requests`. Each turn now takes
its own starting reading when it starts.
- When engine figures were declined, the dialog says the engine's figures
were left out and why, rather than reading as though the engine reported
nothing.

## [0.3.3] – 2026-09-25
### Fixed
- Generation speed counts the time a model spends writing a tool call's
Expand Down
45 changes: 41 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ sub-agent 191 tok
```

Two boxes, each opened and closed by clicking its heading: the last turn,
and the session so far.
and the session so far. `details ›` under them opens the full picture:
where each turn's time went, every step and tool call, and the session's
spread and coverage (see [Details](#details)).

Requires [**OpenCode 2**](https://opencode.ai/v2/docs). For the v1 line
(OpenCode 1.18.x), see
Expand All @@ -36,6 +38,7 @@ Requires [**OpenCode 2**](https://opencode.ai/v2/docs). For the v1 line

- [Install](#install)
- [Keys](#keys)
- [Details](#details)
- [Configuration](#configuration)
- [Supported Engines](#supported-engines)
- [Engine Details](#engine-details)
Expand Down Expand Up @@ -68,10 +71,11 @@ Equivalent, if you keep your config in version control:
| --- | --- |
| `ctrl+shift+m` | Collapse/expand the last-turn box. Clicking its heading does the same. |
| `ctrl+shift+h` | Open/close the per-turn history panel. |
| `ctrl+shift+d` | Open/close the details dialog. So do `/headsup` and clicking `details ›`. |

Both are registered with stable command ids (`headsup.toggle`,
`headsup.panel`), so they can be remapped from your own OpenCode keybind
config and are reachable from the command palette.
All three are registered with stable command ids (`headsup.toggle`,
`headsup.panel`, `headsup.details`), so they can be remapped from your own
OpenCode keybind config and are reachable from the command palette.

The Session box has no key; click its heading. Collapsed, each box keeps
one figure rather than becoming a bare label:
Expand All @@ -80,6 +84,39 @@ one figure rather than becoming a bare label:
▸ MTPLX · last turn 34.4 tok/s
```

## Details

A dialog with the last turn on the left and the session on the right. On a
terminal narrower than 110 columns it shows one at a time; `tab` switches.
It scrolls with the wheel, `↑` `↓` and page up/down; `esc` closes it.

**Last turn**
- **Where the time went**, in seconds and as shares that add up to the
turn's total: waiting for the first token, generating, tools, sub-agents,
compaction, and the rest. A moment is counted once, so a tool running
beside a sub-agent is not counted twice.
- **Steps**: tokens, tok/s and time to first token per step, each tool call
and how long it ran, retries, and a step that waited on a compaction.
- **Tokens**: output, reasoning, fresh input, cache read and cache write;
context used against the model's limit.
- **◆ Engine**: only what the engine itself measured -- MTPLX's acceptance
at every depth and its verify passes, prefill and decode as tokens and
seconds, cache reuse, draft acceptance, and per-step rates where the
engine is read per step. When its figures were not used, it says which
and why.
- Retry and error reasons in full, and sub-agent totals.

**Session** (the current model's turns)
- Speed as an average and a spread (min, median, p90, max), a trend, and
time to first token (median, p90, max).
- Where the time went, in seconds, across the session.
- Tools by time, retries by reason, tokens in all five kinds.
- **Coverage**: how many turns had the engine's own figures, and why the
rest did not (first turn, compaction, overlapping requests, no engine
telemetry, ...).

Everything unmarked is OpenCode's own data; `◆` marks the engine's.

## Configuration

Two keys, because two things are genuinely preferences. Everything else
Expand Down
16 changes: 14 additions & 2 deletions adapters/koboldcpp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import { httpJson, type HttpOptions } from "../http"
import { nn, ni } from "../format"
import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows"
import { rowsOf, timeRows, viewText, nt, phase, type Row, type TurnView } from "../rows"
/**
* The fields of /api/extra/perf this plugin reads. The endpoint returns more
* (image/TTS/transcription counters, horde bookkeeping, seeds) that describe
Expand Down Expand Up @@ -252,7 +252,19 @@ export function koboldView(
t.generationsInWindow !== undefined && t.generationsInWindow > 1
? [`${ni(t.generationsInWindow)} generations this turn`, "(last shown only)"]
: []
return { engine: "KoboldCpp", rows, notes, key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined }
const detail: Row[] = [
...rowsOf("speed", [t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : ""]),
...rowsOf("prefill", [t.prefillTokS !== undefined ? `${ni(t.prefillTokS)} tok/s` : "", phase(t.promptTokens, t.prefillS)]),
["decode", phase(t.completionTokens, t.decodeS)],
...rowsOf("draft", [t.draftAcceptRate !== undefined ? `${ni(t.draftAcceptRate * 100)}% accepted` : ""]),
]
return {
engine: "KoboldCpp",
rows,
notes,
key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined,
detail,
}
}

/** The view as text; kept for tests that look for a figure. */
Expand Down
9 changes: 7 additions & 2 deletions adapters/llamacpp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import { sumLabeledMetric } from "../prometheus-text"
import { httpText, type HttpOptions } from "../http"
import { nn, ni } from "../format"
import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows"
import { rowsOf, timeRows, viewText, nt, phase, type Row, type TurnView } from "../rows"

export interface LlamaCppCounters {
promptTokens: number
Expand Down Expand Up @@ -142,7 +142,12 @@ export function llamaCppView(
...rowsOf("tokens", [nt(t.completionTokens), host.includesSubagents ? "incl. sub-agents" : ""]),
...timeRows(host.total ?? t.decodeS + t.prefillS, host.retries),
]
return { engine: label, rows, notes: [], key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined }
const detail: Row[] = [
...rowsOf("speed", [t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : ""]),
...rowsOf("prefill", [t.prefillTokS !== undefined ? `${ni(t.prefillTokS)} tok/s` : "", phase(t.promptTokens, t.prefillS)]),
["decode", phase(t.completionTokens, t.decodeS)],
]
return { engine: label, rows, notes: [], key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined, detail }
}

/** The view as text; kept for tests that look for a figure. */
Expand Down
18 changes: 16 additions & 2 deletions adapters/mlxserve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

import { httpJson, type HttpOptions } from "../http"
import { nn, ni } from "../format"
import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows"
import { rowsOf, timeRows, viewText, nt, phase, type Row, type TurnView } from "../rows"

/** One record from /v1/metrics/requests, as the server names its fields. */
export interface MlxServeRequest {
Expand Down Expand Up @@ -295,7 +295,21 @@ export function mlxServeView(
// as a tenfold slowdown rather than a one-off load.
if (t.coldStart) notes.push("cold start (model loaded)")
if (t.requests > 1 && t.steps === undefined) notes.push(`${ni(t.requests)} requests this turn`)
return { engine: "mlx-serve", rows, notes, key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined }
const detail: Row[] = [
...rowsOf("speed", [t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : ""]),
...rowsOf("ttft", [t.decodeTokS !== undefined && t.ttft !== undefined ? `${nn(t.ttft, 2)}s` : ""]),
["tokens", nt(t.completionTokens)],
...rowsOf("prompt", [t.promptTokens !== undefined ? `${nt(t.promptTokens)} tok` : ""]),
["request", `${nn(t.totalS, 2)}s`],
...rowsOf("requests", [t.requests > 1 ? nt(t.requests) : ""]),
]
return {
engine: "mlx-serve",
rows,
notes,
key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined,
detail,
}
}

/** The view as text; kept for tests that look for a figure. */
Expand Down
19 changes: 18 additions & 1 deletion adapters/mtplx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,24 @@ export function mtplxView(
rows.push(["accepted", `${l.mean_accept_probability_by_depth.map((p) => Math.round(p * 100)).join("/")}%`])
}
}
return { engine: "MTPLX", rows, notes: [], key: decode !== undefined ? `${nn(decode)} tok/s` : undefined }
// The dialog's engine section: MTPLX's own figures only, with the verify
// passes behind the MTP rate and acceptance at every depth.
const depths = Array.isArray(l.mean_accept_probability_by_depth) ? l.mean_accept_probability_by_depth : []
const detail: Row[] = [
...rowsOf("speed", [decode !== undefined ? `${nn(decode)} tok/s` : ""]),
...rowsOf("ttft", [ttft !== undefined ? `${nn(ttft, 2)}s` : ""]),
...rowsOf("prefill", [prefill !== undefined ? `${ni(prefill)} tok/s` : ""]),
...rowsOf("tokens", [completion !== undefined ? nt(completion) : ""]),
...rowsOf("request", [num(l.request_elapsed_s) !== undefined ? `${nn(num(l.request_elapsed_s) as number, 2)}s` : ""]),
...(verify !== undefined && verify > 0 && completion !== undefined
? rowsOf("MTP", [`${nn(completion / verify, 2)}x`, `${nt(verify)} verify passes`])
: []),
...rowsOf(
"accepted",
depths.map((p, i) => `${Math.round(p * 100)}% at depth ${i + 1}`)
),
]
return { engine: "MTPLX", rows, notes: [], key: decode !== undefined ? `${nn(decode)} tok/s` : undefined, detail }
}

/** The view as text; kept for tests that look for a figure. */
Expand Down
16 changes: 15 additions & 1 deletion adapters/omlx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

import { httpJson, type HttpOptions } from "../http"
import { nn, ni } from "../format"
import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows"
import { rowsOf, timeRows, viewText, nt, phase, type Row, type TurnView } from "../rows"

/** Cumulative counters as this plugin reads them. */
export interface OmlxSample {
Expand Down Expand Up @@ -132,6 +132,10 @@ export function omlxView(
],
notes: [],
key: `${nn(now.avgGen)} tok/s avg`,
detail: [
["speed", `${nn(now.avgGen)} tok/s (server avg)`],
["prefill", `${ni(now.avgPrefill)} tok/s (server avg)`],
],
}
}

Expand Down Expand Up @@ -173,6 +177,16 @@ export function omlxView(
],
notes: [],
key: `${nn(decode)} tok/s${decodeLabel}`,
// The host's rate stands in when oMLX's can't be recovered; it is not
// the engine's, so the engine section leaves it out.
detail: [
...rowsOf("speed", [useHostRate ? "" : `${nn(decode)} tok/s${decodeLabel}`]),
["prefill", `${ni(prefill)} tok/s${prefillLabel}`],
["tokens", nt(completion)],
["prompt", `${nt(promptTokens)} tok`],
...rowsOf("cached", [cached > 0 ? `${nt(cached)} tok` : ""]),
["requests", nt(now.requests - prev.requests)],
],
}
}

Expand Down
22 changes: 20 additions & 2 deletions adapters/prometheus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { httpText, type HttpOptions } from "../http"
import { sumLabeledMetric } from "../prometheus-text"
import { nn, ni } from "../format"
import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows"
import { rowsOf, timeRows, viewText, nt, phase, type Row, type TurnView } from "../rows"
export interface PromSpec {
prefix: string
promptTokens: string
Expand Down Expand Up @@ -346,7 +346,25 @@ export function promView(
["prompt", nt(diff.promptTokens)],
...rowsOf("cached", [diff.cachedTokens > 0 ? nt(diff.cachedTokens) : ""]),
]
return { engine: label, rows, notes: [], key: decodeTokS !== undefined ? `${nn(decodeTokS)} tok/s${overall}` : undefined }
// The engine's own figures only: its rate and TTFT for a single request
// (over several, the counters give only means, and the host's stand in).
const detail: Row[] = [
...rowsOf("speed", [single && diff.decodeTokS !== undefined ? `${nn(diff.decodeTokS)} tok/s` : ""]),
...rowsOf("ttft", [single && diff.ttft !== undefined ? `${nn(diff.ttft, 2)}s` : ""]),
...rowsOf("prefill", [single && diff.prefillTokS !== undefined ? `${ni(diff.prefillTokS)} tok/s` : ""]),
["tokens", nt(diff.completionTokens)],
["prompt", `${nt(diff.promptTokens)} tok`],
...rowsOf("cached", [diff.cachedTokens > 0 ? `${nt(diff.cachedTokens)} tok` : ""]),
...rowsOf("request", [single && diff.durationS !== undefined ? `${nn(diff.durationS, 2)}s` : ""]),
["requests", nt(ttftCount)],
]
return {
engine: label,
rows,
notes: [],
key: decodeTokS !== undefined ? `${nn(decodeTokS)} tok/s${overall}` : undefined,
detail,
}
}

/** The view as text, or null when declined; kept for tests that look for a figure. */
Expand Down
12 changes: 10 additions & 2 deletions adapters/splash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import { sumLabeledMetric } from "../prometheus-text"
import { httpText, type HttpOptions } from "../http"
import { nn, ni } from "../format"
import { rowsOf, timeRows, viewText, nt, type Row, type TurnView } from "../rows"
import { rowsOf, timeRows, viewText, nt, phase, type Row, type TurnView } from "../rows"

/**
* Names verified against the server's own metrics.py (Splash 1.0), which maps
Expand Down Expand Up @@ -219,7 +219,15 @@ export function splashView(
// Only when a turn spanned several requests nobody accounted for, so the
// figures above read as sums rather than as one reply.
const notes = t.requests > 1 && host.steps === undefined ? [`${ni(t.requests)} requests this turn`] : []
return { engine: "Splash", rows, notes, key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined }
const detail: Row[] = [
...rowsOf("speed", [t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : ""]),
...rowsOf("prefill", [t.prefillTokS !== undefined ? `${ni(t.prefillTokS)} tok/s` : "", phase(t.promptTokens, t.prefillS)]),
["decode", phase(t.completionTokens, t.decodeS)],
...rowsOf("cached", [t.cachedTokens > 0 ? `${nt(t.cachedTokens)} tok reused` : ""]),
...rowsOf("draft", [t.draftAcceptRate !== undefined ? `${ni(t.draftAcceptRate * 100)}% accepted` : ""]),
["requests", nt(t.requests)],
]
return { engine: "Splash", rows, notes, key: t.decodeTokS !== undefined ? `${nn(t.decodeTokS)} tok/s` : undefined, detail }
}

/** The view as text; kept for tests that look for a figure. */
Expand Down
39 changes: 39 additions & 0 deletions counters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Taking a known request out of a counter window.
//
// Pure, like the other shared modules. Engines that publish cumulative
// counters -- Splash, llama.cpp, llamafile, the Prometheus engines -- give a
// turn's figures as the difference between a reading before it and one after.
// A request of OpenCode's own inside that window (a compaction, measured on
// Splash) lands in the difference too, and the turn is declined because its
// tokens no longer match. When that request was bracketed by readings of its
// own, its difference can be taken back out.

/** Field-wise `after - before`, for every numeric field. */
export function counterDelta<T extends object>(before: T, after: T): Partial<Record<keyof T, number>> {
const out: Partial<Record<keyof T, number>> = {}
for (const k of Object.keys(after) as Array<keyof T>) {
const a = after[k]
const b = before[k]
if (typeof a === "number" && typeof b === "number") out[k] = a - b
}
return out
}

/**
* The baseline moved forward by each bracketed request's own difference, so
* that `now - shifted` holds everything in the window except those requests.
* Non-numeric fields keep the baseline's. A bracket that went backwards (a
* restarted engine) can't be trusted, and leaves the baseline as it was.
*/
export function shiftBaseline<T extends object>(prev: T, brackets: ReadonlyArray<{ before: T; after: T }>): T {
const shifted = { ...prev } as Record<string, unknown>
for (const { before, after } of brackets) {
const d = counterDelta(before, after) as Record<string, number | undefined>
if (Object.values(d).some((v) => v !== undefined && v < 0)) continue
for (const [k, v] of Object.entries(d)) {
const cur = shifted[k]
if (typeof cur === "number" && v !== undefined) shifted[k] = cur + v
}
}
return shifted as T
}
Loading
Loading