@cardinal4/opencode-fold-diffs 0.3.6 → 0.3.8

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.
Files changed (5) hide show
  1. package/README.md +19 -14
  2. package/index.js +37 -6
  3. package/package.json +2 -4
  4. package/rpc.js +20 -0
  5. package/tui.js +502 -111
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # opencode-fold-diffs
2
2
 
3
3
  > [!NOTE]
4
- > **Unofficial OpenCode V2 port of [Tanner Bruhn](https://github.com/tannerbruhn)'s [`opencode-fold-diffs`](https://github.com/tannerbruhn/opencode-fold-diffs) (npm: `opencode-fold-diffs`) — the OpenCode V1 plugin.** This fork — [`cardin/opencode-fold-diffs`](https://github.com/cardin/opencode-fold-diffs), published as [`@cardinal4/opencode-fold-diffs`](https://www.npmjs.com/package/@cardinal4/opencode-fold-diffs) — ports it to **OpenCode V2** (`@opencode/cli` 2.x, `opencode v2.0.x`). Install this fork on V2, upstream on V1. Tanner has not reviewed or endorsed it; report bugs here, not upstream.
4
+ > **Unofficial OpenCode V2 port of [Tanner Bruhn](https://github.com/tannerbruhn)'s [`opencode-fold-diffs`](https://github.com/tannerbruhn/opencode-fold-diffs) (npm: `opencode-fold-diffs`) — the OpenCode V1 plugin.** This fork — [`cardin/opencode-fold-diffs`](https://github.com/cardin/opencode-fold-diffs), published as [`@cardinal4/opencode-fold-diffs`](https://www.npmjs.com/package/@cardinal4/opencode-fold-diffs) — targets **OpenCode V2** (`@opencode/cli` 2.x, API-checked against `opencode 2.2.3` and OpenTUI 0.8.x). Install this fork on V2, upstream on V1. Tanner has not reviewed or endorsed it; report bugs here, not upstream.
5
5
 
6
6
  **Every `write`, `edit` and `apply_patch` dumps the whole file or the whole diff into your transcript, and stays there.** This plugin folds those blocks down to their header line — `# Wrote 40 lines · click to expand src/app.ts` — and opens them again on click, or with a fold/unfold-all key if you configure one.
7
7
 
@@ -33,7 +33,9 @@ Click the row to open it. If you set the `key` option, that binding folds or unf
33
33
  opencode plugin add @cardinal4/opencode-fold-diffs
34
34
  ```
35
35
 
36
- Then list it in your CLI config so the terminal loads the TUI entrypoint. Add it to `~/.config/opencode/cli.json` (or `$XDG_CONFIG_HOME/opencode/cli.json`):
36
+ That installs the package and adds it to the global `opencode.json`. The package ships a server entry (`index.js`), a portable RPC bridge (`rpc.js`) and a TUI entry (`tui.js`); the TUI part loads automatically with the server entry, so no second configuration is needed.
37
+
38
+ If the terminal connects to a **remote server**, list the package in `~/.config/opencode/cli.json` (or `$XDG_CONFIG_HOME/opencode/cli.json`) instead so the local TUI loads the TUI part. Do not list it in both files:
37
39
 
38
40
  ```json
39
41
  {
@@ -42,9 +44,7 @@ Then list it in your CLI config so the terminal loads the TUI entrypoint. Add it
42
44
  }
43
45
  ```
44
46
 
45
- This plugin's behavior is TUI-only (its server entry is a no-op that exists so OpenCode can discover it by id), so configure it in `cli.json`. TUI plugins also stay active when the terminal connects to a remote server.
46
-
47
- With options:
47
+ Options can go on the plugin entry in either file:
48
48
 
49
49
  ```json
50
50
  {
@@ -61,6 +61,8 @@ With options:
61
61
  }
62
62
  ```
63
63
 
64
+ OpenCode 2.2.3 gives an `opencode.json` server plugin its options but drops them from the TUI entry it derives from the same listing. The server part therefore publishes its options over portable RPC, and the TUI asks for them at startup. If that bridge is unavailable (an older host, or a remote server that never registered it), `cli.json` remains the only source the TUI can read; **Fold diffs: diagnose** reports the effective option object either way. When both files set a key, `cli.json` wins.
65
+
64
66
  Restart OpenCode afterwards; plugins load at startup.
65
67
 
66
68
  ### From a local checkout
@@ -103,19 +105,19 @@ Clone this repository and point the plugin entry at the checkout. The package sh
103
105
 
104
106
  The V2 TUI plugin API (`@opencode/plugin/tui`) has no slot for message parts, so a plugin cannot render a tool block itself. It has to reach the renderables the host already made:
105
107
 
106
- 1. **Find the transcript.** The scrollbox with `stickyScroll && stickyStart === "bottom"`. The sidebar, dialogs, autocomplete and diff viewer all have scrollboxes; none of them are sticky. The one other sticky scrollbox in V2 is the shell output viewer, which is a dialog and is skipped by its `shell-output-scroll` id.
107
- 2. **Find the blocks.** A V2 `BlockTool` renders its header first as a row box whose first two children are the label text (`# Wrote`, `← Edit`, `← Patched`, `# Created`, `# Deleted`) and the path value. File blocks are matched on that label; a bash block carries no header, so it is found by shape — a child whose first grandchild is the `$ `-prefixed command.
108
- 3. **Fold.** Set `visible = false` on the children carrying the bulk. That sets Yoga `display: none`, so the body leaves layout instead of leaving a hole. A zero `max-height` alone is not enough — the box collapses to zero rows but OpenTUI still paints the diff. A positive `lines` value keeps the first body visible and uses `maxHeight` for it instead. The block's own chrome (`gap`, `paddingTop`, `paddingBottom` of 1) is tightened to zero so a folded block occupies one row.
109
- 4. **Toggle.** Assign `onMouseUp` on the block. The solid adapter sets that as a plain property, so a plugin can set it the same way — it replaces `BlockTool`'s own handler, which is why the copy-on-select guard is reimplemented here. For a bash block the host's handler is the output toggle and must be preserved, so the handler goes on the command text with `stopPropagation()` instead.
110
- 5. **Restate the header.** The stats suffix is appended to the label text node, not the path. If solid will not let go of that node the plugin stops trying and folds without the suffix.
108
+ 1. **Find the transcript.** Candidates are every scrollbox with a bottom sticky start, excluding the shell output viewer, diff help and execute detail panels by id. They are scored, not taken first: a `session-part:` descendant is the strongest evidence, a `msg_` id is next, and scroll height breaks ties. A scrolled-up transcript that reports `stickyScroll=false` is still accepted when it has the bottom anchor plus session-part evidence. The cached transcript is dropped when its route/session changes or when it is detached from the renderer root.
109
+ 2. **Find the blocks.** A V2 `BlockTool` renders its header first as a row box whose first two children are the label text (`# Wrote`, `← Edit`, `← Patched`, `# Created`, `# Deleted`) and the path value. File blocks are matched on that label; a bash block carries no header, so it is found by shape — the command row's first text node is the `$ `-prefixed command. A running command (a spinner box before any text) is skipped and picked up by a later sweep; output that happens to begin with `$ ` is never mistaken for the command.
110
+ 3. **Fold.** Set `visible = false` on the children carrying the bulk. That sets Yoga `display: none`, so the body leaves layout instead of leaving a hole. A zero `max-height` alone is not enough — the box collapses to zero rows but OpenTUI still paints the diff. A positive `lines` value keeps the first body visible and uses `maxHeight` for it instead. The block's own chrome (`gap`, `paddingTop`, `paddingBottom`) is tightened to zero so a folded block occupies one row, and the values read from OpenTUI 0.8's `getGap`/`getPadding` getters are restored exactly when it unfolds. `maxHeight` is captured the same way; bash commands fall back to the host's two-row clamp when the getter is unavailable.
111
+ 4. **Toggle.** Assign `onMouseUp` on the block. The solid adapter sets that as a plain property, so a plugin can set it the same way — it replaces `BlockTool`'s own handler, which is why the copy-on-select guard is reimplemented here. For a bash block the host's handler is the output toggle and must be preserved, so the handler goes on the command text with `stopPropagation()` instead. The handler reference is stored and cleared on cleanup, and a `disposed` guard makes a late click a no-op.
112
+ 5. **Restate the header.** The stats suffix is appended to the label text node, not the path. If solid will not let go of that node the plugin stops trying; a block whose suffix was already applied still has it stripped on cleanup/unfold even after that global flag is off. Known blocks are re-summarised on each sweep, so a block whose streamed content grew gets fresh stats instead of keeping its first count.
111
113
 
112
- Renderable classes are matched by duck-typing (`typeof node.diff === "string"` for a diff, `typeof node.content === "string"` for a written file) rather than `instanceof`, because the opentui classes are minified in the shipped binary and their names are not stable. `filetype` is deliberately not required: it was dropped in 2.0.16 and restored in 2.0.27, and keying off it silently stopped write blocks from folding.
114
+ Renderable classes are matched by duck-typing (`typeof node.diff === "string"` for a diff, a string `content` **plus** `getLineSources` for a written file) rather than `instanceof`, because the opentui classes are minified in the shipped binary and their names are not stable. The second half matters in OpenTUI 0.8: `MarkdownRenderable` also has a string `content`, and matching on content alone mistook markdown for a written file. `filetype` is deliberately not required: it was dropped in 2.0.16 and restored in 2.0.27, and keying off it silently stopped write blocks from folding.
113
115
 
114
- Blocks are re-scanned on V2's `session.message.content.updated`, `session.tool.success` and `session.tool.failed`, plus `session.step.ended` and `session.execution.succeeded` (`session.shell.ended` joins them when `bash` folding is on). A 2 s sweep catches a session opened from history, whose parts land before any event this plugin sees.
116
+ Blocks are re-scanned on V2's `session.tool.called`, `session.tool.progress`, `session.tool.success` and `session.tool.failed`, plus `session.step.ended` and `session.execution.succeeded` (`session.shell.ended` joins them when `bash` folding is on). A 5 s fallback sweep catches a session opened from history, whose parts land before any event this plugin sees. The fallback is much slower than the old 2 s timer because it exists only for that initial batch; live updates are event-driven. `session.message.content.updated` is not in the public TUI event union used by the plugin API, so it is not subscribed to.
115
117
 
116
118
  ## Status
117
119
 
118
- Written against **opencode v2.0.24** and re-checked against **v2.0.27**; the `session.*` events it listens to exist from v2.0.10 on, so an older V2 install gets the fix without upgrading. The tree-walking, block matching, fold/unfold and toggle logic run green against a mock renderer tree shaped like V2's (`node --test`), and the fold was checked against a real transcript: `visible = false` (Yoga `display: none`) is what actually hides a body, not `maxHeight`.
120
+ Written against **opencode v2.0.24**, re-checked against **v2.0.27** and **v2.2.0**, and API-checked against **v2.2.3** / OpenTUI 0.8.x; the `session.*` events it listens to exist from v2.0.10 on, so an older V2 install gets the fix without upgrading. The tree-walking, block matching, fold/unfold and toggle logic run green against a mock renderer tree shaped like V2's (`node --test`), and the fold was checked against a real transcript: `visible = false` (Yoga `display: none`) is what actually hides a body, not `maxHeight`.
119
121
 
120
122
  To confirm it loaded, restart (`opencode service restart`) and check `/plugins` lists `opencode-fold-diffs` and `Ctrl+P` → **Fold / unfold file diffs** is in the palette. If nothing folds, run **Fold diffs: diagnose** — the toast reports what the plugin can see:
121
123
 
@@ -125,6 +127,9 @@ To confirm it loaded, restart (`opencode service restart`) and check `/plugins`
125
127
  | `transcript: yes · blocks: 0` | The transcript was found but no file blocks matched — the host render tree differs from what this plugin expects. |
126
128
  | `transcript: yes · blocks: N · folded: N` | Detection and folding ran; if the blocks still look expanded, the fold did not take on the renderables. |
127
129
  | `stats: off` | Folding works but the header could not be restated (Solid owns the label node). |
130
+ | `options: {...}` | The effective merged options. Compare this with the entry in `opencode.json`/`cli.json`; if it is empty and you configured options in a remote `cli.json`, the local TUI never saw them. |
131
+
132
+ The diagnose dump includes up to 80 characters of text from each renderable, so it can contain prompts or tool output. Redact it before sharing; set `dump: false` to skip writing it.
128
133
 
129
134
  Edits with fewer than `min_lines` changed lines (default 6) are left expanded on purpose — a one-line change is already its own summary. Set `min_lines: 0` to fold every file block.
130
135
 
@@ -136,7 +141,7 @@ The port follows V2's new plugin API and render tree, so the V1 code could not b
136
141
  - `api.renderer` → `context.renderer`, `api.route.current.name` → `context.ui.router.current().type`, `api.event.on` → `context.data.on`, `api.ui.toast` → `context.ui.toast.show`, `api.keymap.registerLayer` → `context.keymap.layer` (called straight from `setup` on 2.0.23+, from an `app`-slot component before that).
137
142
  - Event names are V2's `session.*` events; `message.updated` / `message.part.updated` are legacy schemas that never fire.
138
143
  - Tool headers are a label node plus a path node, so detection and the stats suffix target the label.
139
- - `bash` defaults to `false` because V2 trims long commands itself, and config moves from `tui.json` to `cli.json`.
144
+ - `bash` defaults to `false` because V2 trims long commands itself, and config moves from `tui.json` to `opencode.json` (bridged to the TUI by `rpc.js`) or `cli.json` for a remote TUI (which the TUI reads directly and which wins on conflicts).
140
145
 
141
146
  ## License
142
147
 
package/index.js CHANGED
@@ -1,18 +1,49 @@
1
1
  // Server entrypoint for opencode-fold-diffs.
2
2
  //
3
- // All rendering happens in the CLI runtime (tui.js). This file only exists so a
4
- // local plugin directory is a complete plugin: OpenCode discovers a plugin
5
- // directory by its index (server) entry and loads the `tui` entry beside it, so
6
- // the plugin shows up by id instead of as an anonymous entry.
3
+ // Rendering happens in the CLI runtime (tui.js). This file exists so a local
4
+ // plugin directory is a complete plugin: OpenCode discovers a plugin directory
5
+ // by its index (server) entry and loads the `tui` entry beside it, so the plugin
6
+ // shows up by id instead of as an anonymous entry.
7
+ //
8
+ // It also bridges the options. A server plugin listed in opencode.json gets its
9
+ // `options` object; the TUI entry OpenCode derives from the same listing does
10
+ // not. Publishing them over RPC lets tui.js honour `folded`, `stats`, `key`,
11
+ // `bash` and the rest even when the plugin was installed with
12
+ // `opencode plugin add` instead of being configured in cli.json.
7
13
  //
8
14
  // `Plugin.define()` is an identity helper, so a plain { id, setup } object is
9
15
  // the same definition without a runtime dependency on @opencode/plugin.
10
16
 
17
+ import { OPTIONS_RPC } from "./rpc.js";
18
+
11
19
  export const PLUGIN_ID = "opencode-fold-diffs";
12
20
 
13
21
  export default {
14
22
  id: PLUGIN_ID,
15
- setup() {
16
- // Intentional no-op: the transcript folding is registered by tui.js.
23
+ async setup(context) {
24
+ // Keep a copy: a host-side mutation of the context later must not change
25
+ // what an already-running TUI asked for.
26
+ const options =
27
+ context?.options && typeof context.options === "object" && !Array.isArray(context.options)
28
+ ? { ...context.options }
29
+ : {};
30
+ try {
31
+ const registration = await context?.rpc?.register?.(OPTIONS_RPC, {
32
+ // The RPC layer awaits every handler result, so a synchronous object
33
+ // fails with "t(...).then is not a function"; always return a promise.
34
+ async get() {
35
+ return { options };
36
+ },
37
+ });
38
+ return async () => {
39
+ try {
40
+ await registration?.dispose?.();
41
+ } catch {}
42
+ };
43
+ } catch {
44
+ // Older hosts have no portable RPC. The TUI then falls back to cli.json,
45
+ // which is the only path 2.2.3 supports natively.
46
+ return;
47
+ }
17
48
  },
18
49
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardinal4/opencode-fold-diffs",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Unofficial OpenCode V2 fork of opencode-fold-diffs: write, edit and apply_patch blocks render folded to their header line, click to expand.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,6 +24,7 @@
24
24
  "files": [
25
25
  "index.js",
26
26
  "tui.js",
27
+ "rpc.js",
27
28
  "README.md",
28
29
  "LICENSE"
29
30
  ],
@@ -44,8 +45,5 @@
44
45
  },
45
46
  "publishConfig": {
46
47
  "access": "public"
47
- },
48
- "engines": {
49
- "opencode": ">=2.0.0"
50
48
  }
51
49
  }
package/rpc.js ADDED
@@ -0,0 +1,20 @@
1
+ // The one method the server part exposes to the TUI part.
2
+ //
3
+ // OpenCode 2.2.3 passes options to a TUI plugin only from cli.json. An
4
+ // opencode.json server-plugin entry reaches the server context with its
5
+ // options, but the matching TUI entry is reduced to the bare package name, so
6
+ // the TUI never sees them. The server registers this method and the TUI asks
7
+ // for the values before configuring itself; cli.json still wins on a conflict.
8
+ //
9
+ // The definition is deliberately dependency-free: portable RPC accepts JSON
10
+ // Schema, so no @opencode/plugin or effect import is needed at runtime.
11
+ export const OPTIONS_RPC = {
12
+ id: "opencode-fold-diffs.options",
13
+ methods: {
14
+ get: {
15
+ input: { type: "object" },
16
+ output: { type: "object" },
17
+ },
18
+ },
19
+ events: {},
20
+ };
package/tui.js CHANGED
@@ -13,7 +13,10 @@
13
13
  // expand src/app.ts" -- and opens on click, or with the optional `key`
14
14
  // binding for every block at once.
15
15
  //
16
- // Options (cli.json -> { "package": "@cardinal4/opencode-fold-diffs", "options": { ... } }):
16
+ // Options can live on the plugin entry in opencode.json or cli.json:
17
+ // { "package": "@cardinal4/opencode-fold-diffs", "options": { ... } }
18
+ // opencode.json options reach the TUI through the server part's RPC bridge;
19
+ // cli.json is the local TUI's own source and wins when both set a key.
17
20
  // lines lines of the body left visible when folded (default 0, title only)
18
21
  // min_lines leave blocks with fewer content lines alone (default 6)
19
22
  // stats append "40 lines · click to expand" to the header (default true)
@@ -30,6 +33,7 @@
30
33
  import { writeFileSync } from "node:fs";
31
34
  import { tmpdir } from "node:os";
32
35
  import { join } from "node:path";
36
+ import { OPTIONS_RPC } from "./rpc.js";
33
37
 
34
38
  const DEFAULTS = {
35
39
  lines: 0,
@@ -74,10 +78,29 @@ function baseLabel(text) {
74
78
  // "$ " in front of it.
75
79
  const PROMPT = "$ ";
76
80
 
77
- // How often to re-scan when nothing is streaming. Events cover the live case;
78
- // this catches a session opened from history, whose parts arrive as one batch
79
- // before any event this plugin sees.
80
- const SWEEP_MS = 2000;
81
+ // Events cover the live case. The fallback catches a session opened from
82
+ // history, whose parts arrive as one batch before any event this plugin sees;
83
+ // it is deliberately slower than the old 2 s timer because that batch is the
84
+ // only thing it is for.
85
+ const SWEEP_MS = 5000;
86
+
87
+ // Yoga enum values, so the 0.8 getters can be read without importing
88
+ // @opentui/core (which the host bundles and minifies).
89
+ const EDGE_TOP = 1;
90
+ const EDGE_BOTTOM = 3;
91
+ const GUTTER_ROW = 1;
92
+
93
+ // Scrollboxes that belong to the host's panels and dialogs rather than to the
94
+ // transcript. The shell viewer is the important one: since V2 it is also a
95
+ // sticky scrollbox.
96
+ const EXCLUDED_SCROLL_IDS = new Set([
97
+ "shell-output-scroll",
98
+ "diff-help-scroll",
99
+ "execute-detail-scroll",
100
+ ]);
101
+
102
+ const MAX_ROWS = 10000;
103
+ const BRIDGE_TIMEOUT_MS = 500;
81
104
 
82
105
  function children(node) {
83
106
  return typeof node?.getChildren === "function" ? node.getChildren() : [];
@@ -91,32 +114,76 @@ function plain(node) {
91
114
  // Duck-typing, not instanceof: the classes live in the host's bundled
92
115
  // @opentui/core and are minified, so their names are not stable. A diff
93
116
  // renderable is the only thing in the tree carrying a `diff` string. A code
94
- // renderable is the only thing whose `content` is a string -- text renderables
95
- // carry a styled-text `content` object and expose `plainText` instead. Never key
96
- // off `filetype`: OpenCode stopped setting it in 2.0.16 and restored it in
97
- // 2.0.27, and requiring it silently stopped every write block from folding.
117
+ // renderable is a TextBufferRenderable: its `content` is a string AND it
118
+ // exposes the line map. MarkdownRenderable also has a string `content`, so
119
+ // checking the content alone made a markdown block look like a written file.
98
120
  function isDiff(node) {
99
121
  return typeof node?.diff === "string";
100
122
  }
101
123
 
102
124
  function isCode(node) {
103
- return typeof node?.content === "string";
125
+ return typeof node?.content === "string" && typeof node?.getLineSources === "function";
126
+ }
127
+
128
+ // The text renderable's value is a styled-text object, not a string. Text is
129
+ // the one primitive here that every host version has kept.
130
+ function textValue(node) {
131
+ return typeof node?.content === "string" ? node.content : undefined;
132
+ }
133
+
134
+ // The stable identity of a message part, if the host published one. V2 sets
135
+ // `session-part:<messageID>:<partID>` on the part container; older trees at
136
+ // least carry a `msg_` id. Walking parents keeps this working when the block
137
+ // renderable itself has no id.
138
+ function stableKey(node) {
139
+ let current = node;
140
+ for (let i = 0; current && i < 200; i++) {
141
+ const id = typeof current.id === "string" ? current.id : "";
142
+ if (id.startsWith("session-part:") || id.startsWith("msg_")) return id;
143
+ current = current.parent;
144
+ }
145
+ return undefined;
146
+ }
147
+
148
+ // Rows a text content occupies. A lone "\n" is one empty line, not zero; a
149
+ // single trailing newline is the terminator and does not add a second line.
150
+ function contentLines(content) {
151
+ if (content === "") return 0;
152
+ const lines = content.split("\n");
153
+ if (lines.length && lines[lines.length - 1] === "") lines.pop();
154
+ return lines.length;
155
+ }
156
+
157
+ // A real unified-diff file header is the three-character marker followed by a
158
+ // space and a path. Without the space requirement a content line whose text
159
+ // starts "+++" was silently dropped from the stats.
160
+ function isDiffHeader(line) {
161
+ if (!line.startsWith("+++ ") && !line.startsWith("--- ")) return false;
162
+ return line.slice(4).trim().length > 0;
104
163
  }
105
164
 
106
165
  // The command text of a bash block, or nothing. V2 wraps the command and its
107
- // output in one box, and the command is the first child of that box prefixed
108
- // with "$ ". While the tool is still running the host renders the command in a
109
- // row next to a spinner, so a running command is skipped and picked up by a
110
- // later sweep once it settles.
166
+ // output in one box. The command is the first text-bearing child of that box;
167
+ // only an empty <Show> placeholder may precede it.
168
+ //
169
+ // Scanning every grandchild for "$ " was wrong in two ways: while a command is
170
+ // still running the host puts it in a row next to a spinner (no "$ " yet), and
171
+ // its output may legitimately begin with "$ " -- which the old code then folded
172
+ // instead of the command. A non-leaf before the text or an unprefixed first
173
+ // text means this is a running/other block, not an adoptable shell block.
111
174
  function shellCommand(block) {
112
175
  for (const child of children(block)) {
113
176
  const inner = children(child);
114
- if (!inner.length) continue;
115
- // solid's <Show> leaves childless placeholders, so scan rather than assume
116
- // the command is the first grandchild.
177
+ // command plus at least its output/hint sibling
178
+ if (inner.length < 2) continue;
117
179
  for (const node of inner) {
118
180
  const text = plain(node);
119
- if (typeof text === "string" && text.startsWith(PROMPT)) return { node, text };
181
+ if (typeof text === "string") {
182
+ return text.startsWith(PROMPT) ? { node, text } : undefined;
183
+ }
184
+ // A spinner/command row is a box, not a leaf placeholder. Wait for a
185
+ // later sweep, once the command is rendered as text with its prompt.
186
+ if (children(node).length) return undefined;
120
187
  }
121
188
  }
122
189
  return;
@@ -124,10 +191,21 @@ function shellCommand(block) {
124
191
 
125
192
  // Rows the command occupies, not lines it contains: a single-line command long
126
193
  // enough to wrap is exactly the kind worth folding. `height` is the laid-out
127
- // row count and reads 0 before the first layout, so the line count is the floor.
194
+ // row count, but the host already clamps it to two, so it can never prove a
195
+ // long command is long. The intrinsic height comes from the text width instead.
128
196
  function commandRows(node, text) {
129
- const height = typeof node?.height === "number" ? node.height : 0;
130
- return Math.max(text.split("\n").length, height);
197
+ const lines = text.split("\n");
198
+ let rows = lines.length;
199
+ const width =
200
+ typeof node?.width === "number" && Number.isFinite(node.width) ? Math.trunc(node.width) : 0;
201
+ if (width > 0 && node?.wrapMode !== "none") {
202
+ let wrapped = 0;
203
+ for (const line of lines) wrapped += Math.max(1, Math.ceil(line.length / width));
204
+ rows = Math.max(rows, wrapped);
205
+ }
206
+ const height =
207
+ typeof node?.height === "number" && Number.isFinite(node.height) ? node.height : 0;
208
+ return Math.max(rows, height);
131
209
  }
132
210
 
133
211
  function bulk(node, found = []) {
@@ -140,31 +218,67 @@ function bulk(node, found = []) {
140
218
  return found;
141
219
  }
142
220
 
143
- // The session transcript is the scrollbox that sticks to the bottom. Since V2
144
- // the shell output viewer is also a sticky scrollbox, but it is a dialog and
145
- // must never be mistaken for the transcript, so it is excluded by id. Staying
146
- // inside the transcript is what keeps the permission dialog's diff preview
147
- // untouched -- you should always see in full what you are about to approve.
148
- const SHELL_VIEWER_ID = "shell-output-scroll";
149
-
150
- function isTranscript(node) {
151
- return (
152
- typeof node?.scrollTo === "function" &&
153
- typeof node?.scrollHeight === "number" &&
154
- node.stickyScroll === true &&
155
- node.stickyStart === "bottom" &&
156
- node.id !== SHELL_VIEWER_ID
157
- );
221
+ // The session transcript is a scrollbox that sticks to the bottom. It is not
222
+ // the only one: the shell output viewer, the diff help panel and the execute
223
+ // detail panel are scrollboxes too. They are excluded by id, and the remaining
224
+ // candidates are scored by session-part descendants and scroll height rather
225
+ // than taking the first match.
226
+ function isScrollbox(node) {
227
+ try {
228
+ return typeof node?.scrollTo === "function" && typeof node?.scrollHeight === "number";
229
+ } catch {
230
+ return false;
231
+ }
158
232
  }
159
233
 
160
- function findTranscript(node) {
161
- if (!node || node.isDestroyed) return;
162
- if (isTranscript(node)) return node;
163
- if (typeof node.scrollTo === "function") return;
164
- for (const child of children(node)) {
165
- const hit = findTranscript(child);
166
- if (hit) return hit;
234
+ function excludedScrollbox(node) {
235
+ const id = typeof node?.id === "string" ? node.id : "";
236
+ if (!id) return false;
237
+ if (EXCLUDED_SCROLL_IDS.has(id)) return true;
238
+ return /(?:^|[-_])(dialog|panel|popup|viewer)(?:$|[-_])/.test(id);
239
+ }
240
+
241
+ // Bit flags: 1 = a session-part id, 2 = a message id. Collected bottom-up in
242
+ // one walk so each scrollbox only sees evidence inside its own subtree.
243
+ function collectScrollboxes(node, found, depth = 0) {
244
+ if (!node || node.isDestroyed || depth > 1000) return 0;
245
+ const id = typeof node.id === "string" ? node.id : "";
246
+ let flags = id.startsWith("session-part:") ? 1 : id.startsWith("msg_") ? 2 : 0;
247
+ for (const child of children(node)) flags |= collectScrollboxes(child, found, depth + 1);
248
+ if (isScrollbox(node) && !excludedScrollbox(node)) found.push({ node, flags });
249
+ return flags;
250
+ }
251
+
252
+ function transcriptScore(node, flags) {
253
+ const sticky = node.stickyScroll === true;
254
+ const bottom = node.stickyStart === "bottom";
255
+ // Scrolled up, the host can report stickyScroll=false; a bottom anchor plus
256
+ // session-part evidence is still enough to identify the transcript.
257
+ if (!sticky && !(bottom && flags)) return -Infinity;
258
+ let score = 0;
259
+ if (flags & 1) score += 1000;
260
+ if (flags & 2) score += 600;
261
+ if (sticky) score += 50;
262
+ if (bottom) score += 50;
263
+ if (typeof node.scrollHeight === "number" && Number.isFinite(node.scrollHeight)) {
264
+ score += Math.min(100, Math.max(0, Math.log2(node.scrollHeight + 1) * 4));
265
+ }
266
+ return score;
267
+ }
268
+
269
+ function findTranscript(root) {
270
+ const found = [];
271
+ collectScrollboxes(root, found);
272
+ let best;
273
+ let bestScore = -Infinity;
274
+ for (const candidate of found) {
275
+ const score = transcriptScore(candidate.node, candidate.flags);
276
+ if (score > bestScore) {
277
+ best = candidate.node;
278
+ bestScore = score;
279
+ }
167
280
  }
281
+ return best;
168
282
  }
169
283
 
170
284
  // A V2 BlockTool renders the header first: a row box whose first two children
@@ -212,21 +326,127 @@ function summarise(nodes) {
212
326
  let lines = 0;
213
327
  let diffs = 0;
214
328
  for (const node of nodes) {
329
+ if (!node || node.isDestroyed) continue;
215
330
  if (isDiff(node)) {
216
331
  diffs++;
217
- for (const line of node.diff.split("\n")) {
218
- if (line.startsWith("+++") || line.startsWith("---")) continue;
332
+ const lines = node.diff.split("\n");
333
+ for (let i = 0; i < lines.length; i++) {
334
+ const line = lines[i];
335
+ // A file header is the marker plus a path, and the --- / +++ pair is
336
+ // what distinguishes it from a content line that merely starts with
337
+ // the same characters.
338
+ if (isDiffHeader(line)) {
339
+ const previous = lines[i - 1];
340
+ const next = lines[i + 1];
341
+ if (line.startsWith("--- ") && next && isDiffHeader(next) && next.startsWith("+++ ")) {
342
+ continue;
343
+ }
344
+ if (
345
+ line.startsWith("+++ ") &&
346
+ previous &&
347
+ isDiffHeader(previous) &&
348
+ previous.startsWith("--- ")
349
+ ) {
350
+ continue;
351
+ }
352
+ }
219
353
  if (line.startsWith("+")) added++;
220
354
  else if (line.startsWith("-")) removed++;
221
355
  }
222
356
  continue;
223
357
  }
224
- lines += node.content.split("\n").length;
358
+ lines += contentLines(textValue(node) ?? "");
225
359
  }
226
360
  if (diffs) return { size: added + removed, label: `+${added} −${removed}` };
227
361
  return { size: lines, label: `${lines} ${lines === 1 ? "line" : "lines"}` };
228
362
  }
229
363
 
364
+ // --- host style ----------------------------------------------------------
365
+ //
366
+ // Setter-only props in 0.5 became getters in OpenTUI 0.8. Capture what the host
367
+ // actually set instead of assuming BlockTool's values, then put it back when a
368
+ // block unfolds or the plugin is disabled.
369
+
370
+ function readStyle(node, getter, args, property, fallback) {
371
+ try {
372
+ if (typeof node?.[getter] === "function") {
373
+ const value = node[getter](...args);
374
+ if (value !== undefined) return value;
375
+ }
376
+ } catch {}
377
+ try {
378
+ const value = node?.[property];
379
+ if (value !== undefined) return value;
380
+ } catch {}
381
+ return fallback;
382
+ }
383
+
384
+ function maxHeightOf(node, fallback) {
385
+ const value = readStyle(node, "getMaxHeight", [], "maxHeight", fallback);
386
+ // "auto" is the unset value in Yoga. For a shell command the host's real
387
+ // clamp (two rows) is the better restore point than an explicit auto.
388
+ return value === "auto" ? fallback : value;
389
+ }
390
+
391
+ function chromeOf(node) {
392
+ return {
393
+ gap: readStyle(node, "getGap", [GUTTER_ROW], "gap", 1),
394
+ paddingTop: readStyle(node, "getPadding", [EDGE_TOP], "paddingTop", 1),
395
+ paddingBottom: readStyle(node, "getPadding", [EDGE_BOTTOM], "paddingBottom", 1),
396
+ };
397
+ }
398
+
399
+ // --- options -------------------------------------------------------------
400
+
401
+ function plainOptions(value) {
402
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
403
+ }
404
+
405
+ function integerOption(value, fallback) {
406
+ const number = Number(value);
407
+ if (!Number.isFinite(number)) return fallback;
408
+ return Math.min(MAX_ROWS, Math.max(0, Math.trunc(number)));
409
+ }
410
+
411
+ function describeOptions(value) {
412
+ try {
413
+ return JSON.stringify(value);
414
+ } catch {
415
+ return "?";
416
+ }
417
+ }
418
+
419
+ function withTimeout(promise, ms) {
420
+ return new Promise((resolve) => {
421
+ let done = false;
422
+ let timer;
423
+ const finish = (value) => {
424
+ if (done) return;
425
+ done = true;
426
+ clearTimeout(timer);
427
+ resolve(value);
428
+ };
429
+ timer = setTimeout(() => finish(undefined), ms);
430
+ Promise.resolve(promise).then(finish, () => finish(undefined));
431
+ });
432
+ }
433
+
434
+ // Best-effort: the server part answers with the options OpenCode gave it from
435
+ // opencode.json. A missing/older server or a host without portable RPC just
436
+ // leaves the cli.json values (possibly none) in place.
437
+ async function bridgeOptions(context) {
438
+ try {
439
+ if (typeof context?.client?.rpc !== "function") return {};
440
+ const client = context.client.rpc(OPTIONS_RPC);
441
+ if (typeof client?.get !== "function") return {};
442
+ const result = await withTimeout(client.get({}), BRIDGE_TIMEOUT_MS);
443
+ const options = plainOptions(result?.options);
444
+ return options;
445
+ } catch {
446
+ return {};
447
+ }
448
+ }
449
+
230
450
  // Debug-only: one line per renderable, for aligning the matcher with a real
231
451
  // transcript when a live run reports `blocks: 0`. Truncated so a big session
232
452
  // still writes quickly.
@@ -261,31 +481,100 @@ export const PLUGIN_ID = "opencode-fold-diffs";
261
481
 
262
482
  export default {
263
483
  id: PLUGIN_ID,
264
- setup(context) {
265
- const opts = { ...DEFAULTS, ...(context.options ?? {}) };
266
- const peek = Math.max(0, Number(opts.lines) || 0);
267
- const floor = Math.max(0, Number(opts.min_lines) || 0);
484
+ async setup(context) {
485
+ const cliOptions = plainOptions(context.options);
486
+ const serverOptions = await bridgeOptions(context);
487
+ // cli.json is the local TUI's own configuration and wins on a conflict;
488
+ // opencode.json supplies everything it does not set.
489
+ const opts = { ...DEFAULTS, ...serverOptions, ...cliOptions };
490
+ const peek = integerOption(opts.lines, DEFAULTS.lines);
491
+ const floor = integerOption(opts.min_lines, DEFAULTS.min_lines);
268
492
  const shell = opts.bash === true;
269
- const shellPeek = Math.max(0, Number(opts.bash_lines) || 0);
493
+ const shellPeek = integerOption(opts.bash_lines, DEFAULTS.bash_lines);
494
+ const key = typeof opts.key === "string" ? opts.key : "";
270
495
 
271
- // Folded blocks, by their block renderable. WeakMap so a session switch,
272
- // which destroys the renderables, drops the state with them.
496
+ // Folded state by stable part id, so a block that is remounted keeps the
497
+ // mode it was toggled to. WeakMap state below is per-renderable and dies
498
+ // with the renderable; this Map is the part's memory.
499
+ const foldedByKey = new Map();
273
500
  const known = new WeakMap();
501
+ // WeakMap has no iterator, so keep a strong set of adopted blocks only for
502
+ // cleanup. Destroyed blocks are pruned on the sweep to avoid retaining a
503
+ // closed session's renderables.
504
+ const adopted = new Set();
274
505
  // The mode new blocks adopt. ctrl+o flips it, so "expand everything" also
275
506
  // means "and stop folding what arrives next", the way a verbose toggle works.
276
507
  let folding = opts.folded !== false;
277
508
  // Set once the header rewrite is proven not to take, so we stop retrying it.
278
509
  let titles = opts.stats !== false;
510
+ let disposed = false;
279
511
 
280
512
  let cached;
513
+ let routed;
514
+
515
+ function currentRoute() {
516
+ try {
517
+ return context.ui.router.current();
518
+ } catch {
519
+ return undefined;
520
+ }
521
+ }
522
+
523
+ // Route/session changes destroy or replace the transcript and invalidate
524
+ // per-session fold memory. Detecting it here also makes the cache safe to
525
+ // keep between the (now much slower) fallback sweeps.
526
+ function refreshRoute() {
527
+ const route = currentRoute();
528
+ const token =
529
+ route?.type === "session" ? `session:${route.sessionID ?? ""}` : String(route?.type ?? "");
530
+ if (token !== routed) {
531
+ routed = token;
532
+ cached = undefined;
533
+ foldedByKey.clear();
534
+ }
535
+ return token;
536
+ }
537
+
538
+ function attached(node) {
539
+ const root = context.renderer.root;
540
+ let current = node;
541
+ for (let i = 0; current && i < 2000; i++) {
542
+ if (current === root) return true;
543
+ current = current.parent;
544
+ }
545
+ return false;
546
+ }
547
+
281
548
  function transcript() {
282
- if (cached && !cached.isDestroyed) return cached;
549
+ refreshRoute();
550
+ if (cached && !cached.isDestroyed && attached(cached)) return cached;
283
551
  cached = findTranscript(context.renderer.root);
284
552
  return cached;
285
553
  }
286
554
 
555
+ function setTitle(state, fold) {
556
+ if (!state.title) return;
557
+ const next = fold && titles ? `${state.text} ${state.suffix}` : state.text;
558
+ if (plain(state.title) === next) {
559
+ state.titleChanged = fold && titles;
560
+ return;
561
+ }
562
+ try {
563
+ state.title.content = next;
564
+ } catch {
565
+ titles = false;
566
+ return;
567
+ }
568
+ const applied = plain(state.title);
569
+ if (fold && titles) {
570
+ if (applied === next) state.titleChanged = true;
571
+ else titles = false;
572
+ } else {
573
+ state.titleChanged = applied !== state.text;
574
+ }
575
+ }
576
+
287
577
  function apply(state, fold) {
288
- state.folded = fold;
289
578
  state.body.forEach((node, index) => {
290
579
  try {
291
580
  // `visible = false` sets Yoga display:none, which removes the body
@@ -293,36 +582,53 @@ export default {
293
582
  // clamped the box to zero rows but OpenTUI still painted the diff.
294
583
  // A positive `lines` peek keeps the first body visible instead.
295
584
  const peekRow = fold && index === 0 && state.peek > 0;
296
- node.visible = !fold || peekRow;
297
- node.maxHeight = peekRow ? state.peek : undefined;
585
+ node.visible = fold ? peekRow : (state.visible[index] ?? true);
586
+ node.maxHeight = peekRow ? state.peek : state.maxHeight[index];
298
587
  node.overflow = fold ? "hidden" : state.overflow[index];
299
588
  } catch {}
300
589
  });
301
- // With the body at zero height, the block's own padding and the gap it
590
+ // With the body at zero height the block's own padding and the gap it
302
591
  // keeps between children are all that is left. Collapse the chrome too so
303
- // a folded block reads as the single row it now is. The restored values
304
- // are BlockTool's own (paddingTop/Bottom 1, gap 1) because opentui gives
305
- // these setters no getters to read the originals back from. Never for a
592
+ // a folded block reads as the single row it now is, restoring exactly the
593
+ // values the host had rather than BlockTool's assumed 1/1/1. Never for a
306
594
  // shell block: its output is a sibling of the command and stays on screen,
307
595
  // so the chrome is still holding something up.
308
596
  if (state.chrome) {
309
597
  try {
310
- state.block.gap = fold ? 0 : 1;
311
- state.block.paddingTop = fold ? 0 : 1;
312
- state.block.paddingBottom = fold ? 0 : 1;
598
+ state.block.gap = fold ? 0 : state.chromeStyle.gap;
599
+ state.block.paddingTop = fold ? 0 : state.chromeStyle.paddingTop;
600
+ state.block.paddingBottom = fold ? 0 : state.chromeStyle.paddingBottom;
313
601
  } catch {}
314
602
  }
315
- if (!titles || !state.title) return;
316
- const next = fold ? `${state.text} ${state.suffix}` : state.text;
603
+ // Restore the label even after `titles` was turned off globally: a block
604
+ // whose suffix was applied before the failure must not keep it.
605
+ setTitle(state, fold);
606
+ }
607
+
608
+ function setFolded(state, fold) {
609
+ state.folded = fold;
610
+ if (state.key) foldedByKey.set(state.key, fold);
611
+ apply(state, fold);
612
+ }
613
+
614
+ function selection() {
317
615
  try {
318
- state.title.content = next;
616
+ return context.renderer.getSelection?.()?.getSelectedText?.();
319
617
  } catch {
320
- titles = false;
321
- return;
618
+ return undefined;
322
619
  }
323
- // The host owns that text node. If solid is not letting go of it there is
324
- // nothing to be gained by asking again on every block.
325
- if (plain(state.title) !== next) titles = false;
620
+ }
621
+
622
+ function installHandler(state, target, stop) {
623
+ state.handlerTarget = target;
624
+ state.handler = (event) => {
625
+ if (disposed || state.block.isDestroyed) return;
626
+ // Copy-on-select is a drag ending on the block; that is not a click.
627
+ if (selection()) return;
628
+ setFolded(state, !state.folded);
629
+ if (stop) event?.stopPropagation?.();
630
+ };
631
+ target.onMouseUp = state.handler;
326
632
  }
327
633
 
328
634
  function adoptDiff(block) {
@@ -347,41 +653,61 @@ export default {
347
653
  const state = {
348
654
  block,
349
655
  body,
656
+ visible: body.map((node) => node.visible),
657
+ maxHeight: body.map((node) => maxHeightOf(node, undefined)),
350
658
  overflow: body.map((node) => node.overflow),
351
659
  title: header.node,
352
660
  text: header.label,
353
661
  suffix: `${stats.label} · click to expand`,
662
+ signature: `${stats.size}|${stats.label}`,
354
663
  peek,
355
664
  chrome: peek === 0,
665
+ chromeStyle: chromeOf(block),
356
666
  folded: false,
667
+ titleChanged: false,
668
+ key: stableKey(block),
669
+ kind: "diff",
357
670
  };
671
+ const remembered = state.key === undefined ? undefined : foldedByKey.get(state.key);
672
+ state.folded = remembered === undefined ? folding : remembered;
673
+ if (state.key !== undefined) foldedByKey.set(state.key, state.folded);
358
674
  known.set(block, state);
359
-
360
- block.onMouseUp = () => {
361
- // Copy-on-select is a drag ending on the block; that is not a click.
362
- if (context.renderer.getSelection?.()?.getSelectedText?.()) return;
363
- apply(state, !state.folded);
364
- };
365
-
366
- if (folding) apply(state, true);
675
+ adopted.add(block);
676
+ installHandler(state, block, false);
677
+ apply(state, state.folded);
367
678
  return true;
368
679
  }
369
680
 
370
681
  function adoptShell(block) {
371
682
  const found = shellCommand(block);
372
- if (!found) return;
373
- if (commandRows(found.node, found.text) < floor) return;
683
+ if (!found) return false;
684
+ if (commandRows(found.node, found.text) < floor) return false;
374
685
 
375
686
  const state = {
376
687
  block,
377
688
  body: [found.node],
689
+ visible: [found.node.visible],
690
+ // The host clamps a running/long command to two rows. If the getter is
691
+ // unavailable, two is still a better restore point than undefined.
692
+ maxHeight: [maxHeightOf(found.node, 2)],
378
693
  overflow: [found.node.overflow],
379
694
  title: undefined,
695
+ text: "",
696
+ suffix: "",
697
+ signature: "",
380
698
  peek: shellPeek,
381
699
  chrome: false,
700
+ chromeStyle: undefined,
382
701
  folded: false,
702
+ titleChanged: false,
703
+ key: stableKey(block),
704
+ kind: "shell",
383
705
  };
706
+ const remembered = state.key === undefined ? undefined : foldedByKey.get(state.key);
707
+ state.folded = remembered === undefined ? folding : remembered;
708
+ if (state.key !== undefined) foldedByKey.set(state.key, state.folded);
384
709
  known.set(block, state);
710
+ adopted.add(block);
385
711
 
386
712
  // The block's own onMouseUp belongs to the host here -- it is what expands
387
713
  // the collapsed command/output -- and opentui declares the handler as a
@@ -389,22 +715,44 @@ export default {
389
715
  // command text instead and stop the event on it: clicking the command
390
716
  // folds the command, clicking anywhere else in the block still does
391
717
  // exactly what it did before this plugin loaded.
392
- found.node.onMouseUp = (event) => {
393
- if (context.renderer.getSelection?.()?.getSelectedText?.()) return;
394
- apply(state, !state.folded);
395
- event?.stopPropagation?.();
396
- };
397
-
398
- if (folding) apply(state, true);
718
+ installHandler(state, found.node, true);
719
+ apply(state, state.folded);
720
+ return true;
399
721
  }
400
722
 
401
723
  function adopt(block) {
402
- if (adoptDiff(block)) return;
403
- if (shell) adoptShell(block);
724
+ if (adoptDiff(block)) return true;
725
+ if (shell) return adoptShell(block);
726
+ return false;
727
+ }
728
+
729
+ // Recompute a folded block's stats without dropping its adoption. A tool
730
+ // that streams more content into the same renderables used to keep the
731
+ // first summary forever because the sweep skipped every known block.
732
+ function refreshStats(state) {
733
+ if (state.kind !== "diff") return;
734
+ const heavy = [];
735
+ for (const child of state.body) bulk(child, heavy);
736
+ if (!heavy.length) return;
737
+ const stats = summarise(heavy);
738
+ const signature = `${stats.size}|${stats.label}`;
739
+ if (signature === state.signature) return;
740
+ state.signature = signature;
741
+ state.suffix = `${stats.label} · click to expand`;
742
+ if (state.folded) setTitle(state, true);
404
743
  }
405
744
 
406
745
  function sweep() {
407
- if (context.ui.router.current().type !== "session") return;
746
+ if (disposed) return;
747
+ for (const block of adopted) {
748
+ if (block.isDestroyed) {
749
+ adopted.delete(block);
750
+ continue;
751
+ }
752
+ const state = known.get(block);
753
+ if (state) refreshStats(state);
754
+ }
755
+ if (!refreshRoute().startsWith("session:")) return;
408
756
  const box = transcript();
409
757
  if (!box) return;
410
758
  for (const block of scan(box, [], shell)) {
@@ -421,7 +769,7 @@ export default {
421
769
  for (const block of scan(box, [], shell)) {
422
770
  const state = known.get(block);
423
771
  if (!state || state.folded === fold) continue;
424
- apply(state, fold);
772
+ setFolded(state, fold);
425
773
  count++;
426
774
  }
427
775
  return count;
@@ -429,7 +777,7 @@ export default {
429
777
 
430
778
  let pending;
431
779
  function schedule() {
432
- if (pending) return;
780
+ if (disposed || pending) return;
433
781
  pending = setTimeout(() => {
434
782
  pending = undefined;
435
783
  sweep();
@@ -437,17 +785,28 @@ export default {
437
785
  }
438
786
 
439
787
  // V1's message.part.updated / message.updated are legacy schemas in V2 --
440
- // the client emits the session.* events instead, so the V1 names were a
441
- // silent no-op and a new block only folded on the sweeping timer.
788
+ // the client emits the session.* events instead. `session.message.content.
789
+ // updated` is not exposed by the public TUI event union, so the tool events
790
+ // (plus the step and execution endings) are the ones this plugin can rely
791
+ // on; the slow fallback sweep covers anything else.
442
792
  const events = [
443
- "session.message.content.updated", // a part was appended or replaced
444
- "session.tool.success", // a tool body (diff or file) appeared
793
+ "session.tool.called", // a tool block was added to the transcript
794
+ "session.tool.progress", // its body may have grown or changed
795
+ "session.tool.success", // a tool body (diff or file) is final
445
796
  "session.tool.failed", // a failed tool still renders its block
446
797
  "session.step.ended", // the step's final content has arrived
447
798
  "session.execution.succeeded", // the turn settled; take a final look
448
799
  ];
449
800
  if (shell) events.push("session.shell.ended"); // the command now carries its "$ " prefix
450
- const offs = events.map((name) => context.data.on(name, schedule));
801
+ // An older host may not know every event name; a missing subscription must
802
+ // not take the whole plugin down.
803
+ const offs = events.map((name) => {
804
+ try {
805
+ return context.data.on(name, schedule);
806
+ } catch {
807
+ return undefined;
808
+ }
809
+ });
451
810
  const timer = setInterval(sweep, SWEEP_MS);
452
811
  schedule();
453
812
 
@@ -465,8 +824,9 @@ export default {
465
824
  title: "Fold / unfold file diffs",
466
825
  group: "Plugin",
467
826
  palette: true,
468
- bind: opts.key || false,
827
+ bind: key || false,
469
828
  run() {
829
+ if (disposed) return;
470
830
  sweep();
471
831
  const fold = !folding;
472
832
  const changed = all(fold);
@@ -487,13 +847,16 @@ export default {
487
847
  group: "Plugin",
488
848
  palette: true,
489
849
  run() {
490
- const box = transcript();
850
+ if (disposed) return;
851
+ const inSession = refreshRoute().startsWith("session:");
852
+ const box = inSession ? transcript() : undefined;
491
853
  const blocks = box ? scan(box, [], shell) : [];
492
854
  const folded = blocks.filter((block) => known.get(block)?.folded).length;
855
+ const options = describeOptions(opts);
493
856
  let dump;
494
857
  try {
495
858
  const summary = [
496
- `transcript=${Boolean(box)} blocks=${blocks.length} folded=${folded} stats=${titles ? "on" : "off"}`,
859
+ `transcript=${Boolean(box)} blocks=${blocks.length} folded=${folded} stats=${titles ? "on" : "off"} options=${options}`,
497
860
  ];
498
861
  for (const block of blocks) {
499
862
  const state = known.get(block);
@@ -515,8 +878,10 @@ export default {
515
878
  if (target) {
516
879
  const lines = [];
517
880
  dumpNode(box ?? context.renderer.root, 0, lines, 600);
518
- dump = target;
519
881
  writeFileSync(target, summary.concat("", lines).join("\n"));
882
+ // Only advertise the path once the write succeeded; the
883
+ // catch below keeps a failed write from taking down diagnose.
884
+ dump = target;
520
885
  }
521
886
  } catch {}
522
887
  context.ui.toast.show({
@@ -524,32 +889,58 @@ export default {
524
889
  variant: box ? "info" : "warning",
525
890
  duration: 5000,
526
891
  message: box
527
- ? `transcript: yes · blocks: ${blocks.length} · folded: ${folded} · stats: ${titles ? "on" : "off"}` +
892
+ ? `transcript: yes · blocks: ${blocks.length} · folded: ${folded} · stats: ${titles ? "on" : "off"} · options: ${options}` +
528
893
  (dump ? ` · dump: ${dump}` : "")
529
- : "transcript: not found — open a session first",
894
+ : `transcript: not found — open a session first · options: ${options}`,
530
895
  });
531
896
  },
532
897
  },
533
898
  ],
534
- bindings: opts.key ? ["opencode-fold-diffs.toggle"] : [],
899
+ bindings: key ? ["opencode-fold-diffs.toggle"] : [],
535
900
  }));
536
901
  }
537
902
  let stopCommands = () => {};
903
+ const registerCommandsSafely = () => {
904
+ try {
905
+ registerCommands();
906
+ } catch {}
907
+ };
538
908
  if (supportsSetupKeymap(context.app?.version)) {
539
- registerCommands();
909
+ registerCommandsSafely();
540
910
  } else {
541
911
  function FoldCommands() {
542
- registerCommands();
912
+ registerCommandsSafely();
543
913
  return null;
544
914
  }
545
- stopCommands = context.ui.slot({ append: "app", render: () => FoldCommands() });
915
+ try {
916
+ stopCommands = context.ui.slot({ append: "app", render: () => FoldCommands() });
917
+ } catch {}
546
918
  }
547
919
 
548
920
  return () => {
549
- stopCommands();
921
+ if (disposed) return;
922
+ disposed = true;
923
+ if (typeof stopCommands === "function") stopCommands();
550
924
  clearInterval(timer);
551
- if (pending) clearTimeout(pending);
925
+ if (pending) {
926
+ clearTimeout(pending);
927
+ pending = undefined;
928
+ }
552
929
  for (const off of offs) if (typeof off === "function") off();
930
+ // The host owns these renderables and keeps them after the plugin is
931
+ // disabled, so undo our folds and remove our handlers instead of leaving
932
+ // the transcript collapsed or a late click able to re-fold it.
933
+ for (const block of adopted) {
934
+ const state = known.get(block);
935
+ if (!state) continue;
936
+ try {
937
+ if (state.handlerTarget) state.handlerTarget.onMouseUp = undefined;
938
+ } catch {}
939
+ if (state.folded || state.titleChanged) apply(state, false);
940
+ }
941
+ adopted.clear();
942
+ foldedByKey.clear();
943
+ cached = undefined;
553
944
  };
554
945
  },
555
946
  };