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 +10 -0
- package/README.md +13 -1
- package/dist/server.js +6 -2
- package/dist/tui.js +21 -6
- package/docs/USAGE.md +12 -0
- package/package.json +1 -1
- package/src/server/index.ts +3 -2
- package/src/shared/version.ts +3 -0
- package/src/tui/route.tsx +18 -7
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 = [
|
|
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
|
|
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
|
|
21481
|
+
return hit;
|
|
21476
21482
|
const mid = currentMessageOf(row) ?? row.messageID;
|
|
21477
21483
|
const pid = row.kind === "step" ? currentPartOf(row) ?? row.partID : undefined;
|
|
21478
|
-
|
|
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 =
|
|
21508
|
+
const cur = cursorEvents();
|
|
21494
21509
|
const runs = [];
|
|
21495
21510
|
for (const cell of strip().lanes[lane]) {
|
|
21496
|
-
const sel = cell !== null && cell.eventIndex
|
|
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.
|
|
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",
|
package/src/server/index.ts
CHANGED
|
@@ -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)
|
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
|
-
|
|
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
|
|
532
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|