Skip to content
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# Changelog

## Unreleased

- Consumers (`s`) counts the **system prompt**. It walked the transcript only, so its total
could never be reconciled with the `ctx …` gauge two lines above it, which reads
`tokens.input` and does include the system prompt — a silently missing 5–15k on an agent with
a large base prompt and an `AGENTS.md`. The server half now snapshots what the provider is
really sent (in `experimental.chat.system.transform`, before adding its own note) and the view
shows a `≡ system prompt` bucket with one entry per part — base prompt, `AGENTS.md`,
environment — so you can see that your rules file costs 4k. It is not croppable, and `y`
copies a part in full. A session whose prompt the plugin has not seen yet shows no bucket
rather than a misleading zero.

- The timeline lanes fill the terminal, ending on the same column as the rows and the status
line. They were capped at 80 cells and reserved a hardcoded 61 columns for the label and the
mode legend that actually print 49, so a 200-column terminal left ~71 blank columns to the
right of the strip and even a 130-column one left 16. The reserve is now derived from the
strings themselves (`core/lanes.ts#LANE_CHROME`), so shortening the mode legend widens the
strip instead of leaving a gap — which is what went wrong when the "3 calls" mode was
removed and the 61 stayed 61. A wider strip shows more events before scrolling, and the
overview track already hides itself once nothing is off-screen.

## 0.2.3 — 2026-09-04

- **The inspector shows the whole field now, and pages through it.** It used to cap each field
Expand Down
35 changes: 35 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,31 @@ Three changes, in the order they matter:
`y` stays the answer for actually *reading* a large payload — copy it somewhere with search and
folding. The scroller is for "there were twelve more lines and I want to glance at them".

**The system prompt (0.2.4).** Consumers walked the transcript only, so it omitted the system
prompt and its total could not be reconciled with the `ctx …` gauge above it — which reads
`tokens.input` and therefore *does* include it. On an agent with a large base prompt and an
`AGENTS.md` that is a silently missing 5–15k in the one view whose job is "where did my window
go".

`experimental.chat.system.transform` is the only place the plugin can see it. The server half
snapshots `output.system` there **before pushing its own note** (counting our note as part of
the user's prompt would be a small lie in exactly the wrong view), names each part by a shallow
text heuristic (`AGENTS.md`, `CLAUDE.md`, `environment`, …, else `base prompt`), and writes it
to `system-<sessionID>.json` — *overwritten* each request, not appended: it is current state
outside the message tree, not a mutation with history, so `/undo` has nothing to do with it and
it must not grow the way the journal does. The TUI reads it on the same poll as the tree.

It appears as one `≡ system prompt` bucket with an entry per part, `croppable: false` and the
note `sent whole every request · not croppable (y copies a part)` — the same mechanism
`(thinking)` already uses. `y` in the consumers panel copies the selected part in full, which is
the only way to read one: it is not a message, so it has no row and no inspector of its own.

Two honest limits. It is mostly *diagnostic* — OpenCode's base prompt cannot be cropped, though
`AGENTS.md` being 4k is a lever the user owns. And absence means **unknown**, never zero: a
session whose prompt we have not seen yet simply has no bucket, rather than reporting 0.
Tool-definition schemas are still uncounted; `client.tool.list` gives their descriptions but not
what the provider is really sent, so that estimate would be rough enough to mislead.

### 7.5 Filters and search (from Pi)

`f` cycles `default → no-tools → user-only → labeled → all` (default hides
Expand All @@ -643,6 +668,16 @@ labeled shows only `L`-labelled rows). `/` filters rows incrementally by role, t
name, label, and text — every token must match, like Pi. Folding state resets on
filter change (as in Pi) and is otherwise remembered per session in `api.kv`.

**Lane width (0.2.4).** The strip is `width() + 2 - LANE_CHROME`: the terminal, minus the
12-column lane label and the mode legend, plus the two columns a row spends on its `│ ` prefix
*outside* its own width (a lane label carries its own), so all three lane rows end on the same
column as the rows and the status line. `LANE_CHROME` is *measured* from the label and legend
strings in `core/lanes.ts`, never written down — it was a literal `61` against a legend that
printed 49, and it stayed `61` when dropping the "Calls" mode made the legend shorter still.
There is no ceiling on the strip: it is a window onto an unbounded layout, so more cells is
more events visible and less scrolling, and `laneOverview` hides the overview track once
nothing is off-screen.

### 7.6 Narrow terminals

Below 110 columns the inspector is hidden and `i` opens it full-screen; below 80 the
Expand Down
2 changes: 1 addition & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ appended to the trunk as a normal message.*
| `c` `space` `a` `t` `⏎` | crop mode: mark (`space` alone enters it on a croppable row), auto-mark (≥10k tokens, older than 2 turns), result⇄turn, apply |
| `u` (`x`) | undo |
| `D` `E` | decisions panel, export `ctree-decisions.md` |
| `s` | consumers: what is filling the context (`⏎` opens a bucket, `space` marks one entry for crop) |
| `s` | consumers: what is filling the context (`⏎` opens a bucket, `space` marks one entry for crop, `y` copies one). Includes a `≡ system prompt` bucket broken down by part (base prompt, `AGENTS.md`, …) once the plugin has seen one request for the session — it is not croppable, but it is counted, so the total reconciles with the `ctx …` gauge |
| `i` | inspector pane on/off (auto-hidden under 110 columns) |
| `i` `I` `PgUp` `PgDn` | inspector in the side pane / full screen; page through a long payload or result. The pane shows every line it has, sized to your terminal, with `12–40 of 118` at the foot when there is more; `y` copies the untruncated text. Below 110 columns the side pane does not fit, so `i` opens full screen directly |
| `1 2` `0` | timeline lanes, x-axis by duration / one cell per event; `0` off. `│` marks a turn boundary, and the lanes show whatever the `f` filter shows — so `f` → `tools-only` is the "what did I run" view in both the rows and the lanes |
Expand Down
33 changes: 30 additions & 3 deletions src/core/consumers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ export type ConsumerEntry = {
preview: string
/** only a completed tool result can be stubbed by crop's result mode (core/crop.ts) */
croppable: boolean
/** system entries only: which captured part this is. Names are a heuristic and two parts can
* share one (a global and a project `AGENTS.md`), so `y` must not find its text by name. */
systemIndex?: number
}

export type Consumer = {
source: string
kind: "tool" | "assistant" | "user" | "decision" | "summary" | "reasoning"
kind: "tool" | "assistant" | "user" | "decision" | "summary" | "reasoning" | "system"
tokens: number
count: number
/** share of this transcript's own total */
Expand All @@ -32,8 +35,22 @@ export type Consumer = {

const THINKING = "(thinking)"
const THINKING_NOTE = "provider reasoning · not croppable"
const SYSTEM = "≡ system prompt"
const SYSTEM_NOTE = "sent whole every request · not croppable (y copies a part)"

export function consumers(transcript: Transcript, opts: { cropped?: Set<string>; limit?: number } = {}): Consumer[] {
export function consumers(
transcript: Transcript,
opts: {
cropped?: Set<string>
limit?: number
/**
* The system parts the provider is really sent, captured by the server half. Without it
* this view silently omits them, and its total cannot be reconciled with the `ctx …` gauge
* — which reads `tokens.input` and so *does* include them (DESIGN.md §7.4).
*/
system?: { name: string; text: string }[]
} = {},
): Consumer[] {
const acc = new Map<string, Consumer>()
const add = (source: string, kind: Consumer["kind"], tokens: number, message: TranscriptMessage, part: StepPart) => {
const c = acc.get(source) ?? { source, kind, tokens: 0, count: 0, share: 0, entries: [] }
Expand Down Expand Up @@ -62,14 +79,24 @@ export function consumers(transcript: Transcript, opts: { cropped?: Set<string>;
} else if (p.type === "reasoning") add(THINKING, "reasoning", estimateTokens(p.text ?? ""), m, p)
}
}
// one bucket, one entry per part, so "AGENTS.md is 4k" is visible next to "bash is 30k"
for (const [i, part] of (opts.system ?? []).entries()) {
const c = acc.get(SYSTEM) ?? { source: SYSTEM, kind: "system" as const, tokens: 0, count: 0, share: 0, entries: [] }
const tokens = estimateTokens(part.text)
c.tokens += tokens
c.count += 1
c.entries.push({ messageID: "", tokens, preview: `${part.name}: ${part.text.slice(0, 120).replace(/\s+/g, " ").trim()}`, croppable: false, systemIndex: i })
acc.set(SYSTEM, c)
}

const total = [...acc.values()].reduce((s, c) => s + c.tokens, 0) || 1
const limit = opts.limit !== undefined && opts.limit > 0 ? opts.limit : undefined
return [...acc.values()]
.map((c) => ({
...c,
share: c.tokens / total,
...(limit === undefined ? {} : { shareOfWindow: c.tokens / limit }),
...(c.kind === "reasoning" ? { note: THINKING_NOTE } : {}),
...(c.kind === "reasoning" ? { note: THINKING_NOTE } : c.kind === "system" ? { note: SYSTEM_NOTE } : {}),
entries: c.entries.sort((a, b) => b.tokens - a.tokens),
}))
.sort((a, b) => b.tokens - a.tokens)
Expand Down
22 changes: 22 additions & 0 deletions src/core/journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,28 @@ export type JournalEntry = z.infer<typeof JournalEntrySchema>
export type JournalEntryType = JournalEntry["type"]

/** Parse one JSONL line into a validated journal entry, or `undefined` if it is malformed. */
/**
* What the provider is actually sent as its system prompt, captured by the server half in
* `experimental.chat.system.transform` (DESIGN.md §7.4). It is *not* a journal entry: it has
* no history, it is not a mutation the user made, and `/undo` has nothing to do with it — the
* store keeps it in its own per-session file, overwritten each request.
*/
export const SystemPart = z.object({
/** A name for the part, guessed from its own text — providers hand us an unlabelled array. */
name: z.string(),
chars: z.number(),
text: z.string(),
})
export type SystemPart = z.infer<typeof SystemPart>

export const SystemSnapshot = z.object({
v: z.literal(1),
/** When the provider was last sent this; the prompt can change between requests. */
ts: z.number(),
parts: z.array(SystemPart),
})
export type SystemSnapshot = z.infer<typeof SystemSnapshot>

export function parseJournalLine(line: string): JournalEntry | undefined {
const trimmed = line.trim()
if (!trimmed) return undefined
Expand Down
24 changes: 24 additions & 0 deletions src/core/lanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,30 @@ function spanOf(m: TranscriptMessage): number {
* tokens — `tokens` rides along only for the inspector.
*/

/**
* Chrome around the strip on a lane row: the fixed-width label on the left and the mode line
* on the right. Both are fixed width *by construction* — `laneCue` caps at three digits, and
* each mode label is the same length selected or not — so the strip can be sized as
* "everything else" without measuring per frame.
*
* They live here, and `LANE_CHROME` is derived from them rather than written down, because the
* width was a literal `61` that stayed `61` when dropping the "3 calls" mode shortened the
* mode line: the strip quietly gave up ~10 columns and stopped short of the right edge.
*/
export const laneLabel = (name: string, cue = "") => `│ ${name} ${cue}`.padEnd(LANE_LABEL_WIDTH)
export const LANE_LABEL_WIDTH = 12

export function laneModeLine(mode: LaneMode): string {
return `${mode === "duration" ? "[1] Duration" : " 1 duration"} · ${mode === "turns" ? "[2] Turns" : " 2 turns"} · 0 off`
}

export function laneSuffix(cue: string, mode: LaneMode): string {
return `${cue.padStart(4).padEnd(5)}${laneModeLine(mode)}`
}

/** Columns a lane row spends on anything that is not the strip. */
export const LANE_CHROME = LANE_LABEL_WIDTH + laneSuffix("", "turns").length

export type LaneEvent = {
lane: "input" | "model" | "tools"
/** `context` = compaction/branch summary: machine-written context, not the human prompting */
Expand Down
64 changes: 62 additions & 2 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* messages OpenCode sends to the model, in place. Tree-shaping entries it does
* not create itself (squash merges, labels, summaries) come from the TUI half.
*/
import crypto from "node:crypto"
import fs from "node:fs"
import path from "node:path"
import type { Plugin } from "@opencode-ai/plugin"
Expand All @@ -23,7 +24,54 @@ import { cacheShare, contextSizeOf, formatK, type MinimalMessage as MinimalToken
import { parseForkTitle } from "../core/adopt.js"
import { adoptNativeForks } from "../shared/adopt.js"

/**
* Name the parts of an unlabelled system prompt so the consumers view can say *which* part
* costs what — "my AGENTS.md is 4k" is a lever the user can actually pull, where OpenCode's
* base prompt is not.
*
* The heuristics are deliberately shallow and never throw: an unrecognised part is "system
* prompt" plus its index, which is still worth counting.
*/
function nameSystemPart(text: string, index: number): string {
const head = text.slice(0, 400).toLowerCase()
if (head.includes("agents.md")) return "AGENTS.md"
if (head.includes("claude.md")) return "CLAUDE.md"
if (/\bcontext notes:/.test(head)) return "context-tree note"
if (head.includes("<env>") || head.includes("working directory")) return "environment"
if (head.includes("today's date") || head.includes("current date")) return "date"
return index === 0 ? "base prompt" : `system prompt ${index + 1}`
}

/**
* Record the system parts for a session. Best effort in every direction: if the host ever hands
* us an empty array (we append to it, so it arrives carrying OpenCode's own parts — the debug
* line below is how to confirm that on a live server), nothing is written and every reader
* treats the absence as "unknown", never as "zero".
*/
/** Last shape written per session, so an unchanged prompt costs no filesystem write at all —
* this hook runs on every single request. */
const lastSystem = new Map<string, string>()

function captureSystem(store: JournalStore, sessionID: string, system: readonly string[]): void {
try {
const parts = system
.map((text, i) => ({ name: nameSystemPart(text, i), chars: text.length, text }))
.filter((p) => p.chars > 0)
// hash the text, not just its length: the `<env>` part's date rolls 2026-09-04 → 2026-09-05
// without changing a single character count, and a length-only key would serve that stale
// text from disk for the life of the process.
const shape = crypto.createHash("sha1").update(parts.map((p) => `${p.name}\n${p.text}`).join("\n\n")).digest("hex")
debug("system.captured", { sessionID, parts: parts.length, chars: parts.reduce((n, p) => n + p.chars, 0), unchanged: lastSystem.get(sessionID) === shape })
if (parts.length === 0 || lastSystem.get(sessionID) === shape) return
store.writeSystem(sessionID, { v: 1, ts: Date.now(), parts })
lastSystem.set(sessionID, shape)
} catch (e) {
debug("system.capture.failed", { error: e instanceof Error ? e.message : String(e) })
}
}

export const server: Plugin = async ({ worktree, client, directory }, options) => {
debug("server.loaded", { worktree, directory })
// same option parsing as the TUI half, so both write to the same place (docs/USAGE.md)
const mode: StorageMode = options?.["storage"] === "global" ? "global" : "local"
// awaiting an SDK call in the plugin factory deadlocks the server (plugin init blocks
Expand Down Expand Up @@ -255,9 +303,21 @@ export const server: Plugin = async ({ worktree, client, directory }, options) =
)
},

// DESIGN.md §6.8: a system note so the model reads ◆ / ✂ markers correctly
// DESIGN.md §6.8: a system note so the model reads ◆ / ✂ markers correctly, and §7.4:
// this is the one place the plugin can see what the provider is really sent as its system
// prompt, so it is also where the consumers view gets the bucket it was missing.
"experimental.chat.system.transform": async ({ sessionID }, output) => {
if (!sessionID || !(await journal).stateForSession(sessionID)) return
if (!sessionID) return
// Snapshot what OpenCode assembled, BEFORE our own note joins it — the note is ours, and
// counting it as part of the user's prompt would be a small lie in the one view that
// exists to say where the context went.
//
// Deliberately NOT gated on the session being in a tree, unlike the note below: a session
// is only registered by a branch/fork/adoption, so gating here would mean a plain session
// never captured its prompt and the consumers bucket silently never appeared — which is
// the common case, not the edge one.
captureSystem(await journal, sessionID, output.system)
if (!(await journal).stateForSession(sessionID)) return
output.system.push(
"Context notes: messages starting with ◆ are decision records confirmed by the user — treat them as settled facts. Tool results reading [cropped: …] or turns reading [dropped turn …] were removed from your context on purpose to save space; if you need one back, ask the user to restore it (they can with /undo in the context tree).",
)
Expand Down
35 changes: 34 additions & 1 deletion src/shared/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/
import fs from "node:fs"
import path from "node:path"
import { foldJournal, parseJournal, type JournalActor, type JournalEntry, type TreeState } from "../core/journal.js"
import { foldJournal, parseJournal, SystemSnapshot, type JournalActor, type JournalEntry, type TreeState } from "../core/journal.js"

export type StorageMode = "local" | "global"

Expand Down Expand Up @@ -54,6 +54,39 @@ export class JournalStore {
if (!fs.existsSync(gitignorePath)) fs.writeFileSync(gitignorePath, "*\n")
}

/**
* Where a session's system-prompt snapshot lives. One file per session, **overwritten** each
* request rather than appended: it is the current state of something outside the message
* tree, not a mutation with history, so it must not grow the way the journal does.
*/
private systemPath(sessionID: string): string {
// the id reaches us from the host and lands in a path we write: a `/` or `..` in it would
// escape baseDir, so anything but an id-shaped string is refused rather than sanitised
if (!/^[A-Za-z0-9_-]+$/.test(sessionID)) throw new Error(`refusing to use unsafe sessionID in a path: ${JSON.stringify(sessionID)}`)
return path.join(this.baseDir, `system-${sessionID}.json`)
}

/** Record the system parts the provider is actually being sent (server half, at request
* time). Best effort: a session whose prompt we never see simply has no snapshot, and every
* reader treats that as "unknown" rather than "zero". */
writeSystem(sessionID: string, snapshot: SystemSnapshot): void {
this.ensureDir()
const tmp = `${this.systemPath(sessionID)}.${process.pid}.${Date.now()}.tmp`
fs.writeFileSync(tmp, `${JSON.stringify(snapshot)}\n`)
fs.renameSync(tmp, this.systemPath(sessionID))
}

/** The last snapshot for a session, or undefined when we have never seen its prompt. */
readSystem(sessionID: string): SystemSnapshot | undefined {
try {
const raw = JSON.parse(fs.readFileSync(this.systemPath(sessionID), "utf8")) as unknown
const parsed = SystemSnapshot.safeParse(raw)
return parsed.success ? parsed.data : undefined
} catch {
return undefined
}
}

private journalPath(treeId: string): string {
return path.join(this.baseDir, `${treeId}.jsonl`)
}
Expand Down
Loading
Loading