opencode-context-tree 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 — 2026-09-03
4
+
5
+ - Lanes: the cursor now highlights the thinking pills folded into the selected row (they never
6
+ lit up before); the `?` legend explains the lane colours (Input green you / grey context ·
7
+ Model purple answer / grey thinking · Tools orange call / red failed).
8
+ - `?` help and `/ctree status` print the running plugin version.
9
+ - Docs: how to upgrade (OpenCode pins the version it installed and does not re-check `@latest`;
10
+ pin a version with `opencode plugin opencode-context-tree@<version> -g --force`), and a note
11
+ that the trajectory panels are off by default (`1/2/3` show them, `i` the inspector).
12
+
3
13
  ## 0.2.0 — 2026-09-03 — UI/UX pass from a four-lens review
4
14
 
5
15
  Findings from reviewers with lazygit/tig, k9s/btop, Helix/fzf and agent-tooling PM backgrounds,
package/README.md CHANGED
@@ -26,7 +26,9 @@ opencode plugin opencode-context-tree # for the current project only (.op
26
26
  ```
27
27
 
28
28
  Restart OpenCode. `/tree` (or `ctrl+q`) opens the tree; `/branch`, `/merge`, `/decisions` and the
29
- headless `/ctree` commands are available too.
29
+ headless `/ctree` commands are available too. The trajectory panels are off by default so the
30
+ first screen is the plain tree: inside `/tree` press `1`, `2` or `3` for the Input/Model/Tools
31
+ timeline (`0` hides it) and `i` for the inspector.
30
32
 
31
33
  If you only see `/ctree` and no `/tree`, the TUI half is not registered: run the command above.
32
34
  To register by hand instead, the package name must be listed in **both** files:
@@ -38,6 +40,16 @@ To register by hand instead, the package name must be listed in **both** files:
38
40
  { "plugin": ["opencode-context-tree"] }
39
41
  ```
40
42
 
43
+ **Upgrading.** OpenCode keeps the version it installed and does not re-check `@latest` on restart.
44
+ To move to a new release, pin it (this also rewrites both config entries):
45
+
46
+ ```sh
47
+ opencode plugin opencode-context-tree@0.2.0 -g --force # or without -g for the project
48
+ ```
49
+
50
+ or delete `~/.cache/opencode/packages/opencode-context-tree@latest` and restart. `?` in `/tree`
51
+ and `/ctree status` show the version you are running.
52
+
41
53
  Options go in either file: `[["opencode-context-tree", { "storage": "global", "jumpSummary": "never" }]]`
42
54
  (see [docs/USAGE.md](docs/USAGE.md)). To hack on it from a checkout, see "Try it from source" below.
43
55
 
