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
146 changes: 61 additions & 85 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,50 @@
alt="OpenCode Heads Up" width="510">
</h1>

OpenCode Heads Up is a heads-up display (HUD) with per-turn telemetry
for both local inference engines and remote models. It contains a
universal layer of baseline metrics along with any additional data from
the provider.


```
▾ 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. `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
[opencode-engine-hud](https://github.com/charlesnutter/opencode-engine-hud).
OpenCode Heads Up is a heads-up display (HUD) with telemetry
for both local inference engines and remote models. Get your data straight from the source, with OpenCode's own data layer filling in the gaps.

<table>
<tr>
<td width="50%" align="center">
<img src="https://raw.githubusercontent.com/charlesnutter/opencode-headsup/main/assets/screenshot-sidebar.png" alt="Heads Up in the OpenCode sidebar: the last turn and the session so far" />
</td>
<td width="50%">
<img src="https://raw.githubusercontent.com/charlesnutter/opencode-headsup/main/assets/screenshot-turn.png" alt="Heads Up details dialog, Turn tab" />
</td>
</tr>
<tr>
<td width="50%" align="center"><strong>Sidebar</strong><br />The last turn and the session so far, each collapsible, with <code>details ›</code> for the full picture.</td>
<td width="50%" align="center"><strong>Turn</strong><br />Where the time went, a timeline per step, every tool call, tokens and the engine's own figures.</td>
</tr>
<tr>
<td width="50%">
<img src="https://raw.githubusercontent.com/charlesnutter/opencode-headsup/main/assets/screenshot-session.png" alt="Heads Up details dialog, Session tab" />
</td>
<td width="50%">
<img src="https://raw.githubusercontent.com/charlesnutter/opencode-headsup/main/assets/screenshot-history.png" alt="Heads Up details dialog, History tab" />
</td>
</tr>
<tr>
<td width="50%" align="center"><strong>Session</strong><br />Speed and its spread, where the time went, tools, coverage and engine averages.</td>
<td width="50%" align="center"><strong>History</strong><br />Every turn in fixed columns, for this session or all of them.</td>
</tr>
</table>

## Features

- **Metrics straight from local engines**: speed, prefill, cache and speculative-decoding
figures read from the engine itself. [See the full list](#supported-engines).
- **Works with any model**: remote and hosted models get the same core
figures from OpenCode's own data.
- **Sidebar and details view**
- **Turn and session figures**
- **Where the time went**: each turn split into waiting, generating, tools,
sub-agents and compaction, step by step.
- **Practical numbers**: tok/s is generation speed only, time is real elapsed
time, bad data is dispensed with.
- **Sub-agent aware**: sub-agents are counted and shown
separately.

## Contents

Expand All @@ -48,15 +62,15 @@ Requires [**OpenCode 2**](https://opencode.ai/v2/docs). For the v1 line

## Install

Headsup 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).

```bash
opencode plugin add @banburist/opencode-headsup
```

Restart OpenCode. The panel appears in the sidebar footer after the first
turn. `opencode plugin list` shows what is installed; `plugin update` and
`plugin remove` handle the rest.

Equivalent, if you keep your config in version control:
Or place it within the OpenCode cli config file.

```jsonc
// ~/.config/opencode/cli.json
Expand Down Expand Up @@ -453,62 +467,24 @@ Same shape for any OpenAI-compatible server:

### tok/s is generation speed; the total is what you waited

`tok/s` is tokens over the time spent streaming after the first token —
raw generation speed. OpenCode's own tok/s, in the footer under each turn,
divides by each step's time from the request to the end of streaming: it
leaves out time spent running tools, but includes prefill and the wait for
the first token. On a turn with a long wait before the first token the two
differ widely (measured: 38.1 tok/s over a 0.97s decode window against 3.7
over the same turn's 10.03s; on MTPLX with a 17.6s prefill, 35.2 against
OpenCode's 13.1). Both are correct; the TTFT
beside the rate is what reconciles them. A turn that cannot be timed
from its stream shows no rate rather than a whole-turn figure.

A large prefill shows in TTFT, in the prefill rate where the engine
reports one, and in the total — never in `tok/s`. The total runs from
the request to the end of the turn, and names any retries OpenCode made:
`60.00s (6 retries)`.

A turn that calls tools is several requests, one per step. Its tokens,
cost and cache reuse are summed over every step; its `tok/s` covers only
the steps' own streaming, never the time spent running tools.
`tok/s` counts only the time spent generating, after the first token, so
it can be far higher than OpenCode's own figure, which includes the wait
for the first token. That wait shows as TTFT, and the total is the real
time from request to answer, retries included.

### Every figure is one turn, never a running total

Four things in this API are cumulative where a per-turn figure is
expected — `session.usage.updated`, `session.cost()`, raw engine
counters, and `time.streamed` (which is stamped at the *end* of the
stream, not the start, and is therefore not a TTFT). The per-turn
figures here are differenced or measured accordingly.

A counter difference is only one turn's when the requests that reached the
engine between the two readings are this turn's own — one per step — and
its token count equals OpenCode's for the turn. OpenCode's own background
work (a new session's title, compaction), a turn you interrupted that kept
generating, or another tab or client sharing the server all break that, and
no engine here labels its counters by request or session to separate them
again. So a turn that shared its window shows the universal line with
`engine data skipped: overlapping requests` rather than figures that
describe several requests at once. This applies to every engine that
differences counters: the Prometheus engines, `llamacpp`, `llamafile`,
`splash` and `omlx`, checked against the turn's tokens and, where the
engine counts requests, against its steps. Verified live on vllm-mlx; the
others are built from their live captures.

`mtplx`, `koboldcpp` and `mlxserve` report only the engine's latest
request, so they are read at the end of every step and the steps' receipts
combined: tokens summed, the rate over every step's decode time, TTFT and
prefill from the first step, the step that read the context. Each receipt
must match OpenCode's count for its step, or the turn shows the universal
line with the notice. Verified live on MTPLX; KoboldCpp and mlx-serve are
built from their live captures but not yet run step by step against a live
server.
Engines that only publish running totals are read before and after each
turn, and the difference is used only when it matches OpenCode's count for
that turn. When other work shares the engine at the same time (another tab,
say), the engine's figures are left out and the reason is shown.

### Absent is not zero

A free model shows no cost rather than `$0.00`, a cold prompt shows no
cache line rather than `0 cached`, and a missing speculative-draft
counter shows nothing rather than `0% accepted`.
A figure that isn't available is left out, not shown as zero: no `$0.00`
for a free model, no `0 cached` for a cold prompt.

More detail: [docs/measurement-notes.md](docs/measurement-notes.md).

## Roadmap

Expand Down
Binary file added assets/screenshot-history.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/screenshot-session.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/screenshot-sidebar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/screenshot-turn.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 1 addition & 44 deletions dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,49 +442,6 @@ export function engineItems(rows: ReadonlyArray<readonly [string, string]>): Arr
return items.sort((a, b) => rank(a.label) - rank(b.label))
}

/**
* Label/value rows packed several to a line, as the mockup's engine section:
* `speed 41.2 tok/s prefill 475 tok/s ttft 17.37s`. A row with an
* empty label continues the one above; acceptance by depth folds into
* `93/87/82% by depth`.
*/
export function packRows(rows: ReadonlyArray<readonly [string, string]>, w: number): Line[] {
const all: Array<{ label: string; values: string[] }> = []
for (const [label, value] of rows) {
if (label || all.length === 0) all.push({ label, values: [value] })
else (all[all.length - 1] as { values: string[] }).values.push(value)
}
// The mockup's order: rates first, then speculative decoding. The token
// count is left out: the Tokens section already has it.
const ORDER = ["speed", "prefill", "ttft", "MTP", "accepted", "draft"]
const rank = (l: string): number => (ORDER.includes(l) ? ORDER.indexOf(l) : ORDER.length)
const groups = all.filter((g) => g.label !== "tokens").sort((a, b) => rank(a.label) - rank(b.label))
const segs = groups.map(({ label, values }): { label: string; value: Line } => {
const depths = values.map((v) => /^(\d+)% at depth \d+$/.exec(v)?.[1])
if (depths.length > 1 && depths.every((x) => x !== undefined)) {
return { label, value: [[`${depths.join("/")}%`, "bold"], [" by depth", "dim"]] }
}
const [first, ...rest] = values
return { label, value: [[first ?? "", "bold"], ...(rest.length > 0 ? ([[` ${rest.join(" ")}`, "dim"]] as Line) : [])] }
})
const out: Line[] = []
let cur: Line = []
let prev = ""
for (const g of segs) {
const first: Line = [[g.label.padEnd(LABEL), "dim"], ...g.value]
const next: Line = [[" ", ""], [`${g.label} `, "dim"], ...g.value]
const newGroup = prev !== "" && rank(prev) <= 2 && rank(g.label) > 2
prev = g.label
if (cur.length === 0) cur = first
else if (newGroup || width(cur) + width(next) > w) {
out.push(cur)
cur = first
} else cur = [...cur, ...next]
}
if (cur.length > 0) out.push(cur)
return out
}

// ---- Session ------------------------------------------------------------------------

/** The Session tab, laid out as the mockup. */
Expand Down Expand Up @@ -608,7 +565,7 @@ export function historyTabLines(
const out: Line[] = [
[
[" ", ""],
[`${n0(rows.length)} turns`, "bold"],
[`${n0(rows.length)} ${rows.length === 1 ? "turn" : "turns"}`, "bold"],
[" · ", "dim"],
[`${n0(tokens)} tok`, "bold"],
...(genS > 0 ? ([[" · ", "dim"], [`${n1(genTok / genS)} tok/s`, "bold"], [" avg", "dim"]] as Line) : []),
Expand Down
78 changes: 78 additions & 0 deletions docs/measurement-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Measurement notes

The README's "Important Notes" section in full, as it stood before it was
shortened (2026-09-25). Kept here as source material for fuller docs later.

Two things have changed since it was written and should be updated before
this is published anywhere:

- **Compaction.** A compaction inside a turn on a counter engine is now read
before and after and taken out of the turn's window, so the turn keeps its
engine figures; `compaction ran this turn` is shown only when that fails.
- **Same-engine sub-agents.** A sub-agent on the same counter engine as its
parent is expected in the parent's window (its tokens and steps are added
to the check) and labelled `incl. sub-agents`, and each turn now takes its
own starting reading, so the sub-agent's report no longer moves it.

---

## Important Notes (as of 0.4.0)

### tok/s is generation speed; the total is what you waited

`tok/s` is tokens over the time spent streaming after the first token —
raw generation speed. OpenCode's own tok/s, in the footer under each turn,
divides by each step's time from the request to the end of streaming: it
leaves out time spent running tools, but includes prefill and the wait for
the first token. On a turn with a long wait before the first token the two
differ widely (measured: 38.1 tok/s over a 0.97s decode window against 3.7
over the same turn's 10.03s; on MTPLX with a 17.6s prefill, 35.2 against
OpenCode's 13.1). Both are correct; the TTFT
beside the rate is what reconciles them. A turn that cannot be timed
from its stream shows no rate rather than a whole-turn figure.

A large prefill shows in TTFT, in the prefill rate where the engine
reports one, and in the total — never in `tok/s`. The total runs from
the request to the end of the turn, and names any retries OpenCode made:
`60.00s (6 retries)`.

A turn that calls tools is several requests, one per step. Its tokens,
cost and cache reuse are summed over every step; its `tok/s` covers only
the steps' own streaming, never the time spent running tools.

### Every figure is one turn, never a running total

Four things in this API are cumulative where a per-turn figure is
expected — `session.usage.updated`, `session.cost()`, raw engine
counters, and `time.streamed` (which is stamped at the *end* of the
stream, not the start, and is therefore not a TTFT). The per-turn
figures here are differenced or measured accordingly.

A counter difference is only one turn's when the requests that reached the
engine between the two readings are this turn's own — one per step — and
its token count equals OpenCode's for the turn. OpenCode's own background
work (a new session's title, compaction), a turn you interrupted that kept
generating, or another tab or client sharing the server all break that, and
no engine here labels its counters by request or session to separate them
again. So a turn that shared its window shows the universal line with
`engine data skipped: overlapping requests` rather than figures that
describe several requests at once. This applies to every engine that
differences counters: the Prometheus engines, `llamacpp`, `llamafile`,
`splash` and `omlx`, checked against the turn's tokens and, where the
engine counts requests, against its steps. Verified live on vllm-mlx; the
others are built from their live captures.

`mtplx`, `koboldcpp` and `mlxserve` report only the engine's latest
request, so they are read at the end of every step and the steps' receipts
combined: tokens summed, the rate over every step's decode time, TTFT and
prefill from the first step, the step that read the context. Each receipt
must match OpenCode's count for its step, or the turn shows the universal
line with the notice. Verified live on MTPLX; KoboldCpp and mlx-serve are
built from their live captures but not yet run step by step against a live
server.

### Absent is not zero

A free model shows no cost rather than `$0.00`, a cold prompt shows no
cache line rather than `0 cached`, and a missing speculative-draft
counter shows nothing rather than `0% accepted`.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "npm run typecheck && bun test/format.test.mjs && bun test/http.test.mjs && bun test/prometheus-text.test.mjs && bun test/universal.test.mjs && bun test/mtplx.test.mjs && bun test/omlx.test.mjs && bun test/llamacpp.test.mjs && bun test/prometheus.test.mjs && bun test/koboldcpp.test.mjs && bun test/splash.test.mjs && bun test/mlxserve.test.mjs && bun test/history.test.mjs && bun test/panels.test.mjs && bun test/session.test.mjs && bun test/rows.test.mjs && bun test/detail.test.mjs && bun test/counters.test.mjs && bun test/references.test.mjs && bun test/e2e/turns.e2e.mjs && bun test/e2e/counters.e2e.mjs"
"test": "npm run typecheck && bun test/format.test.mjs && bun test/http.test.mjs && bun test/prometheus-text.test.mjs && bun test/universal.test.mjs && bun test/mtplx.test.mjs && bun test/omlx.test.mjs && bun test/llamacpp.test.mjs && bun test/prometheus.test.mjs && bun test/koboldcpp.test.mjs && bun test/splash.test.mjs && bun test/mlxserve.test.mjs && bun test/history.test.mjs && bun test/panels.test.mjs && bun test/session.test.mjs && bun test/rows.test.mjs && bun test/detail.test.mjs && bun test/counters.test.mjs && bun test/dialog.test.mjs && bun test/references.test.mjs && bun test/e2e/turns.e2e.mjs && bun test/e2e/counters.e2e.mjs && bun test/e2e/dialog.e2e.mjs"
},
"files": [
"tui.tsx",
Expand Down
Loading
Loading