Goal. Bring the Pi-style context tree — and the pi-context-tree git-style
workflow (/branch, /merge, /crop, /undo, /panel) — to OpenCode, and
fold a DeepSeek-Harness-style trajectory view (timeline lanes, per-step cost and
timing, inspector) into the same screen.
Status. Research + design. Nothing is implemented yet. Every OpenCode fact
below was verified against @opencode-ai/plugin / @opencode-ai/sdk 1.18.26 and
the OpenCode source at commit 69c172e (2026-09-01) unless marked [verify].
| # | Decision | Why |
|---|---|---|
| D1 | A branch is an OpenCode session. The tree is a tree of sessions linked by (parentSessionID, anchorMessageID). |
OpenCode has no in-session tree. session.fork(messageID) is native, every client (TUI, desktop, web) renders a branch as a normal session, and nothing in OpenCode's storage has to be reinterpreted. |
| D2 | Plugin state is an append-only journal (ctree.jsonl per tree) mirrored into session.metadata.ctree. OpenCode's own storage is never rewritten. |
Same "never mutate the source" invariant as pi-context-tree; the mirror lets the tree be rebuilt if the journal is lost and makes the linkage visible to other clients. |
| D3 | Crop is a per-request view, applied in experimental.chat.messages.transform from the journal. The transcript on screen keeps the originals; the model sees stubs. |
The hook is the only place a plugin can change what the LLM sees. It is ephemeral and in-place, so crops are reversible by construction. No "reconstruction block" compromise is needed (unlike Pi). |
| D4 | Merge = human-confirmed decision record written into the trunk session as a noReply user message tagged in part metadata. Squash / discard / tournament as in pi-context-tree. |
session.prompt({noReply:true}) persists a message without running the model. Tagging via TextPartInput.metadata lets the hook and the panel recognise records. |
| D5 | One full-screen route combines tree + trajectory. Rows are trajectory steps; a git-log-style gutter draws branches at their anchors; a lane minimap on top; an inspector on the right. | Both views are projections of the same event stream: the tree is the structure axis, the trajectory is the time / cost axis. One screen answers "where else could I be" and "what is this costing me" together. |
| D6 | Two halves, one package: a server plugin (hooks, headless /ctree commands, journal) and a TUI plugin (route, slots, dialogs, keymap). |
OpenCode runs plugins in two processes with different APIs. Display-only commands must live in the TUI half; context rewriting must live in the server half. |
| D7 | Undo is journal-driven. Every mutation records its anchor; /undo peels the last active one. |
Same semantics as Pi's /undo. Cheap because crops are views and branches are sessions that still exist. |
Non-goals for v1: cherry-picking raw messages between branches, a web UI, automatic
(un-invoked) cropping or squashing, rewriting OpenCode's SQLite directly, and
OpenCode v2 (opencode2/next) support beyond keeping the core layer portable.
You come from Pi, where:
- the session file is a tree (
id/parentIdper entry), the leaf is a pointer, and what the model sees is exactly the root→leaf path; /treemoves the leaf (nothing is copied or deleted); picking a user message means "redo this turn" (text pre-filled in the editor), picking anything else means "continue from here";pi-context-treeadds a git metaphor on top: a small trunk, side-work on branches,/mergesquashes a branch back as a ◆ decision record the human confirmed in$EDITOR,/cropstubs fat tool results,/undoreverts the last mutation,/panelshows per-node token cost, consumers, decisions, and a green→red health gauge with absolute bands (8k / 32k / 64k).
OpenCode's native model is different, and the design has to map one onto the other:
| Concept | Pi | OpenCode (verified) |
|---|---|---|
| History | one JSONL file, tree of entries | flat list of messages per session (SQLite message/part) |
| Branch | move the leaf pointer | POST /session/:id/fork {messageID} copies messages strictly before messageID into a new session. No parentID is set; metadata is cloned. |
| Undo | /undo moves the leaf |
/undo = session.revert (git snapshot + pending marker). Reverted messages are hard-deleted on the next prompt (revert.cleanup), so revert cannot be a tree primitive. |
| Compaction | marker on the linear chain | user message with a compaction part + assistant summary:true; context = everything after the last completed compaction (+ retained tail) |
| Pruning | extension context hook |
experimental.chat.messages.transform (in place, ephemeral, also runs during compaction), plus native state.time.compacted flag → [Old tool result content cleared] |
| Plugin UI | ctx.ui.custom(overlay) |
TUI plugin API: routes, dialogs, slots, keymap layers, reactive state |
So "the leaf" becomes "which session is open", and "a branch" becomes "a session whose origin we remember". Everything else in this document follows from that.
| What it does | Gap for us | |
|---|---|---|
| OpenCode built-ins | /fork (pick a user message → new session, prompt pre-filled), /undo /redo (revert with file restore), /timeline (<leader>g, rewind), /compact, native tool-output pruning after 40k tokens, session.metadata, noReply prompts, delete/patch part & message endpoints. |
No link from a fork to its origin; no tree UI; revert deletes; no merge; pruning is automatic and coarse; no per-step cost view. |
@ishaksebsib/opencode-tree 0.4.2 ("Pi-style /tree", MIT) |
TUI-only route /tree; registry + snapshot files recording parentSessionId / anchorMessageId; select a message → session.fork, optional Pi-style branch summary generated in a throw-away helper session and injected with noReply; Pi keybinds (j/k, h/l, shift-jump). |
No crop, merge, undo, labels, filters, token costs, gauge, inspector; no server half so it cannot change what the LLM sees. Good reference for route/keymap code; ~640 kB bundled. |
DCP (@tarquinen/opencode-dcp 3.1.15, AGPL) |
Mature pruning pipeline in experimental.chat.messages.transform: dedupe, purge errors, compress tool, `/dcp sweep |
context |
| opencode-rewind / checkpoint | git commit-tree checkpoints of files on session.idle. |
Files only. Complementary (see §9). |
| Other agents | Claude Code /rewind + "summarize from/up to here"; Codex /fork, /side; Amp handoff; Cursor/Zed file-only checkpoints; Claude Code issue #32631 proposes /tree /switch /merge --summary. |
Nobody ships human-confirmed merge or per-node cost; the decision-record merge is still unique to pi-context-tree. |
Conclusion. Build a new plugin, borrow the route/keymap scaffolding pattern from
opencode-tree and the hook mechanics from DCP, and port the semantics from
pi-context-tree (whose core layer is already pure and portable).
export default { id: "opencode-context-tree", server: async ({ client, directory, worktree }, options) => ({
"experimental.chat.messages.transform": async (_input, output) => { /* mutate output.messages IN PLACE */ },
"experimental.chat.system.transform": async ({ sessionID, model }, out) => { /* add one paragraph about ◆/✂ markers */ },
"experimental.session.compacting": async ({ sessionID }, out) => { out.context.push(/* decisions on path */) },
"command.execute.before": async ({ command, sessionID, arguments: args }, out) => { /* headless /ctree … */ },
"chat.message": async (input, out) => { /* branch model override (verified M0) */ },
config: async (cfg) => { cfg.command["ctree"] = { template: "", description: "…" } /* DCP pattern */ },
event: async ({ event }) => { /* session.deleted, message.removed, session.compacted → journal upkeep */ },
})}Facts that constrain the design (from source):
messages.transforminput is{}. Derive the session fromoutput.messages[0].info.sessionID. It also fires during compaction on a clone of the head (so stubs flow into the summary — desirable) and for subagent sessions (gate on the journal: only sessions we know about).- The array is consumed by
toModelMessagesby reference. Reassigningoutput.messagesdoes nothing;splice,length = 0,push, and editingpart.state.outputwork. lastUserand tool availability are computed before the hook: never drop the last user message; never orphan a tool call from its result.- Text parts with
ignored: trueare skipped bytoModelMessages; tool parts withstate.time.compactedrender as[Old tool result content cleared]. Both are settable throughPATCH /session/:id/message/:mid/part/:pid(upsert of a fullPart). Nothing can rewrite message info. session.prompt({ noReply: true, parts: [{ type: "text", text, metadata }] })stores a user message and returns without running the model.TextPartInputhassynthetic?,ignored?,metadata?: Record<string, any>.session.fork({ sessionID, messageID? })→ new session titled"<title> (fork #N)", messagesslice(0, indexOf(messageID))with fresh IDs,metadatadeep-copied,parentIDnot set (that field means "subagent child" and would put the branch in the TUI's child-session navigation — we do not want that).DELETE /session/:id/message/:midrefuses with 409 while the session is busy.- Compaction:
experimental.session.compactingcan append context or replace the prompt;experimental.compaction.autocontinuecan suppress the synthetic "Continue…" turn. - Server plugins are declared in
opencode.json"plugin": ["pkg", ["pkg", {…}]]or dropped into.opencode/plugins/.
export default { id: "opencode-context-tree", tui: async (api, options, meta) => {
api.keymap.registerLayer({ commands: [{ namespace: "palette", name: "ctree.open", title: "Context tree",
category: "Context", slashName: "tree", slashAliases: ["panel"], run: () => api.route.navigate("ctree", {…}) }],
bindings: [{ key: "ctrl+q", cmd: "ctree.open" }] })
api.route.register([{ name: "ctree", render: ({ params }) => <TreeRoute … /> }])
api.slots.register({ slots: { session_prompt_right: GaugeSlot, sidebar_content: BranchCard } })
}}api.ui:dialog.replace/clear/setSize,DialogSelect,DialogPrompt,DialogConfirm,toast.api.state.session.messages(id)/api.state.part(mid)are reactive.api.event.on(type, fn).api.kvpersists small UI state.api.clientis the v2 SDK (client.session.fork({ sessionID, messageID })).api.rendereris theCliRenderer, so the plugin can do exactly what OpenCode's ownopenEditor()does:renderer.suspend(), spawn$VISUAL || $EDITORon a temp file,renderer.resume(). That is the merge editor gate.- Slots available to plugins:
session_prompt_right,sidebar_title/content/footer,app_bottom,home_*. - Declared only in
tui.json"plugin": ["pkg", ["pkg", {…}], "./relative/file.js"](global,OPENCODE_TUI_CONFIG, project,.opencode/tui.json); installed withopencode plugin <pkg> [--global]. The TUI never scans.opencode/plugins/and the server glob is*.{ts,js}only, so a package ships two entry points (./server,./tui) and needs one line inopencode.jsonand one intui.json(verified in M0, seedocs/M0.md). Peer deps@opentui/{core,keymap,solid}; JSX must be compiled with@opentui/solid/bun-pluginandsolid-js/@opentui/*left external, because the host provides them (bundlingsolid-jspulls its server build and breaks). - Known bug to design around: DialogSelect
onSelecton Enter (anomalyco/opencode #22610) — use our own list component inside the route, notDialogSelect, for the main tree.
AssistantMessage.tokens.{input,output,reasoning,cache}andcostper assistant turn;step-finishparts carry per-steptokensandcost.tokens.inputof the latest assistant turn is the real context size at that turn — that is the gauge and the Input lane.toolparts:state.time.{start,end}→ duration;state.input/output/title;callID;toolname.text/reasoningparts:time.{start,end}.- Everything after the last assistant turn that produced output tokens is estimated at chars/4 and shown with
~.
One JSONL file per tree, ctree/<treeId>.jsonl, plus registry.json mapping
sessionID → treeId. Location (option storage, default local):
local → <worktree>/.opencode/context-tree/ (gitignored by default via a generated
.gitignore inside it; commit it deliberately if teammates should see decisions and
branch history), or global → <opencode state dir>/plugins/opencode-context-tree/
(Linux ~/.local/state/opencode). Every line:
type |
data |
Meaning |
|---|---|---|
tree.created |
{ rootSessionID } |
first time a session is touched by the plugin |
branch.opened |
{ sessionID, parentSessionID, anchorMessageID, name?, trunkModel?, branchModel?, kind: "explicit" | "jump" | "redo" | "native" } |
a fork we made (via /branch, or by jumping in the tree), or one of OpenCode's own /fork sessions adopted by matching its copied message prefix (core/adopt.ts) |
branch.closed |
{ sessionID, status: "squashed" | "rejected" | "discarded" | "abandoned", decisionMessageID?, note? } |
/merge result or undo of /branch |
summary.recorded |
{ sessionID, messageID, fromSessionID, fromMessageID } |
a Pi-style auto summary injected on jump (◇, unreviewed) |
decision.recorded |
{ sessionID, messageID, forkSessionID, branchName, siblings: [{ name, reason }] } |
the ◆ record message we wrote into the trunk |
crop.applied |
{ sessionID, mode: "result" | "turn", targets: [{ messageID, partID?, callID?, tool?, estTokens, sha8 }], anchorMessageID } |
what to stub / drop, by stable IDs |
crop.restored |
{ cropID } |
undo of a crop |
label.set |
{ sessionID, messageID, label: string | null } |
bookmark |
session.forgotten |
{ sessionID } |
session deleted in OpenCode; keep the edge for the tree drawing (rendered greyed) |
Rules: never edit a line; derive state by folding the file; the fold is a pure
function in core and is unit-tested with fixtures (same approach as
pi-context-tree's SessionBuilder goldens).
On every branch.opened/closed we PATCH /session/:id { metadata: { ctree: { treeId, parentSessionID, anchorMessageID, name, status } } }. session.fork deep-copies
metadata, so a fork of a branch inherits treeId automatically (we then overwrite
parentSessionID/anchor). If the journal is missing, the tree can be reconstructed
from session.list() + metadata (lossy: crops and labels are journal-only).
A ◆ record is a real user message created with session.prompt({ noReply: true, parts: [{ type: "text", text: "◆ Decision: <branch>\n…", metadata: { ctree: { kind: "decision", forkSessionID, branchName } } }] }). It is visible in the transcript as a user message
(the TUI cannot render custom message types; the ◆ header and markdown make it
scannable), is part of the LLM context, and survives compaction because
experimental.session.compacting re-injects all decision records on the path into the
compaction prompt.
For a session S: the path is S's own messages, preceded by parent's messages up
to the anchor, recursively. Because session.fork copies the prefix, S already
contains its inherited messages; the journal only needs the anchor to draw the
branch point and to attribute cost. The LLM context of S is therefore exactly what
OpenCode computes for S (after compaction filtering), minus our crops. No
cross-session assembly is ever needed for the model — only for the picture.
| Command | Runs in | Also as | What it does |
|---|---|---|---|
/tree (alias /panel, Ctrl+Q) |
TUI | palette "Context tree" | opens the combined tree + trajectory route (§7) |
/branch (prompts for name, then model picker) |
TUI | /ctree branch <name> [model] (server, headless) |
label the current point and fork here; optionally switch the branch to a cheaper model. TUI slash commands cannot take arguments (M0), hence the dialogs. |
/merge [--pick | --no-llm | --discard [note] | --tournament] |
TUI (needs the editor gate) | /ctree merge --discard only, headless |
close the nearest open branch containing the current session |
/crop [--top | --auto [--apply] | --dry-run | --min-tokens N | --older-than N | --keep glob] |
TUI (interactive) / server (--auto --apply, --top with confirm) |
/ctree crop … |
stub fat results or drop whole Q&A turns, as a view |
/undo |
TUI | /ctree undo |
revert the last active mutation (branch / merge / crop) |
/decisions [--export path] |
TUI | /ctree decisions [--export [path]] (default ./ctree-decisions.md) |
decisions view / markdown export |
/label [text] |
TUI | — | bookmark the selected (or last) message |
The gauge is not command-driven: it renders in the session_prompt_right TUI slot
(always on); an earlier /gauge bar\|off placement command was dropped in M6.
Rationale for the split: server-side custom commands always run a model turn
(command.execute.before cannot suppress it and throwing crashes the TUI), so
display-only commands must be TUI commands. Server /ctree … variants exist for the
desktop/web clients and scripts; they answer through a noReply message or toast.
Keys (all rebindable through plugin options, like opencode-tree does; the
built-in tui.json keybinds table only covers OpenCode's own action names):
- open:
ctrl+q(matchespi-context-tree) —<leader>tsuggested in README because many terminals eatctrl+q. - inside the route:
↑↓/j kmove ·gg/Gtop/bottom ·ctrl+f/ctrl+bpage ·ctrl+d/ctrl+uhalf page ·H M Lscreen top/middle/bottom ·{ }turn rows ·[[ ]]branch rows ·←→/h l/Tabfold branch ·za zo zc zr zm zj zkfold turns ·⏎go here ·gbbranch ·gmmerge ·ccrop mark ·tresult⇄turn ·aauto-mark ·uundo ·i/Iinspector ·gsconsumers ·gddecisions ·geexport ·mlabel (mark) ·/ n Nsearch ·gffilter ·g1 g2 g0lane x-axis ·ycopy ·?help ·q/escback.
Vim alignment. The keymap should read native to a vim user, so a key means here what it
means there. Already true: j k, ctrl+d/ctrl+u, ctrl+f/ctrl+b, gg, G, { }
(vim's paragraph motion, mapped onto turns — the unit the strip already rules), [[ ]],
/ n N, y, u. Turn folds take vim's fold vocabulary whole rather than inventing one:
za toggle, zo/zc open/close, zr/zm open-all/close-all, zj/zk between folds
(h/l/Tab stay as tree-explorer aliases, and l/→ also opens a folded turn, as
vim's foldopen=…,hor does). Two deliberate exceptions: ? is help, not
reverse search (/ with N covers that, and ? is universal in TUIs), and q/esc is back.
The realignment (shipped as its own change, since it moves keys people had in their fingers;
the CHANGELOG carries the same table and the keybinds config that restores the old
spellings):
| now | vim's meaning | becomes |
|---|---|---|
J K jump 20 |
join / keyword lookup | dropped — ctrl+f/ctrl+b and } cover it |
x undo alias |
delete a character | dropped; u stays |
0 1 2 lanes |
digits are counts | g0 g1 g2, leaving bare digits free |
L label |
bottom of the window | m (vim's set mark: a label is a bookmark), freeing H M L |
m merge |
set mark | gm |
e toggle fold |
end of word | dropped; Tab and za cover it |
f F filter |
find character in line | gf; filter_prev keeps the command but loses its default key |
The pattern behind the right-hand column is vim's own answer for verbs the language lacks:
put them behind g, the way LSP plugins do (gd, gr, gi) — gb branch, gm merge,
gs consumers, gd decisions, ge export — which frees the bare letters for real vim
meanings. Lowercase throughout: this host's binding parser does not match a shifted second
stroke, verified against the real TUI (test/e2e/tui.test.ts drives gs/gd alongside gg
to pin that the sequence tree branches at all). Freeing L is what lets H M L mean the
top, middle and bottom of the screen, as they do in vim.
Each flow: what you do → what you see → what happened → what the model sees on the next turn → how to undo.
/tree. The route opens on the current session, cursor on your last message, the
active path expanded, sibling branches folded to one row each at their anchor point.
The minimap shows the whole active path; the status line shows
ctx 46k/200k · filling ▲ +24% (bash) (same gauge as the prompt slot). Nothing
happens to the session. q returns to the chat exactly as it was.
Select any row, press ⏎. What the row is decides the move, exactly as Pi's
agent-session.ts#navigateTree decides it from the entry type:
- Row is the tip of a branch (its last message) → switch to that session
(
route.navigate("session")). No fork. This is Pi's "move the leaf to an existing leaf". - Row is a user message in the middle →
session.fork({ messageID })(copies everything before it) →branch.opened{kind:"redo"}→ open the new session → the user text is pre-filled in the prompt (tui.appendPrompt). Pi does the same thing by moving the leaf to the message's parent and putting its text in the editor. - Row is an assistant/tool step → fork at the next message (so the step is included) with an empty prompt: "continue from here".
- Row is a branch header → same as its tip.
One question, three answers. ⏎ opens Pi's tree-selector question, and that question
is the confirmation — there is no separate yes/no step:
┌ Fork & prefill this turn? ───────────────────────────────────────────────┐
│ No summary start clean · nothing carried over│
│ Summarize everything below this point carry the 3 turns · ~14k over… │
│ Summarize with a custom prompt the same, with your own focus │
└──────────────────────────────────────────────────────────────────────────┘
(A **switch** to another branch has no picked point, so its middle answer reads
"Summarize what you are leaving"; everything else is the same.)
Pi's order and Pi's escape hatches: esc on the choices puts you back on the same row with
nothing done, and cancelling the custom-prompt editor loops back to the three choices rather
than quietly meaning "no summary" (interactive-mode.ts#showTreeSelector). Option
jumpSummary, default "ask"; "never" (the pure pi-context-tree stance) degrades it to
a plain confirm, as does a jump with nothing below the selected point to summarize.
What "everything below that point" is. Pi collects the entries from the old leaf back to
the common ancestor with the target and summarizes those. We compute the same set across
sessions: both sides are reduced to their spine (core/tree.ts#spineOf) — the ordered
sessionID:messageID path from the root, where an ancestor's copied prefix keeps the
ancestor's own IDs — the deepest entry present in both is the common ancestor, and everything
after it in the current session is the abandoned tail (core/actions.ts#abandonedTail, unit
tested). A fork plan cuts the target spine before its boundary, because session.fork
copies messages strictly before it. So redoing trunk turn 2 summarizes turns 2–3; switching
from a branch to a sibling summarizes the branch's own turns and not the shared trunk.
Saying so while it happens. Drafting a summary is the one thing in the plugin that waits
on a model, and a wait nobody narrates reads as a hang — you press ⏎, answer the question,
and the tree sits there. Every step that waits on the server therefore reports through
ActionContext.progress (core/progress.ts formats it, tui/actions.ts#progressReporter
feeds it), and the tree's status line redraws it on a 120 ms interval:
⠹ summarizing 3 turns · ~14k · Progress · 1.2k chars · 4s · esc cancels
— the stage in the dialog's own words, then the draft as it streams
(message.part.updated on the helper session, reduced to the section the model is on and how
much it has written), then an elapsed counter, then the way out. The counter and the spinner
are what separate "still working" from "stuck": neither the label nor the section changes for
seconds at a time. The stages are the same for a merge (reading ⎇ x → drafting the ◆ record → writing the ◆ record into trunk), and a flow started from the palette, where there
is no status line to redraw, gets one toast for the stage that waits on the model and silence
for the sub-second ones.
Order of operations, matching navigateTree: abort a streaming response first
(session.abort, Pi #7022, so the summary covers the reply as it actually ended) → draft the
summary while nothing has moved yet → fork or switch → inject. Drafting first is what makes
esc meaningful: it aborts the helper session's reply and the whole jump, leaving you on the
row you started from. A summary that fails (rather than being aborted) never blocks the
move — we say so and go anyway, because the alternative is stranding you on the session you
asked to leave.
Summarize generates the Pi-format branch summary (Goal / Constraints / Progress / Key
decisions / Next steps) in a throw-away helper session, deletes it, and injects the text into
the destination session with session.prompt({ noReply: true }) prefixed by "The user
explored a different conversation branch before returning here", tagged
metadata.ctree.kind = "summary" — Pi likewise attaches its branch_summary entry at the new
leaf, not on the branch it left. The summary is journalled (summary.recorded) so /undo can
hide it and the decisions view can distinguish ◆ confirmed records from ◇ auto summaries.
/merge remains the reviewed path; a summary is never written when a merge closes the branch.
The old branch is untouched and stays visible. Undo: x closes the jump branch as
abandoned and returns to where you were.
From the chat or the tree. Result: a new session titled ⎇ fix-flaky-test forked at
the current tip, journal branch.opened{kind:"explicit", name, trunkModel, branchModel},
metadata mirrored, TUI switched to it, sidebar card shows ⎇ fix-flaky-test · open · from "Fix flaky test" @ msg 12. The trunk session gets a label.set at the anchor so
the branch point is a named checkpoint in the tree.
Model switch: the TUI's model picker is per-TUI state, not per-session; the branch
model is applied by the server half in chat.message by overriding
output.message.model for sessions whose journal entry has branchModel. Verified in
M0: the hook's message is persisted before the loop reads it, and the provider request
used the overridden model.
Precondition: the current session is an open branch (or contains one on its path).
- Draft. Collect the branch transcript (messages after the anchor, tool outputs
truncated to 2,000 chars, decisions kept verbatim) and ask the branch model for a
decision record using the
pi-context-treetemplate (Outcome / Why / Assumptions / Changes / Gotchas / Open questions / Confidence / Rejected alternatives). Done in a throw-away helper session withsystem:override and a hardmaxOutputTokens, then deleted — the same trickopencode-treeuses, so no provider keys are needed. - Gate.
renderer.suspend()→$EDITORon the draft (exactly OpenCode'sopenEditor()), or the in-route textarea when no$EDITOR. Save = confirm; empty file or non-zero exit = abort everything.--no-llmskips step 1 and opens the empty template.rredrafts. - Land. Switch to the parent session; write the record as a
noReplyuser message withmetadata.ctree.kind = "decision"; appenddecision.recordedandbranch.closed{status:"squashed"}; mirror metadata (status: "squashed"); optionally archive the branch session (time.archived, optionarchiveOnMerge, default off so it stays in/sessions). Toast:◆ merged fix-flaky-test → 0.9k tokens added to trunk. - Discard skips 1–2, lands
branch.closed{status:"rejected", note}; nothing is written into the trunk. Tournament requires open siblings with the same(parentSessionID, anchorMessageID): the current branch wins, each loser gets a one-line drafted epitaph in the same record, all siblings are closed at once.
What the model sees next turn in the trunk: its own history + one ◆ message. The
noisy branch turns are in another session and never enter the trunk context. Undo:
x → branch.closed is superseded by a new branch.opened (re-open), the ◆ message is
hidden by the transform hook (ignored via journal) or deleted with
session.deleteMessage if the trunk is idle (option undoDeletesRecord, default
hide), and the TUI switches back to the branch tip.
Interactive (default): the route enters crop mode: rows show their estimated
token cost, space marks a tool result (result mode) or a whole Q&A turn (t → turn
mode: the user message plus every assistant/tool step until the next user message),
a pre-marks by rules (≥ minTokens 10k, older than olderThan 2 turns, never the
latest result per tool, never keep globs, never decision records, never the current
turn), a running total shows ~19.4k reclaimed, ⏎ applies with a confirm.
/crop --top skips the panel: ✂ bash "bun test …" ~4.7k → crop? [y/N].
Apply = one crop.applied line. No OpenCode data changes. From then on, in
messages.transform for that session:
- result mode:
part.state.output = "[cropped: <tool> <arg>, ~4.7k tokens, sha8 3f9a1c2e]"(input args kept so the call/result pair stays valid); - turn mode: the user message and its assistant/tool messages are
spliced out and replaced by one synthetic user message[dropped turn — 7 steps, ~12k tokens, recoverable: sha8]at the anchor, so user/assistant alternation is preserved.
The transcript on screen keeps the full originals (the TUI cannot annotate messages
it did not create); instead the sidebar card lists active crops (✂ 3 results, 1 turn · ~31k hidden from model) and the tree route shows ✂ on cropped rows. A one-line
paragraph in system.transform tells the model what [cropped: …] means and that it
can ask the user to restore.
Hard crop (option, off by default): additionally PATCH the tool part with
state.time.compacted = now so OpenCode itself renders [Old tool result content cleared] and hides it in the TUI. Still reversible (clear the flag), but it touches
OpenCode storage, so it stays opt-in. Deleting messages (deleteMessage) is offered
only behind an explicit --purge and is not undoable.
Undo: x → crop.restored{cropID}; the next turn sends the originals again.
Finds the most recent journal mutation still active on the current session's path
(branch.opened without close, branch.closed, crop.applied without restore) and
reverts it as described in 6.2–6.5, with a confirm naming what will happen
(↶ re-open branch fix-flaky-test (squashed 3 min ago)?). Run again to peel further.
Revised after the 0.1.1 UX review. The bands are relative to the model's context window when OpenCode knows it (<25% low · <60% healthy · <85% filling · else red), with the absolute 8k / 32k / 64k bands only as the fallback: 30k is "healthy" on a 200k model and one prompt from compaction on a 32k one. Every surface (prompt gauge, tree header, sidebar card) shows the same string,
ctx ▓▓░░░ ~2.3k/32.8k · low, from one helper (formatContext).
session_prompt_right slot: ctx 46k/200k ▓▓▓▓░░ filling ▲+24% (bash) coloured by
band relative to the model limit (<25% · <60% · <85% · red; absolute <8k · 8–32k · 32–64k · ≥64k
when the limit is unknown), from the last assistant reply with output tokens:
input + output + reasoning + cache.read + cache.write, OpenCode's own sidebar rule
(+ chars/4 for anything newer, shown as ~), then · 95% cached: the share of that prompt
served from the provider's cache, shown once the provider has reported cache tokens in the
session (so 0% cached after a crop, merge or fork means the cache was reset), with the bar's
filled cells split dim-cached / bright-fresh; attribution = the biggest new part since
the last turn. One-time toast when entering red; a separate warning when within the
compaction reserve (model.limit.context − compaction.reserved), because OpenCode's
auto-compaction is the lossy event the user wants to pre-empt with /crop or
/merge. sidebar_content slot: ⎇ fix-flaky-test · open · parent "Fix flaky test",
active crops, decisions on path, [/tree].
Which run takes which colour (0.3.0-beta.3, core/gauge.ts#gaugeRoles). Through
0.3.0-beta.2 the whole gauge was one band-coloured run — ctx, the bar, and
84.7k/1M · low all in success green — which is legible on a dark theme by luck and was
reported illegible on a light one. A theme guarantees text and textMuted are readable on
its own background; it guarantees nothing of the kind about success / warning / error,
which it picks to be distinguishable from each other. So the rule is: the bar carries the
band and readable text never does. ctx is textMuted, the numbers and the band word are
text, the bar's cached cells are textMuted, its fresh cells the band colour, its empty
cells borderSubtle, and · 95% cached is textMuted. A filled bar survives a lower
contrast ratio than glyph strokes do, and its colour is the signal rather than a decoration
on text that says the same thing anyway.
The same error was next to the gauge in three other places, all fixed the same way — the
glyph carries the colour, the label beside it is read: the prompt slot's ⎇ <branch> ·
(band-coloured, which was also a category error — which branch you are on has nothing to do
with the context band), its ▲ +24% (bash) trend, and the sidebar card's ⎇ <branch> and
✂ 2 crops · ~14k hidden. test/gauge.test.ts holds the table to the rule and greps both
components for a readable string inside a band-coloured element.
The cursor's own prompt figure. The tree's status line carries, right-aligned directly under
the header gauge, what the provider was really sent at the row you are on:
T2 reply · prompt 43.7k · 30.1k cached. It sums input + cache.read + cache.write exactly as
contextSizeOf does, so the two numbers stack in one column and are read against each other —
the gauge is now, this is the cursor, the gap is everything after that point. Because it is
tokens.input, it inherently includes the system prompt and the tool definitions, which the
per-row token column (a marginal, chars/4 estimate) never does.
core/tree.ts#promptAtRow resolves it: an assistant step carries its own message's report on
every one of its rows; a user turn takes the first assistant message after it, the reply whose
prompt was the first to include that turn; a branch header has none, its column already being a
subtree total. Nothing sent yet — a trailing turn, a reply in flight — reads not sent yet
rather than a zero. It is deliberately not a second per-row column: it is history (an older
row's figure is what went out then, not what a later crop would send now), and one number the
user is deliberately inspecting can carry that caveat where forty scrolling ones cannot. Dropped
whole, not wrapped, when the terminal is too narrow (§7.6).
- The transform hook runs during compaction too, so cropped results are already stubs in the summary input — the summary cannot resurrect them.
experimental.session.compacting: push every ◆ record on the path intooutput.contextso decisions survive the summary verbatim.- After a compaction, crops that target messages before the compaction boundary are
moot; the panel greys them out and
/undoskips them. /compactitself gets the same philosophy warning as inpi-context-tree, never a block.
Sessions with parentID (task/subagent children) are never treated as branches; in
the trajectory they appear as nested rows under their subtask part, DSH-style. The
desktop/web clients see branches as ordinary sessions with a ⎇ name title, decision
records as user messages, and can use the headless /ctree commands.
Revised after the 0.1.1 UX review. The lanes are an event strip: one pill per event on one shared axis across Input / Model / Tools, one cell of gap between neighbours (three at a turn boundary, holding the
│rule), width proportional to duration in Duration mode, categorical colours (input green / context grey, model purple, tools orange, error red), the selected step inverted. Nothing is scaled by token count — height-as-magnitude produced flat or solid lanes on real sessions; tokens live in the row column. When the strip does not fit, the newest events are kept and the count dropped on the left is shown.src/core/lanes.tsbuildEventStrip.Windowing (0.2.2). The timeline is laid out unbounded (
layoutEventStrip) and the strip renders a window of it chosen bywindowFor: end of the session by default, unchanged while the cursor's event stays inside a scroll-off margin (width/8), shifted in chunks of width/3 when it nears an edge, clamped to the layout. Squashing the whole session into the width was rejected: cells would merge and the pills would stop being countable events. When the layout overflows, a one-line overview track under the lanes shows the window's position and red ticks at failed tool calls, so global orientation survives without giving up pill fidelity.Model switches (0.2.5). Colour on the Model lane is categorical by kind (text vs. reasoning), not by which model answered — recoloring per model would compete with that and with the error/warning colours other lanes already use. So a mid-session model change (an explicit switch, not a branch's fixed
--model) is marked structurally instead: the turn rule on the Model lane thickens to┃at the turn where the answering model differs from the last turn that had one. The inspector'sModelline (now populated for assistant turns and steps, from the message's ownproviderID/modelID) is the way to confirm which model that actually was.
On the DSH comparison. The three-lane split is ours. DSH's own ui-trajectory README
describes a single combined Overview ("A fixed Overview above the ledger projects real record
start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding")
above a vertical ledger of User/Assistant/Tool/Subtool records; a hands-on review describes
"input, model and tool lanes across the top", so the secondhand sources conflict and the
original research here (Appendix A) was a screenshot and write-ups, not the source. What we do
take from DSH directly is the event-pill idea, the always-available duration axis, and the turn
rule (§7.3).
DSH's Trajectory tab and Pi's /tree both render the same append-only event
stream. DSH orders it by time and annotates each step with cost and duration
(three lanes on top, an inspector on the right, role badges, turn markers). Pi orders
it by ancestry and shows alternatives. A git log has solved this exact problem
already: git log --graph --stat is a linear list (time axis) with a gutter that draws
the branch structure (ancestry axis). That is the layout:
- rows = trajectory steps of the active path (turn markers, role glyph, preview, tokens, duration, ⚠ for ≥10k, ✂ if cropped, ◆ for decisions);
- gutter = tree: at each anchor a branch row
⎇ name (status · turns · ~tokens)is drawn with├⎇/╰⎇(branches that are not on the active path — siblings, the trunk's continuation past your fork point — are listed at the bottom as┆⎇rows);e/→expands it inline (its steps appear indented under the anchor, drawn with│),←folds it. Only the active path is expanded by default, exactly like Pi orders the active branch first and DSH keeps one linear list; - minimap on top = DSH lanes for the active path:
Input(context size at each assistant turn — this is the gauge over time),Model(one block per assistant step, shaded by output tokens),Tools(one block per tool call, shaded by result size, red if error). Cursor position is mirrored in the lanes; - inspector on the right = DSH inspector: Summary / Payload / Result / Schema /
Timing / Crop for the selected row; toggled with
i; below 110 columns it becomes a full-screen view instead (this ispi-context-tree's inspect view) — implemented in 0.2.3, along withshift+ito ask for it on any width andPgUp/PgDnto page it.
┌ Context tree · Fix flaky test ⎇ fix-flaky-test (open · haiku-4.5) ctx 46k/200k ▓▓▓░░ filling ▲+24% (bash) ┐
│ Input ▁▁▂▂▂▃▃▃▄▄▄▅▅▅▆▆▆▆▇▇▇█ mode: [1] Duration [2] Turns [3] Calls / search f: default │
│ Model ▪ ▪ ▪ ▪ ▪ ▪ ▪ ▪ │
│ Tools ▪▪▪ ▪▪ ▪▪▪▪ ▪▪ ▪▪▪▪▪▪ ▪▪ ▪▪▪ │
├─────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────┤
│ T1 ● user Build yourself a tool that reads the… 1.2k │ ⚙ bash · T1 · step 3 [s]um [p]ay [r]es │
│ ○ assistant I'll start by understanding my env… 0.3k │ Status completed · 21 ms │
│ ⚙ bash pwd && echo "---DSH ENV---" → total 4240 0.4k │ Hierarchy T1 › assistant › step 3 │
│ › ⚙ bash ls -la ~/Documents/ → total 744 2.1k │ Payload {"command":"ls -la /Users/…","desc…"} │
│ ○ assistant Key findings: DSH_HOME=… 0.5k │ Result total 744 │
│ T2 ● user decompress the session and inspect… 0.2k │ drwx------@ 41 tn.shen staff 1312 Aug… │
│ ├⎇ try-redis squashed → ◆ T3 · 9 turns · ~22k │ Timing started 15:20:37.236 · 21 ms · session ts │
│ ╰⎇ fix-flaky-test open · 6 turns · ~14k · haiku-4.5 ← here │ Tokens ~2.1k (chars/4) · 4.6% of context │
│ │ ● user the test flakes on CI only… 0.2k │ Crop [c] stub result · [t] drop turn │
│ │ ⚙ bash bun test src/foo.test.ts → 3 failed ⚠ 4.7k │ Branch fix-flaky-test · anchor T2 · parent… │
│ │ ○ assistant The failures share a timing assump… 0.6k │ │
│ T3 ◆ decision Decision: try-redis — Outcome: keep in-… 0.9k │ │
│ T4 ● user now make it pass on CI 0.1k │ │
├─────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────┤
│ ⏎ go here b branch m merge c crop t result⇄turn a auto x undo i inspector u consumers D decisions L label q │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Reading it: T1/T2 are trunk turns; at T2 two branches were opened — try-redis was
squashed and its ◆ record is T3 on the trunk; fix-flaky-test is where you are, so it
is expanded under its anchor; the bun test result is 4.7k and flagged as a crop
candidate; the inspector shows the selected ls -la call with DSH's five facets.
Revised (0.2.3). There were three modes —
Duration | Turns | Calls— and Turns and Calls drew the same events in the same lanes, differing only by one blank cell at each turn boundary. Checking DSH settled it: its Trajectory Overview has no mode toggle at all. It is always duration-proportional ("projects real record start/duration timing from left to right"), marks turns with rules ("Thick rules mark Turn boundaries, compact inline markers identify Steps"), and reaches "just the tool calls" through zoom, drag-to-focus and search rather than a mode.
So the toggle now carries only what it can honestly carry — the x-scale — and the two other jobs move to the mechanisms that already existed:
| What it is | |
|---|---|
1 Duration |
cells proportional to wall clock (time.start/end), so a 3-minute bash is visibly wider than a 0.2 s read. DSH's only axis. |
2 Turns (default) |
one cell per event: an event count axis, where a busy turn is wide because it did a lot, not because it took long. |
| turn boundaries | drawn as a │ rule across all three lanes, in both modes — DSH's thick rule. Never a mode of its own, and never just a wider gap you have to measure. |
| which events | the row Filter (f, §7.5), not a mode. tools-only is the "what did I run" view and thins the rows and the lanes together; no-tools is its mirror; user-only leaves the prompts. |
core/lanes.ts#eventAllowed is that coupling, with two deliberate mismatches against the rows'
stepAllowed: labeled is an annotation on a row rather than a property of an event, so the
lanes read it as no filter; and reasoning stays on the Model lane under every filter, because
folding thinking into its assistant row is a reading convenience while the strip is a
timeline — a minute of thinking is a thing that happened.
What this deletes: the calls LaneMode, the 3 keybind, and the whole original
magnitude-column model (buildLanes, sparkline, fitColumns, durationWeighted,
columnFor) plus buildEventStrip, which the windowed layout superseded in 0.2.2. None of it
was reachable from the route; it survived only because its tests kept passing, which is exactly
how the Turns/Calls bug shipped.
- Consumers (
u): tokens by source —bash 31% ▰▰▰▰▰▰▰▰ · read 22% · assistant text 18% · decisions 3%—cjumps to crop with that source pre-marked. - Decisions (
D): ◆ cards (date, model, branch, ✓ human-confirmed, epitaphs);⏎jumps to the record row;/decisions --exportwrites markdown. - Crop mode (
c): the same list with checkboxes and a running reclaimed total; the inspector's Crop facet explains protection (latest per tool, current turn, decision,keepglob).
Reading a long field (0.2.3). The inspector used to cap each field at a fixed 8 / 10 / 14
lines whatever the terminal, ending in a dead … 61 more lines (y to copy); it had no scroll
state at all, and below 110 columns i flipped a flag that rendered nothing and said nothing.
Three changes, in the order they matter:
- The caps follow the pane. Every line is materialised (bounded by
INSPECTOR_MAX_LINESonly so a pathological payload cannot build an unbounded array per render) and the window is sized fromheight(), so a tall terminal simply shows more instead of leaving the pane half empty under a truncation notice. PgUp/PgDnpage it, with no focus mode:j/kmust keep driving the row selection, because that is what chooses the inspector's content, and a focus concept would add modality to a route that has none.core/navigation.ts#paneWindow/scrollPanehold the arithmetic (clamped in the getter, so a resize or a shorter row cannot strand the view past the end); the foot of the pane reads12–40 of 118 · PgUp/PgDn · y copy · I full. The offset resets when the selected row changes — new content, new top.shift+iis full screen, and is also whatidoes below 110 columns. A ~40-column pane is not a JSON viewer; this is the "view all of it" answer, and it retires the narrow-terminal dead end in the same mechanism.
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.
f cycles default → no-tools → user-only → labeled → all (default hides
step-start/finish, snapshot, patch, retry; no-tools hides tool rows;
labeled shows only L-labelled rows). / filters rows incrementally by role, tool
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.
Turn folds (core/fold.ts). A turn whose model ran six tools costs seven rows and one of
them is the skeleton you were skimming, so a turn collapses into its ● row carrying what it
swallowed: ● ▸6 T7 add a retry to the flaky test 1✗ 2⚠. Nothing escapes a fold.
Where the marker goes (0.3.0-beta.2). ▸6 sits between the glyph and the text, because
that is where an outline puts a disclosure control — and the first shipped version put a
digest after the preview instead, which failed twice over. The caret was nowhere near the ●
it belonged to, so on a screen of folded turns nothing marked a collapsed row until you had
read to the end of its (usually clipped) preview; and the digest carried a token figure that
was the row's own token column again, since applyFolds rolls the hidden steps' tokens into
the turn. Two numbers of the same magnitude a few columns apart, always equal, read as one
number that had gone wrong. So: the caret and the count on the left (foldMark), the flags at
the end of the text where a step row already draws its own (foldFlags), and the tokens once.
Posture. auto (the default) keeps the current turn of the path you are on open, so the
far scrollback compresses while the end you are working at stays in detail; zm folds every
turn, zr opens every one. Hand-folds (za, zo, zc) win over the posture and live in the
route, not in api.kv: your folds hold while the tree is open and every visit starts from the
same clean outline. Crop mode and an active search force everything open — crop marks live on
the step rows, and a search that hid its own matches would read as broken.
Why a post-pass, not a branch in the emitter. buildTreeView decides what exists (the
Filter); folding decides what is drawn now. Keeping them apart is what lets the event strip
stay complete while the rows collapse: layoutEventStrip is fed the transcript and the filter,
never these rows, so the Filter remains the only "which events" control (§7.3) and the fold
cannot gut the timeline it is supposed to complement. A folded turn's tokens roll into its row,
so the column still totals; its digest counts what the current filter would have shown, so
rows, digest and strip tell one story.
On the strip. Selecting a folded turn lights every event it swallowed, across all three lanes — one collapsed row here, that span of pills there, its errors still red. That is what makes "nothing escapes the fold" safe: the row list stays clean, the timeline keeps the evidence.
Keys. vim's fold vocabulary, since vim already has one: za toggle, zo/zc open/close,
zj/zk between folds, and zr/zm for all-open/all-folded. vim spells the last pair
zR/zM, but OpenCode's binding parser does not match a shifted second stroke (verified
against the real TUI in test/e2e/tui.test.ts), and with a single fold level vim's own
zr/zm — one level less/more folding — mean exactly the same thing here. h/l/Tab
stay branch folds, with one vim-faithful addition (0.3.0-beta.2): l/→ opens a folded
turn, because vim's foldopen default includes hor — a horizontal move opens the fold
under the cursor. Only opening. Nothing in vim closes a fold by moving, so h/← keep their
branch meaning and za/zc stay the way to close one; and on a turn row l was a no-op
before (at depth 0 there is no branch to expand, and deeper the branch is already shown),
which is what left the key free to mean this.
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.
Below 110 columns the inspector is hidden and i opens it full-screen; below 80 the
minimap collapses to a single Input sparkline line. Row layout always keeps
glyph · preview · tokens.
Chrome is dropped, not wrapped. Everything that is not the row's own content yields
when the terminal is too narrow for it, in this order: the cursor row's affordance hint
disappears first (it needs its own width plus four columns of gap, or it is not drawn), then
the footer drops verbs from the right until it fits — ? help survives, being the route to
everything it dropped. Nothing here ever wraps to a second line: a tree whose row count
changes with the terminal width cannot be navigated by eye.
The keymap is vim's, which is transparent if you know vim and opaque otherwise: nothing on
screen said that the ▸ on a folded turn opens with za. The selected row only carries a
right-aligned hint naming the one action it affords, in the key that is bound to it now
(core/help.ts#rowHint over keyLabel, so a keybinds override changes the hint rather than
making it lie): za open / za fold on a turn, → expand / ⏎ switch on a branch,
space crop on a croppable result, u restore on a cropped one.
One row and one action, deliberately:
- One row. A hint on every row would be a second column of noise on the thing you are
skimming; and because it is cursor chrome, the live search (which matches over the same
rendered line) never matches it —
rowLinetakes the hint as an argument that the search path does not pass. - One action. A row that lists four keys teaches none of them, so each row kind names its single most likely next move: a turn folds or opens, a branch expands or (if it is not the one you are on) switches, a tool result crops — or, if it is already cropped, restores.
- Nothing to say is nothing drawn. A row with no affordance, a command the user has
unbound, crop mode and the
?pane (both of which already own a key legend of their own) all render no hint at all.
The pane opens under the tree rather than over it, so the rows it explains stay on screen
— which makes the Legend a live reference rather than a memory test, and is why vertical
space here is genuinely scarce: every row is paid for out of the tree.
Salience. The pane's job is to let someone find one key without reading. That only works
if a key is drawn differently from the prose around it, so the pane is a list of typed
segments (key, name, label, heading, glyph, strong, text) that the route colours
individually — core/help.ts#helpSegments, route.tsx#helpColor. Keys are the brightest and
the only bold thing; headings are accent; prose is muted; the │ gutter is always dim.
Through 0.3.0-beta.2 this was exactly inverted — the route coloured whole lines, keyed on
whether the line was indented, so headings (which name no keys) were the one accent-coloured
thing and every key on the pane was muted.
Two layouts, and the split is the principle: tabulate what the user cannot guess.
Act and Views name operations with no analogue anywhere else, so they get a key column and
a name column and can be entered from either side — the keys if you think gm, the names if
you think "merge". Move and Legend keep packed clauses behind a dim static label: twenty
motion clauses tabulated would be twenty rows spent making vim's own keys the most prominent
thing on the pane, which is the wrong trade for this audience.
Column widths are measured per section, so one absurd rebind in Act cannot shift
Legend; past an 8-column key the row alone goes ragged rather than pushing its whole
section right, as :help does. Rejected: a two-column pane (at 100 columns each column gets
~46, and the teaching sentences are 66–81 — it would convert the pane into the key list it
deliberately is not, and is impossible at 80); a strict :help key column throughout (see
Move above); a separate Legend pane (modality, and it needs a key).
Width. The pane is laid out for the terminal it is on: cols - HELP_CHROME, where the 4
columns are the box's padding and the │ gutter. Clauses drop from the right and prose
clips with … — never wrapping, per §7.6 — so the same 34 rows survive from 100 columns down
to 44. Before this it was written for a fixed width and asserted against the raw terminal
number, so nine lines clipped at 100 columns with the test green.
Where am I. The footer names the section (12–29 of 34 · Act · PgUp/PgDn scroll · q/esc back), because on a 24-row terminal you see 12 of 34 rows and the headings scroll off.
Rejected: snapping PgUp/PgDn to section boundaries — Legend is 8 rows and would not fit a
short terminal's window, so snapping would sometimes strand you.
DSH is a web GUI with unlimited space and mouse; its Payload/Schema tabs show full
JSON. In the TUI, Payload is pretty-printed and truncated with y to copy the full
text to the clipboard, Schema shows the tool description only (from
client.tool.list), and there is no export button — /decisions --export and
OpenCode's own /export cover it.
packages/
core/ pure TS, zero OpenCode imports: journal fold, tree model, path/anchor math,
token estimator, crop planner (rules + protections), view-models
(tree rows, lanes, consumers, decisions) as (state, key) → state reducers
server/ @opencode-ai/plugin server half: hooks, /ctree headless commands,
journal IO (bun file watcher + mtime cache), metadata mirror, events
tui/ @opencode-ai/plugin/tui half: route, slots, dialogs, keymap layer,
editor gate, journal writes for user actions
package (root) "opencode-context-tree": exports { "./server", "./tui" } — one npm name,
listed once in opencode.json and once in tui.json
- Process boundary. The TUI and the server are separate processes with separate
plugin runtimes. They share state only through the journal file and
session.metadata. The TUI writes journal lines for user actions and patches metadata; the server re-reads the journal (mtime check, sub-millisecond) at everymessages.transformand onsession.updatedevents. No RPC is needed and either half works alone (server-only = headless crops and/ctree; TUI-only = tree without crops, with a "install the server half" hint). - Multiple TUIs on one server are fine: the journal is append-only and every
write is a whole line; a lock file guards
registry.jsonupdates. - Build. Bun +
bun buildfor two entry points; JSX@opentui/solid; peer deps asopencode-treedeclares them;engines.opencode ">=1.18".coreis plain TS, runnable in Node for tests. - Tests.
corewith fixtures (journal folds, crop plans, reducers as tables — ported frompi-context-tree's testkit);serveragainst a fakeoutput.messagesarray asserting in-place mutation invariants;tuisnapshot of rendered rows via opentui's test renderer [verify availability]; one end-to-end run againstopencode servewith a mock provider for fork/merge/crop goldens.
- Never remove or alter the last user message (OpenCode picked it before the hook).
- A
toolpart is stubbed by rewritingstate.outputonly;callID,state.inputand status are untouched, so call/result pairing survives. - A dropped turn removes user + all assistant/tool messages up to the next user message, then inserts exactly one synthetic user message, so roles still alternate.
- Never touch messages before the last completed compaction boundary (they are not in the array anyway) and never touch subagent sessions unless the journal knows them.
- Decision records (
metadata.ctree.kind === "decision") are never cropped. - The hook is idempotent: running it twice on the same array yields the same result.
| Situation | Behaviour |
|---|---|
| Jump/branch while the assistant is streaming | abort first (session.abort), toast, then fork. (Pi #7022 lesson.) |
| Fork of a session with a pending revert | OpenCode deletes reverted messages on the next prompt; we fork before that, so the fork contains them. Warn: "session has a pending undo; /redo first?" |
Session deleted in OpenCode (session.deleted event) |
session.forgotten line; the tree keeps the edge, drawn grey; its children re-anchor visually to the grandparent. |
| Journal lost | rebuild edges from session.list() + metadata.ctree; crops/labels are gone; toast once. |
| Compaction happened on the trunk | ◆ records re-injected via session.compacting; crops before the boundary marked moot. |
| Merge draft fails (model error, abort) | nothing written; helper session deleted; toast with the error. |
| Editor exits non-zero or file empty | abort; branch stays open. |
| Server half missing | tree/branch/merge/undo work; /crop shows "needs server plugin" with the config snippet. |
| Hook fires for a subagent session | journal has no entry → no-op. |
| Two branches named the same | allowed; disambiguated by anchor in the UI, --tournament uses (parent, anchor) not names. |
| Very long sessions (>2k messages) | session.messages is paged (limit/before); the route loads the active session fully and other branches lazily on expand (as opencode-tree does). |
OpenCode v2 (opencode2, next) |
TUI plugin loading from tui.json is currently broken there (#36525); v2 adds session.context, staged reverts, SessionMessageCompaction. Keep core free of SDK types and put the v1↔v2 mapping in server/tui adapters. |
"Toast" above means OpenCode's toast when the action runs from the palette or after the route has navigated away; inside /tree the same message goes to the route's status-line notice, because toasts raised while the route is mounted never render.
| Capability | Pi /tree |
pi-context-tree |
OpenCode built-in | @ishaksebsib/opencode-tree |
this plugin |
|---|---|---|---|---|---|
| Tree of branches | in-file | in-file | none (flat forks) | tree of sessions | tree of sessions + trajectory |
| Jump to any node | ✓ | ✓ | /fork (user msgs only) |
✓ | ✓ (fork or switch) |
| Named branch, model per branch | ✗ / ✗ | ✓ / ✓ | ✗ | ✗ | ✓ / ✓ |
| Merge as human-confirmed record | ✗ (auto summary) | ✓ squash/discard/tournament | ✗ | ✗ (auto summary) | ✓ same modes |
| Crop results / drop turns | ✗ | ✓ (reconstruction block) | auto prune only | ✗ | ✓ true per-message view |
| Undo of mutations | leaf move | ✓ | revert (deletes) | ✗ | ✓ |
| Per-node tokens, consumers, gauge | ✗ | ✓ | ✗ | ✗ | ✓ |
| Timing lanes, inspector (DSH) | ✗ | ✗ | ✗ | ✗ | ✓ |
| Labels, filters, search | ✓ | ✓ | ✗ | ✗ | ✓ |
| Works in desktop/web clients | n/a | n/a | ✓ | ✗ | partially (headless /ctree, branches as sessions) |
| Milestone | Deliverable | Verifies |
|---|---|---|
M0 spike — done, see docs/M0.md |
harness + spike plugins for both halves; all five behaviours verified, plus a bundled JSX route | tui.json-only loading; in-place hook mutation; metadata round-trip; renderer.suspend; chat.message model override |
| M1 tree — done | route with gutter, fold/expand, jump (fork/switch), /branch, labels, filters, search, api.kv memory |
pty e2e: branch, switch both ways |
| M2 cost — done | tokens per row, minimap lanes, gauge slot with trend/attribution and compaction guard, consumers view, sidebar card | screen snapshots |
| M3 crop — done | crop mode (result/turn), --top, --auto, protections, /undo for crops, headless /ctree crop |
e2e: stub reaches the provider, undo restores |
| M4 merge — done | draft → editor gate → ◆ record; discard; tournament; decisions view + export; /undo for merges; compaction re-injection |
e2e: record reaches the provider, undo re-opens |
| M5 trajectory — done (partly) | inspector facets, Duration/Turns/Calls modes; idle separators and nested subagent rows not yet | screen snapshots |
| M6 polish — in progress | docs/USAGE.md, headless /ctree, LICENSE, package metadata done; keybind options, v2 adapter, hard-crop option, --purge, npm release pending |
| # | Decision | Chosen |
|---|---|---|
| 1 | Package and slash names | opencode-context-tree; slash /tree with alias /ctree; headless server commands are /ctree …. The /tree name only collides if @ishaksebsib/opencode-tree is installed alongside. |
| 2 | Journal location | Local, <worktree>/.opencode/context-tree/, gitignored by default. storage: "global" remains an option. |
| 3 | Summarize on jump | Ask every time (Pi behaviour), as the one dialog ⏎ opens: No summary / Summarize everything below this point / Summarize with a custom prompt. The summary covers the abandoned tail (§6.2), not the whole session. jumpSummary: "never" opts out. Summaries are journalled as ◇ unreviewed and are distinct from ◆ merge records. |
| 4 | Undo of a squash | Hide the ◆ record from the model, keep it on screen. Journal marks it inactive; the hook drops it; OpenCode storage untouched. No delete path in v1. |
| 5 | Code base | From scratch, spec-driven. Port the pi-context-tree semantics (merge modes, crop protections, undo rules, gauge bands, decision template) and its method (pure core reducers, journal fold, golden fixtures, table-driven view-model tests), but write all code against OpenCode's message/part/session model. Do not fork @ishaksebsib/opencode-tree (unmaintained) or copy Pi entry-based code; read both only as API references. Reliability rule: every OpenCode API the plugin depends on gets an integration test against opencode serve with a mock provider before it is used by a feature. |
- OpenCode source
anomalyco/opencode@69c172e:packages/plugin/src/{index,tui,tool}.ts,packages/opencode/src/session/{prompt,compaction,message-v2,session,revert}.ts,packages/tui/src/editor.ts,packages/opencode/src/plugin/tui/runtime.ts. @opencode-ai/plugin/@opencode-ai/sdk1.18.26 type declarations (npm).- OpenCode docs: plugins, tui, keybinds. Issues #22610, #36525, #1020, #25494, #29980.
- Pi
earendil-works/pi@b8b873b:session-manager.ts,tree-selector.ts,agent-session.ts#navigateTree,branch-summarization.ts, docssessions.md,session-format.md,compaction.md; issues #735, #5366, #982, #2796, #6910. navbytes/pi-context-tree0.3.1: README,docs/USAGE.md,docs/pi-context-tree-spec.md,docs/pi-context-tree-architecture.md.@ishaksebsib/opencode-tree0.4.2 (npm dist + README),Opencode-DCP3.1.15 source.- DeepSeek Harness v0.1: screenshot of the Trajectory tab, deepseek.com/harness, gigazine
2026-08-14, community write-ups (session log envelope
{type, seq, time, data}).
{ "v": 1, "id": "e_01J…", "ts": 1788300000000, "type": "<kind>", "actor": "tui|server|cli", "data": { … } }