package/dist/server.js CHANGED
@@ -19399,6 +19399,9 @@ ${records.map(one).join(`
19399
19399
  `;
19400
19400
  }
19401
19401
 
19402
+ // src/shared/version.ts
19403
+ var PLUGIN_VERSION = "0.2.1";
19404
+
19402
19405
  // src/core/adopt.ts
19403
19406
  var FORK_TITLE = /^(.+) \(fork #(\d+)\)$/;
19404
19407
  function parseForkTitle(title) {
@@ -19590,7 +19593,8 @@ ${text}
19590
19593
  case "help":
19591
19594
  return say(output2, `${cmd.error ? `error: ${cmd.error}
19592
19595
 
19593
- ` : ""}${CTREE_HELP}`);
19596
+ ` : ""}${CTREE_HELP}
19597
+ (opencode-context-tree ${PLUGIN_VERSION})`);
19594
19598
  case "status": {
19595
19599
  await adopt();
19596
19600
  const state = store.stateForSession(sessionID);
@@ -19603,7 +19607,7 @@ ${text}
19603
19607
  const title = me && !me.name ? (await client.session.get({ path: { id: sessionID }, query: { directory } }).catch(() => {
19604
19608
  return;
19605
19609
  }))?.data?.title : undefined;
19606
- return say(output2, [`tree ${state.treeId}`, me ? `this session is \u2387 ${me.name ?? title ?? "branch"} (${me.status}) of ${me.parentSessionID}${me.note ? ` \u2014 ${me.note}` : ""}` : "this session is the trunk", `${branches.length} branch(es) from here: ${branches.map((b) => `${b.name ?? b.sessionID} [${b.status}]`).join(", ") || "none"}`, `${crops.length} active crop(s), ~${hidden} tokens hidden`, `${Object.values(state.decisions).filter((d) => d.sessionID === sessionID && !d.hidden).length} decision record(s) here`].join(`
19610
+ return say(output2, [`opencode-context-tree ${PLUGIN_VERSION} \xB7 tree ${state.treeId}`, me ? `this session is \u2387 ${me.name ?? title ?? "branch"} (${me.status}) of ${me.parentSessionID}${me.note ? ` \u2014 ${me.note}` : ""}` : "this session is the trunk", `${branches.length} branch(es) from here: ${branches.map((b) => `${b.name ?? b.sessionID} [${b.status}]`).join(", ") || "none"}`, `${crops.length} active crop(s), ~${hidden} tokens hidden`, `${Object.values(state.decisions).filter((d) => d.sessionID === sessionID && !d.hidden).length} decision record(s) here`].join(`
19607
19611
  `));
19608
19612
  }
19609
19613
  case "branch": {
package/dist/tui.js CHANGED
@@ -19813,6 +19813,11 @@ import { insertNode as _$insertNode } from "@opentui/solid";
19813
19813
  import { insert as _$insert } from "@opentui/solid";
19814
19814
  import { setProp as _$setProp } from "@opentui/solid";
19815
19815
  import { createElement as _$createElement } from "@opentui/solid";
19816
+
19817
+ // src/shared/version.ts
19818
+ var PLUGIN_VERSION = "0.2.1";
19819
+
19820
+ // src/tui/route.tsx
19816
19821
  import { For, Show, createEffect, createMemo, createSignal as createSignal2, on, onCleanup } from "solid-js";
19817
19822
 
19818
19823
  // src/core/actions.ts
@@ -21078,7 +21083,7 @@ var EMPTY_TRANSCRIPT = {
21078
21083
  messages: []
21079
21084
  };
21080
21085
  var NO_BRANCHES = "No branches yet \xB7 b forks here into a real OpenCode session; nothing is copied or deleted.";
21081
- var HELP = ["? help \xB7 ? or esc closes", "Move", " \u2191\u2193 j k \xB7 J K by 20 \xB7 ctrl+d ctrl+u half page \xB7 gg top \xB7 G bottom \xB7 [ ] branch rows", " h l \u2190 \u2192 fold/unfold a branch \xB7 Tab (or e) toggle \xB7 / live search \xB7 n N next/prev match", "Act", " \u23CE go \u2014 a \u2387 header switches to it \xB7 a user turn forks & prefills it \xB7 a step forks after it", " b branch \xB7 m merge \xB7 c crop mode (space mark \xB7 a auto \xB7 t result\u21C4turn \xB7 \u23CE apply \xB7 esc leave)", " u undo (alias x) \xB7 L label \xB7 y copy \xB7 E export decisions", "Views", " i inspector \xB7 1 2 3 lanes (duration/turns/calls) \xB7 0 off \xB7 s consumers \xB7 D decisions \xB7 f F filter", "Legend", " \u25CF user \xB7 \u25CB assistant \xB7 \u2699 tool step \xB7 \u25C6 decision \xB7 \u2263 summary \xB7 \u2387 branch (a real OpenCode session)", " \u2502 \u251C \u2570 draw the topology \xB7 \u25BE open \u25B8 folded \xB7 \u2190 here is the session you are in", " dim rows are not sent to the model; \u2500\u2500 not in this branch's context \u2500\u2500 is where your path forked", " right column is tokens; ~ estimated \xB7 \u26A0 \u226510k \xB7 \u2702 cropped \xB7 \u2717 tool error", " \u2387 colours: open green \xB7 squashed blue \xB7 rejected/discarded red \xB7 abandoned grey"];
21086
+ var HELP = [`? help \xB7 ? or esc closes \xB7 opencode-context-tree ${PLUGIN_VERSION}`, "Move", " \u2191\u2193 j k \xB7 J K by 20 \xB7 ctrl+d ctrl+u half page \xB7 gg top \xB7 G bottom \xB7 [ ] branch rows", " h l \u2190 \u2192 fold/unfold a branch \xB7 Tab (or e) toggle \xB7 / live search \xB7 n N next/prev match", "Act", " \u23CE go \u2014 a \u2387 header switches to it \xB7 a user turn forks & prefills it \xB7 a step forks after it", " b branch \xB7 m merge \xB7 c crop mode (space mark \xB7 a auto \xB7 t result\u21C4turn \xB7 \u23CE apply \xB7 esc leave)", " u undo (alias x) \xB7 L label \xB7 y copy \xB7 E export decisions", "Views", " i inspector \xB7 1 2 3 lanes (duration/turns/calls) \xB7 0 off \xB7 s consumers \xB7 D decisions \xB7 f F filter", "Legend", " \u25CF user \xB7 \u25CB assistant \xB7 \u2699 tool step \xB7 \u25C6 decision \xB7 \u2263 summary \xB7 \u2387 branch (a real OpenCode session)", " \u2502 \u251C \u2570 draw the topology \xB7 \u25BE open \u25B8 folded \xB7 \u2190 here is the session you are in", " dim rows are not sent to the model; \u2500\u2500 not in this branch's context \u2500\u2500 is where your path forked", " right column is tokens; ~ estimated \xB7 \u26A0 \u226510k \xB7 \u2702 cropped \xB7 \u2717 tool error", " \u2387 colours: open green \xB7 squashed blue \xB7 rejected/discarded red \xB7 abandoned grey", " lanes: Input green you / grey context \xB7 Model purple answer / grey thinking \xB7 Tools orange call / red failed"];
21082
21087
  var FILTERS = [{
21083
21088
  title: "default",
21084
21089
  value: "default",
@@ -21469,13 +21474,23 @@ function TreeRoute(props) {
21469
21474
  const userTurns = () => (live()?.messages ?? []).filter((m) => m.role === "user").length;
21470
21475
  const laneWidth = () => Math.max(10, Math.min(width() - 46, 80));
21471
21476
  const strip = createMemo(() => buildEventStrip(live() ?? EMPTY_TRANSCRIPT, laneMode(), laneWidth()));
21472
- const cursorEvent = createMemo(() => {
21477
+ const cursorEvents = createMemo(() => {
21473
21478
  const row = current();
21479
+ const hit = new Set;
21474
21480
  if (!row || row.kind === "branch" || row.kind === "separator")
21475
- return -1;
21481
+ return hit;
21476
21482
  const mid = currentMessageOf(row) ?? row.messageID;
21477
21483
  const pid = row.kind === "step" ? currentPartOf(row) ?? row.partID : undefined;
21478
- return stripIndexFor(strip(), mid, pid);
21484
+ const own2 = stripIndexFor(strip(), mid, pid);
21485
+ if (own2 >= 0)
21486
+ hit.add(own2);
21487
+ strip().events.forEach((e, i) => {
21488
+ if (e.messageID !== mid)
21489
+ return;
21490
+ if (row.kind === "turn" ? e.lane === "input" : e.kind === "reasoning")
21491
+ hit.add(i);
21492
+ });
21493
+ return hit;
21479
21494
  });
21480
21495
  const cellColor = (cell) => {
21481
21496
  if (cell.error)
@@ -21490,10 +21505,10 @@ function TreeRoute(props) {
21490
21505
  return e.kind === "reasoning" ? t.textMuted : t.accent;
21491
21506
  };
21492
21507
  const laneRuns = (lane) => {
21493
- const cur = cursorEvent();
21508
+ const cur = cursorEvents();
21494
21509
  const runs = [];
21495
21510
  for (const cell of strip().lanes[lane]) {
21496
- const sel = cell !== null && cell.eventIndex === cur;
21511
+ const sel = cell !== null && cur.has(cell.eventIndex);
21497
21512
  const color = cell === null ? t.textMuted : cellColor(cell);
21498
21513
  const fg = sel ? t.background : color;
21499
21514
  const bg = sel ? color : undefined;
package/docs/USAGE.md CHANGED
@@ -39,6 +39,18 @@ unfold toggle go branch label filter_pick filter_prev search search_next search_
39
39
  crop crop_toggle_mode mark auto undo merge inspector consumers copy mode_duration mode_turns
40
40
  mode_calls lanes_off decisions export help`.
41
41
 
42
+ ## Upgrading
43
+
44
+ OpenCode caches the version it installed and does not re-resolve `@latest` on restart. Pin the new
45
+ release, which also rewrites both config entries:
46
+
47
+ ```sh
48
+ opencode plugin opencode-context-tree@0.2.0 -g --force # drop -g for the current project
49
+ ```
50
+
51
+ or delete `~/.cache/opencode/packages/opencode-context-tree@latest` and restart. `?` in `/tree` and
52
+ `/ctree status` print the running version.
53
+
42
54
  ## The loop
43
55
 
44
56
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-context-tree",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Pi-style context tree for OpenCode: branch, merge (human-confirmed decision records), crop, undo, plus a DeepSeek-Harness-style trajectory view",
@@ -16,6 +16,7 @@ import { CTREE_HELP, parseCtreeArgs } from "../core/ctree-args.js"
16
16
  import { autoMark, planResultCrop, resultCandidates, topCandidate, type CropRules, DEFAULT_RULES } from "../core/cropplan.js"
17
17
  import { planUndo } from "../core/undo.js"
18
18
  import { exportDecisions } from "../core/decision.js"
19
+ import { PLUGIN_VERSION } from "../shared/version.js"
19
20
  import type { Transcript, TranscriptMessage } from "../core/transcript.js"
20
21
  import { parseForkTitle } from "../core/adopt.js"
21
22
  import { adoptNativeForks } from "../shared/adopt.js"
@@ -97,7 +98,7 @@ export const server: Plugin = async ({ worktree, client, directory }, options) =
97
98
  const cmd = parseCtreeArgs(input.arguments)
98
99
  switch (cmd.kind) {
99
100
  case "help":
100
- return say(output, `${cmd.error ? `error: ${cmd.error}\n\n` : ""}${CTREE_HELP}`)
101
+ return say(output, `${cmd.error ? `error: ${cmd.error}\n\n` : ""}${CTREE_HELP}\n(opencode-context-tree ${PLUGIN_VERSION})`)
101
102
  case "status": {
102
103
  await adopt() // headless clients have no TUI half to do it for them
103
104
  const state = store.stateForSession(sessionID)
@@ -108,7 +109,7 @@ export const server: Plugin = async ({ worktree, client, directory }, options) =
108
109
  const branches = Object.values(state.sessions).filter((b) => b.parentSessionID === sessionID)
109
110
  // an adopted native fork carries no journal name: fall back to the session's own title
110
111
  const title = me && !me.name ? ((await client.session.get({ path: { id: sessionID }, query: { directory } }).catch(() => undefined))?.data as { title?: string } | undefined)?.title : undefined
111
- return say(output, [`tree ${state.treeId}`, me ? `this session is ⎇ ${me.name ?? title ?? "branch"} (${me.status}) of ${me.parentSessionID}${me.note ? ` — ${me.note}` : ""}` : "this session is the trunk", `${branches.length} branch(es) from here: ${branches.map((b) => `${b.name ?? b.sessionID} [${b.status}]`).join(", ") || "none"}`, `${crops.length} active crop(s), ~${hidden} tokens hidden`, `${Object.values(state.decisions).filter((d) => d.sessionID === sessionID && !d.hidden).length} decision record(s) here`].join("\n"))
112
+ return say(output, [`opencode-context-tree ${PLUGIN_VERSION} · tree ${state.treeId}`, me ? `this session is ⎇ ${me.name ?? title ?? "branch"} (${me.status}) of ${me.parentSessionID}${me.note ? ` — ${me.note}` : ""}` : "this session is the trunk", `${branches.length} branch(es) from here: ${branches.map((b) => `${b.name ?? b.sessionID} [${b.status}]`).join(", ") || "none"}`, `${crops.length} active crop(s), ~${hidden} tokens hidden`, `${Object.values(state.decisions).filter((d) => d.sessionID === sessionID && !d.hidden).length} decision record(s) here`].join("\n"))
112
113
  }
113
114
  case "branch": {
114
115
  const tr = await transcriptOf(sessionID)
@@ -0,0 +1,3 @@
1
+ /** Stamped by scripts/build.ts from package.json (the release tag); "dev" when run from source. */
2
+ declare const __CTREE_VERSION__: string | undefined
3
+ export const PLUGIN_VERSION: string = typeof __CTREE_VERSION__ === "string" ? __CTREE_VERSION__ : "dev"
package/src/tui/route.tsx CHANGED
@@ -4,6 +4,7 @@
4
4
  * OpenCode data through the adapters, actions through ./actions.
5
5
  */
6
6
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
7
+ import { PLUGIN_VERSION } from "../shared/version.js"
7
8
  import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
8
9
  import { planJump } from "../core/actions.js"
9
10
  import { foldJournal, type TreeState } from "../core/journal.js"
@@ -195,7 +196,7 @@ const NO_BRANCHES = "No branches yet · b forks here into a real OpenCode sessio
195
196
  /** The `?` pane: unindented lines are headings, indented ones body (see the render).
196
197
  * It sits under the rows, so the tree stays on screen while you read it. */
197
198
  const HELP = [
198
- "? help · ? or esc closes",
199
+ `? help · ? or esc closes · opencode-context-tree ${PLUGIN_VERSION}`,
199
200
  "Move",
200
201
  " ↑↓ j k · J K by 20 · ctrl+d ctrl+u half page · gg top · G bottom · [ ] branch rows",
201
202
  " h l ← → fold/unfold a branch · Tab (or e) toggle · / live search · n N next/prev match",
@@ -211,6 +212,7 @@ const HELP = [
211
212
  " dim rows are not sent to the model; ── not in this branch's context ── is where your path forked",
212
213
  " right column is tokens; ~ estimated · ⚠ ≥10k · ✂ cropped · ✗ tool error",
213
214
  " ⎇ colours: open green · squashed blue · rejected/discarded red · abandoned grey",
215
+ " lanes: Input green you / grey context · Model purple answer / grey thinking · Tools orange call / red failed",
214
216
  ]
215
217
 
216
218
  /** `f` opens this as a picker; `F` steps back through it (DESIGN.md §7.5). */
@@ -528,13 +530,22 @@ export function TreeRoute(props: TreeRouteProps) {
528
530
  // One pill per event on a shared time axis — categorical colour, nothing scaled by tokens.
529
531
  const laneWidth = () => Math.max(10, Math.min(width() - 46, 80))
530
532
  const strip = createMemo(() => buildEventStrip(live() ?? EMPTY_TRANSCRIPT, laneMode(), laneWidth()))
531
- /** The strip event the cursor sits on; its cells draw inverted, so there is no cursor block. */
532
- const cursorEvent = createMemo(() => {
533
+ /** The strip events the cursor sits on; their cells draw inverted, so there is no cursor block.
534
+ * A step row also owns the thinking pills folded into it (the list shows them as `· Ns thought`),
535
+ * otherwise the grey reasoning pills would never light up. */
536
+ const cursorEvents = createMemo<Set<number>>(() => {
533
537
  const row = current()
534
- if (!row || row.kind === "branch" || row.kind === "separator") return -1
538
+ const hit = new Set<number>()
539
+ if (!row || row.kind === "branch" || row.kind === "separator") return hit
535
540
  const mid = currentMessageOf(row) ?? row.messageID
536
541
  const pid = row.kind === "step" ? (currentPartOf(row) ?? row.partID) : undefined
537
- return stripIndexFor(strip(), mid, pid)
542
+ const own = stripIndexFor(strip(), mid, pid)
543
+ if (own >= 0) hit.add(own)
544
+ strip().events.forEach((e, i) => {
545
+ if (e.messageID !== mid) return
546
+ if (row.kind === "turn" ? e.lane === "input" : e.kind === "reasoning") hit.add(i)
547
+ })
548
+ return hit
538
549
  })
539
550
  const cellColor = (cell: StripCell): unknown => {
540
551
  if (cell.error) return t.error
@@ -546,10 +557,10 @@ export function TreeRoute(props: TreeRouteProps) {
546
557
  }
547
558
  /** Adjacent cells of the same colour collapse into one <text>, so a lane is a few nodes. */
548
559
  const laneRuns = (lane: "input" | "model" | "tools") => {
549
- const cur = cursorEvent()
560
+ const cur = cursorEvents()
550
561
  const runs: { text: string; fg: unknown; bg: unknown }[] = []
551
562
  for (const cell of strip().lanes[lane]) {
552
- const sel = cell !== null && cell.eventIndex === cur
563
+ const sel = cell !== null && cur.has(cell.eventIndex)
553
564
  const color = cell === null ? t.textMuted : cellColor(cell)
554
565
  const fg = sel ? t.background : color
555
566
  const bg = sel ? color : undefined