Skip to content

Count the system prompt in the consumers view; let the lanes fill the terminal - #7

Merged
navbytes merged 8 commits into
mainfrom
claude/pi-context-tree-workflow-sjuutd
Sep 4, 2026
Merged

Count the system prompt in the consumers view; let the lanes fill the terminal#7
navbytes merged 8 commits into
mainfrom
claude/pi-context-tree-workflow-sjuutd

Conversation

@navbytes

@navbytes navbytes commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Two user-visible changes, plus the bug fix and the verification that had to happen before either could be trusted.

The consumers view counts the system prompt

s walked the transcript only, so its total could never be reconciled with the ctx … gauge two lines above it — that reads tokens.input, which does include the system prompt. On an agent with a large base prompt and an AGENTS.md, 5–15k was silently missing.

The server half now snapshots what the provider is really sent, in experimental.chat.system.transform, before appending its own note — counting our note as part of the user's prompt would be a small lie in the one view that exists to say where the context went. The view shows a ≡ system prompt bucket with one entry per part, so you can see that your rules file costs 4k. Not croppable; y copies a part in full. A session whose prompt we have not seen shows no bucket, never a misleading 0.

The lanes fill the terminal

They were capped at 80 cells and reserved a hardcoded 61 columns for chrome that prints 49, so a 200-column terminal left ~71 blank columns and even a 130-column one left 16. LANE_CHROME is now measured from the label and legend strings rather than written down — that is the fix for the bug class, not just the instance: shortening the legend now widens the strip instead of leaving a gap, which is exactly what went wrong when the · 3 calls mode was removed and the 61 stayed 61.

The bug that would have made the feature never appear

The capture was gated on stateForSession(sessionID), but a session is only registered by a branch/fork/adoption — so on a plain session that never branches (the common case) it would never have fired and the bucket would silently never have shown up. The capture is ungated now; the Context notes: push keeps its gate, since that note only makes sense for sessions the plugin manages.

What was verified, and what it settled

The branch arrived with the whole feature resting on an untested assumption: that experimental.chat.system.transform hands us output.system already carrying OpenCode's own parts. It does. A new e2e drives a real TUI on a plain, never-branched session and asserts the snapshot exists with real content, that our own note is not in it, and that the bucket reaches the screen.

That opened a worse question — nothing had ever tested that this hook fires at all, which would have meant the plugin's Context notes: had never been delivered and applyCrops had been shipping [cropped: …] stubs the model was never told how to read. It is delivered: a new server e2e asserts the note reaches the provider's system message on a branched session, and correctly does not on one outside the tree.

Review fixes (last commit)

  • y copied the wrong system part. Names are a substring heuristic, so two AGENTS.md parts share one; the lookup matched by name in original order while entries render sorted biggest-first, so picking the 4k part copied the 12k part's text. Entries carry their part index now.
  • Snapshots went stale. The change key was name:chars, so an equal-length edit never rewrote — the <env> date rolling 2026-09-042026-09-05 would serve yesterday's text for the life of the process. Hashed by content now.
  • Path guard. system-${sessionID}.json interpolated a host-supplied id into a path we write.
  • Snapshot read gated on the panel being open (it was a readFileSync per poll tick), pretty-printing dropped, four tests moved inside the describe they were indented into.

Tests

  • Unit: 265 pass, 0 fail. Typecheck and build clean.
  • e2e (CTREE_E2E=1, both files): 15 pass, 1 fail.

The one failure is crops the completed bash tool output, which is pre-existing on main — it fails deterministically 3/3 in a clean worktree at 2266966 with the same 30s timeout and ConnectionRefused. It is a probe of the OpenCode API through a scratch "spike" plugin, not of this plugin; the real crop path is covered by a passing test that asserts the provider sees the stub. Tracked separately.

Known gaps, deliberately left

  • Tool-definition schemas are still uncountedclient.tool.list gives descriptions, not what the provider receives, so the estimate would mislead. This is the last unattributed chunk between the consumers total and the ctx gauge.
  • Snapshot files are bounded per session, unbounded in session count. One small JSON per session that ever made a request, in the gitignored plugin dir.
  • Part naming is a shallow heuristic over the first 400 chars; a miss just reads system prompt N.

claude and others added 8 commits September 4, 2026 08:31
The strip stopped short of the right edge for two independent reasons in
one expression:

  const laneWidth = () => Math.max(10, Math.min(width() - 61, 80))

A hard cap of 80 cells, so past ~150 columns it stopped growing at all —
71 blank columns on a 200-column terminal, 111 on a 240. And a reserve
of 61 columns for chrome that actually prints 49, so even below the cap
there was a constant 16-column gap. Part of that reserve was stale: it
was sized for a mode legend that included "3 calls", and stayed 61 when
that mode was removed.

The strip is now width() + 2 - LANE_CHROME. The +2 is the "│ " a row
draws outside its own width, which a lane label carries inside its 12,
so the lane rows end on the same column as the rows and the status line
rather than two short. No ceiling: the strip is a window onto an
unbounded layout, so more cells is more events visible and less
scrolling, and laneOverview already hides the overview track once
nothing is off-screen.

