Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4ead9db
Rebuild the details dialog as tabs: turn, session, history; fit its h…
charlesnutter Sep 25, 2026
6b2fc12
Lay the dialog out at its measured width; draw rules in the theme's b…
charlesnutter Sep 25, 2026
2fe0583
Pick dialog colours by contrast with the theme's background; pack the…
charlesnutter Sep 25, 2026
cdb0f99
Make the dialog's figures agree across sections: generated tokens, tr…
charlesnutter Sep 25, 2026
877eb34
Dialog spacing and grid: a blank row under every heading, thin bars, …
charlesnutter Sep 25, 2026
8f5a351
Fill the dialog's width; half-block bars that grow with it
charlesnutter Sep 25, 2026
f356434
Fix a stray comma in the row bars' width
charlesnutter Sep 25, 2026
50eea62
Match the approved dialog mockup: its layout, glyphs, legends, column…
charlesnutter Sep 25, 2026
331b5d5
Dialog: spacing options (blank row under headings, or under bars); ba…
charlesnutter Sep 25, 2026
9f5566d
Dialog spacing as the user laid it out: a row under headings, around …
charlesnutter Sep 25, 2026
7983db3
Dialog: an option to indent the body under section titles
charlesnutter Sep 25, 2026
3998e7a
Two-tone dialog: a lighter panel inside the dialog's darker ring; dro…
charlesnutter Sep 25, 2026
54adb81
Dialog: square bars; token rows and engine figures on aligned columns
charlesnutter Sep 25, 2026
4271f7e
Aligned token rows: the bar gives way to the notes at narrow widths
charlesnutter Sep 25, 2026
a0c35ef
Dialog spacing: a blank row under headings only; always leave room fo…
charlesnutter Sep 25, 2026
901c1fe
Separate a long legend name from its time; changelog for the dialog's…
charlesnutter Sep 25, 2026
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
## [Unreleased]
### Changed
- The details dialog is one column with three tabs -- Turn, Session and
History -- switched with `tab`, instead of two side-by-side columns. It is
as tall as its content (up to most of the screen), fills the width of an
`xlarge` dialog, and sits on a lighter panel inside a darker ring. Each
section has a title and rule with a blank row under it; shares are drawn
as square bars; and figures within a section sit on shared columns
(Tokens' values, qualifiers and bars; the engine's figures in a
three-column grid).
- Tools by time on the Session tab always shows: the tools used, that none
were, or that tool use was not recorded for older turns.
- The Turn tab adds a timeline: each step's wait, generation and tools on
one time scale. Its steps table shows the engine's own per-step rate
beside OpenCode's where the engine is read per step.
- `ctrl+shift+h` opens the dialog's History tab instead of the history
panel. The tab lays each turn out in fixed columns, so a long row no
longer wraps; `s` switches between this session and every session.
- `/headsup session` and `/headsup history` open the dialog on those tabs.

## [0.4.0] – 2026-09-25
### Added
- A **details dialog**, opened by `details ›` under the sidebar boxes,
Expand Down
35 changes: 23 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,10 @@ Equivalent, if you keep your config in version control:
| Key | Does |
| --- | --- |
| `ctrl+shift+m` | Collapse/expand the last-turn box. Clicking its heading does the same. |
| `ctrl+shift+h` | Open/close the per-turn history panel. |
| `ctrl+shift+d` | Open/close the details dialog. So do `/headsup` and clicking `details ›`. |
| `ctrl+shift+d` | Open/close the details dialog on the Turn tab. So do `/headsup` and clicking `details ›`. |
| `ctrl+shift+h` | Open the details dialog on the History tab. |

`/headsup session` and `/headsup history` open the dialog on those tabs.

All three are registered with stable command ids (`headsup.toggle`,
`headsup.panel`, `headsup.details`), so they can be remapped from your own
Expand All @@ -86,15 +88,17 @@ one figure rather than becoming a bare label:

## Details

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

**Last turn**
- **Where the time went**, in seconds and as shares that add up to the
turn's total: waiting for the first token, generating, tools, sub-agents,
compaction, and the rest. A moment is counted once, so a tool running
beside a sub-agent is not counted twice.
A dialog with three tabs -- **Turn**, **Session** and **History** --
switched with `tab` (`shift+tab` goes back). It is as tall as its content,
up to most of the screen, and scrolls beyond that with the wheel, `↑` `↓`
and page up/down; `esc` closes it.

**Turn**
- **Where the time went**, as a bar and in seconds and shares that add up
to the turn's total: waiting for the first token, generating, tools,
sub-agents, compaction, and the rest. A moment is counted once, so a tool
running beside a sub-agent is not counted twice.
- **Timeline**: each step's wait, generation and tools on one time scale.
- **Steps**: tokens, tok/s and time to first token per step, each tool call
and how long it ran, retries, and a step that waited on a compaction.
- **Tokens**: output, reasoning, fresh input, cache read and cache write;
Expand All @@ -110,11 +114,18 @@ It scrolls with the wheel, `↑` `↓` and page up/down; `esc` closes it.
- Speed as an average and a spread (min, median, p90, max), a trend, and
time to first token (median, p90, max).
- Where the time went, in seconds, across the session.
- Tools by time, retries by reason, tokens in all five kinds.
- Tools by time as bars, retries by reason, tokens in all five kinds.
- **Coverage**: how many turns had the engine's own figures, and why the
rest did not (first turn, compaction, overlapping requests, no engine
telemetry, ...).

**History**
- One row per turn in fixed columns, never wrapped: time, tok/s, tokens,
time to first token, total, tool calls, and `◆` where the engine's own
figures were used. `s` switches between this session and every session,
where a model column appears. Cost and cache columns appear when some
turn has them.

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

## Configuration
Expand Down
115 changes: 7 additions & 108 deletions detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export interface StepDetail {
retryReason?: string
/** Seconds of this step's wait for its first token spent on compaction. */
compactionS?: number
/** Epoch ms: the step's request, its first token and its last, for the timeline. */
createdAt: number
firstAt?: number
lastAt?: number
error?: string
}

Expand Down Expand Up @@ -193,6 +197,9 @@ export function buildTurnDetail(
retries: Math.max(0, (t?.attempts ?? 1) - 1),
retryReason: m.retry?.error?.message,
error: m.error?.message,
createdAt: m.time.created,
firstAt: t?.firstAt,
lastAt: t?.lastAt,
}
if (t?.firstAt !== undefined && t.firstAt > m.time.created) {
s.ttftS = (t.firstAt - m.time.created) / 1000
Expand Down Expand Up @@ -303,15 +310,6 @@ export function wrap(text: string, width: number): string[] {
return out
}

/** A titled block of the dialog: labelled rows, or preformatted lines. */
export interface Section {
title: string
rows?: Row[]
lines?: string[]
}

const secs = (s: number): string => (s >= 60 ? `${Math.floor(s / 60)}m ${String(Math.round(s % 60)).padStart(2, "0")}s` : `${s.toFixed(2)}s`)
const n0 = (v: number): string => Math.round(v).toLocaleString("en-US")

/**
* Largest-remainder rounding, so shares of a whole add up to exactly 100.
Expand All @@ -331,102 +329,3 @@ export function percents(parts: readonly number[]): number[] {
}
return floor
}

/** The turn column's sections. Widths fit a 46-cell column. */
export function turnSections(d: TurnDetail): Section[] {
const out: Section[] = []
if (d.time && d.totalS !== undefined) {
const parts: Array<[string, number]> = [
["waiting", d.time.waiting],
["generating", d.time.generating],
["tools", d.time.tools],
["sub-agents", d.time.subagents],
["compaction", d.time.compaction],
["other", d.time.other],
]
const shown = parts.filter(([label, v]) => v > 0 || label === "waiting" || label === "generating")
const pct = percents(shown.map(([, v]) => v))
out.push({
title: `Where the time went · ${secs(d.totalS)}`,
rows: shown.map(([label, v], i) => [label, `${secs(v).padStart(8)} ${String(pct[i]).padStart(3)}%`] as const),
})
}
if (d.steps.length > 0) {
const lines = ["# tokens tok/s ttft tool"]
d.steps.forEach((s, i) => {
const tok = s.output + s.reasoning
const rate = s.streamS && s.streamS > 0 ? (tok / s.streamS).toFixed(1) : "—"
const ttft = s.ttftS !== undefined ? `${s.ttftS.toFixed(2)}s` : "—"
const head = `${String(i + 1).padEnd(2)} ${n0(tok).padStart(6)} ${rate.padStart(5)} ${ttft.padStart(6)} `
const tools = s.tools.length > 0 ? s.tools : [undefined]
tools.forEach((t, j) => {
const tail = t
? `${t.name.slice(0, 9).padEnd(9)} ${t.seconds !== undefined ? secs(t.seconds) : t.status}`
: s.finish && s.finish !== "tool-calls"
? `— ${s.finish}`
: ""
lines.push((j === 0 ? head : " ".repeat(head.length)) + tail)
})
if (s.retries > 0) lines.push(`${" ".repeat(3)}${s.retries} ${s.retries === 1 ? "retry" : "retries"}`)
if (s.compactionS !== undefined && s.compactionS > 0) lines.push(`${" ".repeat(3)}waited on compaction ${secs(s.compactionS)}`)
})
out.push({ title: `Steps · ${d.steps.length}`, lines })
// The reasons in full, wrapped: the table only has room for a count.
const reasons = d.steps.flatMap((s, i) =>
[s.retryReason ? `step ${i + 1}: ${s.retryReason}` : "", s.error ? `step ${i + 1} failed: ${s.error}` : ""].filter(Boolean)
)
if (reasons.length > 0) out.push({ title: "Retries and errors", lines: reasons.flatMap((r) => wrap(r, 46)) })
}
const t = d.tokens
const rows: Row[] = [
["output", n0(t.output)],
...(t.reasoning > 0
? ([["reasoning", `${n0(t.reasoning)} (${Math.round((t.reasoning / Math.max(1, t.output + t.reasoning)) * 100)}% of output)`]] as Row[])
: []),
["input", `${n0(t.input)} fresh`],
["", `${n0(t.cacheRead)} cache read`],
...(t.cacheWrite > 0 ? ([["", `${n0(t.cacheWrite)} cache write`]] as Row[]) : []),
]
if (d.context) {
rows.push([
"context",
d.context.limit ? `${n0(d.context.used)} / ${n0(d.context.limit)} ${Math.round((d.context.used / d.context.limit) * 100)}%` : n0(d.context.used),
])
}
if (d.cost !== undefined) rows.push(["cost", `$${d.cost.toFixed(4)}`])
out.push({ title: "Tokens", rows })
if (d.engineRows.length > 0) {
const perStep = (d.stepEngine ?? []).flatMap((e, i) =>
e && (e.decodeTokS !== undefined || e.prefillTokS !== undefined)
? [
`step ${String(i + 1).padEnd(2)} ${e.decodeTokS !== undefined ? `${e.decodeTokS.toFixed(1)} tok/s` : ""}${
e.prefillTokS !== undefined ? ` prefill ${Math.round(e.prefillTokS)} tok/s` : ""
}`,
]
: []
)
const lines = [
...(perStep.length > 1 ? ["", "per step", ...perStep] : []),
...(d.compactionEngine ? ["", "compaction, taken out of the above", ...d.compactionEngine] : []),
]
out.push({ title: `${ENGINE_MARK} Engine · ${d.engine}`, rows: d.engineRows, lines: lines.length > 0 ? lines : undefined })
} else if (d.engineNote && d.engineNote.length > 0) {
// The engine reported; its figures were not used. Say which and why,
// rather than reading as though it had been silent.
out.push({ title: `Engine · ${d.engine}`, lines: [`${d.engine}'s figures were left out:`, ...d.engineNote] })
} else {
out.push({ title: `Engine · ${d.engine}`, lines: ["no engine telemetry for this provider"] })
}
if (d.subagents) {
out.push({
title: "Sub-agents",
rows: [
["count", String(d.subagents.count)],
["tokens", n0(d.subagents.tokens)],
["time", secs(d.subagents.spanS)],
...(d.subagents.cost !== undefined ? ([["cost", `$${d.subagents.cost.toFixed(4)}`]] as Row[]) : []),
],
})
}
return out
}
Loading
Loading