LANE_CHROME is derived from the label and legend strings, which now live
in core/lanes.ts, instead of being written down — that is the actual fix
for this class of bug. Four tests pin the invariants it rests on: the
legend is the same width whichever mode is selected, the label column is
fixed width for every lane name with or without a cue, the suffix is
fixed width with or without a cue, and LANE_CHROME equals what a lane
row really spends off-strip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrwAnTfTmJSmFwVn3DDFxr
Consumers walked the transcript only, so it omitted the system prompt
and its total could not be reconciled with the `ctx …` gauge two lines
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 whole 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 system-<sessionID>.json. That file is overwritten each request,
never 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 renders as one `≡ system prompt` bucket with an entry per part,
croppable: false and a note, reusing the mechanism `(thinking)` already
had. `y` in the consumers panel copies the selected part in full — the
only way to read one, since it is not a message and so has no row and no
inspector of its own.

Absence means unknown, never zero: a session whose prompt we have not
seen yet has no bucket rather than a 0. The capture is defensive in
every direction and logs what it actually saw (debug "system.captured",
parts + chars), which is also how to confirm on a live server that
output.system arrives carrying OpenCode's own parts rather than empty —
the assumption the whole feature rests on, and one this container cannot
check because opencode serve will not bind here.

Tool-definition schemas remain uncounted: client.tool.list gives their
descriptions but not what the provider is really sent, so that estimate
would be rough enough to mislead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrwAnTfTmJSmFwVn3DDFxr
The capture was gated on the session already being in a tree, but a
session is only registered by a branch, a fork or adoption — so a plain
session never captured its prompt and the consumers bucket silently
never appeared. That is the common case, not the edge one.

The capture is now unconditional; only the "Context notes:" push keeps
the tree guard, since that note explains ◆/✂ markers that exist only in
managed sessions. Since this hook runs on every request, an in-memory
per-session shape check skips the write when nothing changed.

Adds the e2e that settles the assumption the feature rests on — that
output.system arrives carrying OpenCode's own parts rather than empty.
It drives the real TUI on a session that never branches and asserts the
snapshot exists with real content, that our own note is NOT in it (the
capture runs before the push), and that "≡ system prompt" reaches the
consumers view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrwAnTfTmJSmFwVn3DDFxr
The first run of this test failed with a bare scandir ENOENT on the
context-tree directory, which says only "it did not happen" — not
whether the hook never fired or fired with an empty output.system.

The test now runs the TUI with CTREE_DEBUG and, when the directory is
absent, fails with the capture's own log lines. That distinguishes the
two causes on the spot: a "system.captured" line with parts: 0 means the
array arrives empty, and no line at all means the hook does not fire in
this OpenCode version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrwAnTfTmJSmFwVn3DDFxr
"Did the server plugin load at all?" is the first question in any bug
report about it, and there was no way to answer it — every other debug
line sits behind a hook that only fires for sessions already in a tree,
so an empty log meant nothing.

No-op unless CTREE_DEBUG is set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrwAnTfTmJSmFwVn3DDFxr
…tles it

The system-prompt capture could not be verified in the web container:
its opencode never runs the plugin's server half at all, which was
established by reproducing the crop e2e failure on clean main in a
separate worktree. This file records that finding, marks 8adb027 as
do-not-merge until one command is run, and reads out the three possible
outcomes — including that a bad result would mean the plugin's existing
"Context notes:" system push has never been delivered.

Delete before merging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrwAnTfTmJSmFwVn3DDFxr
Three real defects found reviewing the branch before opening it.

`y` handed you the wrong text. `nameSystemPart` is a substring heuristic, so a
global and a project `AGENTS.md` both come back "AGENTS.md" — and the copy path
looked its part up *by name*, in original order, while the entries are shown
sorted biggest-first. Selecting the 4k part copied the 12k part, silently.
Entries now carry the index of the part they came from.

The "has this prompt changed?" key was `name:chars`, so a change of equal length
never rewrote the file: the `<env>` part's date rolling 2026-09-04 → 2026-09-05
is the same length and the same name, and the snapshot would serve yesterday's
text for the life of the server process. It hashes the content now.

`system-${sessionID}.json` put a host-supplied id straight into a path we write,
where a `/` or `..` escapes the plugin dir. Refused rather than sanitised.

Also: the snapshot read was an eager memo doing a readFileSync — a thrown and
caught ENOENT, in the common no-snapshot case — on every poll tick whether or
not the consumers panel was ever opened; it is gated on the panel now. Dropped
the pretty-printing that doubled every snapshot on disk. Four consumers tests
were indented as if inside their `describe` but sat outside it.

And the e2e that proves the capture end to end now asserts against the
pyte-rendered screen: the raw pty stream drops cells from an incrementally
repainted panel, so it read `≡ yem prompt` and failed a working feature.
@navbytes
navbytes merged commit 6306adf into main Sep 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants