@cardinal4/opencode-fold-diffs 0.3.7 → 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 +15 -12
  2. package/index.js +37 -6
  3. package/package.json +2 -4
  4. package/rpc.js +20 -0
  5. package/tui.js +488 -114
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, tested with `opencode 2.2.0`). 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,7 @@ 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
- That installs the package and adds it to the global `opencode.json`. The package ships a server entry (`index.js`, a no-op that gives the plugin an id) and a TUI entry (`tui.js`); the TUI part loads automatically with the server entry, so no second configuration is needed.
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
37
 
38
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:
39
39
 
@@ -44,7 +44,7 @@ If the terminal connects to a **remote server**, list the package in `~/.config/
44
44
  }
45
45
  ```
46
46
 
47
- Options go on the plugin entry in either file:
47
+ Options can go on the plugin entry in either file:
48
48
 
49
49
  ```json
50
50
  {
@@ -61,6 +61,8 @@ Options go on the plugin entry in either file:
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**, re-checked against **v2.0.27** and **v2.2.0**; 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,7 @@ 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. |
128
131
 
129
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.
130
133
 
@@ -138,7 +141,7 @@ The port follows V2's new plugin API and render tree, so the V1 code could not b
138
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).
139
142
  - Event names are V2's `session.*` events; `message.updated` / `message.part.updated` are legacy schemas that never fire.
140
143
  - Tool headers are a label node plus a path node, so detection and the stats suffix target the label.
141
- - `bash` defaults to `false` because V2 trims long commands itself, and config moves from `tui.json` to `opencode.json` (or `cli.json` for a remote TUI).
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).
142
145
 
143
146
  ## License
144
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.7",
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));
167
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
+ }
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,22 +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
- const content = node.content.endsWith("\n") ? node.content.slice(0, -1) : node.content;
225
- lines += content === "" ? 0 : content.split("\n").length;
358
+ lines += contentLines(textValue(node) ?? "");
226
359
  }
227
360
  if (diffs) return { size: added + removed, label: `+${added} −${removed}` };
228
361
  return { size: lines, label: `${lines} ${lines === 1 ? "line" : "lines"}` };
229
362
  }
230
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
+
231
450
  // Debug-only: one line per renderable, for aligning the matcher with a real
232
451
  // transcript when a live run reports `blocks: 0`. Truncated so a big session
233
452
  // still writes quickly.
@@ -262,15 +481,22 @@ export const PLUGIN_ID = "opencode-fold-diffs";
262
481
 
263
482
  export default {
264
483
  id: PLUGIN_ID,
265
- setup(context) {
266
- const opts = { ...DEFAULTS, ...(context.options ?? {}) };
267
- const peek = Math.max(0, Number(opts.lines) || 0);
268
- 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);
269
492
  const shell = opts.bash === true;
270
- 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 : "";
271
495
 
272
- // Folded blocks, by their block renderable. WeakMap so a session switch,
273
- // 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();
274
500
  const known = new WeakMap();
275
501
  // WeakMap has no iterator, so keep a strong set of adopted blocks only for
276
502
  // cleanup. Destroyed blocks are pruned on the sweep to avoid retaining a
@@ -281,16 +507,74 @@ export default {
281
507
  let folding = opts.folded !== false;
282
508
  // Set once the header rewrite is proven not to take, so we stop retrying it.
283
509
  let titles = opts.stats !== false;
510
+ let disposed = false;
284
511
 
285
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
+
286
548
  function transcript() {
287
- if (cached && !cached.isDestroyed) return cached;
549
+ refreshRoute();
550
+ if (cached && !cached.isDestroyed && attached(cached)) return cached;
288
551
  cached = findTranscript(context.renderer.root);
289
552
  return cached;
290
553
  }
291
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
+
292
577
  function apply(state, fold) {
293
- state.folded = fold;
294
578
  state.body.forEach((node, index) => {
295
579
  try {
296
580
  // `visible = false` sets Yoga display:none, which removes the body
@@ -298,36 +582,53 @@ export default {
298
582
  // clamped the box to zero rows but OpenTUI still painted the diff.
299
583
  // A positive `lines` peek keeps the first body visible instead.
300
584
  const peekRow = fold && index === 0 && state.peek > 0;
301
- node.visible = !fold || peekRow;
302
- node.maxHeight = peekRow ? state.peek : undefined;
585
+ node.visible = fold ? peekRow : (state.visible[index] ?? true);
586
+ node.maxHeight = peekRow ? state.peek : state.maxHeight[index];
303
587
  node.overflow = fold ? "hidden" : state.overflow[index];
304
588
  } catch {}
305
589
  });
306
- // 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
307
591
  // keeps between children are all that is left. Collapse the chrome too so
308
- // a folded block reads as the single row it now is. The restored values
309
- // are BlockTool's own (paddingTop/Bottom 1, gap 1) because opentui gives
310
- // 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
311
594
  // shell block: its output is a sibling of the command and stays on screen,
312
595
  // so the chrome is still holding something up.
313
596
  if (state.chrome) {
314
597
  try {
315
- state.block.gap = fold ? 0 : 1;
316
- state.block.paddingTop = fold ? 0 : 1;
317
- 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;
318
601
  } catch {}
319
602
  }
320
- if (!titles || !state.title) return;
321
- 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() {
322
615
  try {
323
- state.title.content = next;
616
+ return context.renderer.getSelection?.()?.getSelectedText?.();
324
617
  } catch {
325
- titles = false;
326
- return;
618
+ return undefined;
327
619
  }
328
- // The host owns that text node. If solid is not letting go of it there is
329
- // nothing to be gained by asking again on every block.
330
- 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;
331
632
  }
332
633
 
333
634
  function adoptDiff(block) {
@@ -352,41 +653,59 @@ export default {
352
653
  const state = {
353
654
  block,
354
655
  body,
656
+ visible: body.map((node) => node.visible),
657
+ maxHeight: body.map((node) => maxHeightOf(node, undefined)),
355
658
  overflow: body.map((node) => node.overflow),
356
659
  title: header.node,
357
660
  text: header.label,
358
661
  suffix: `${stats.label} · click to expand`,
662
+ signature: `${stats.size}|${stats.label}`,
359
663
  peek,
360
664
  chrome: peek === 0,
665
+ chromeStyle: chromeOf(block),
361
666
  folded: false,
667
+ titleChanged: false,
668
+ key: stableKey(block),
669
+ kind: "diff",
362
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);
363
674
  known.set(block, state);
364
675
  adopted.add(block);
365
-
366
- block.onMouseUp = () => {
367
- // Copy-on-select is a drag ending on the block; that is not a click.
368
- if (context.renderer.getSelection?.()?.getSelectedText?.()) return;
369
- apply(state, !state.folded);
370
- };
371
-
372
- if (folding) apply(state, true);
676
+ installHandler(state, block, false);
677
+ apply(state, state.folded);
373
678
  return true;
374
679
  }
375
680
 
376
681
  function adoptShell(block) {
377
682
  const found = shellCommand(block);
378
- if (!found) return;
379
- if (commandRows(found.node, found.text) < floor) return;
683
+ if (!found) return false;
684
+ if (commandRows(found.node, found.text) < floor) return false;
380
685
 
381
686
  const state = {
382
687
  block,
383
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)],
384
693
  overflow: [found.node.overflow],
385
694
  title: undefined,
695
+ text: "",
696
+ suffix: "",
697
+ signature: "",
386
698
  peek: shellPeek,
387
699
  chrome: false,
700
+ chromeStyle: undefined,
388
701
  folded: false,
702
+ titleChanged: false,
703
+ key: stableKey(block),
704
+ kind: "shell",
389
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);
390
709
  known.set(block, state);
391
710
  adopted.add(block);
392
711
 
@@ -396,23 +715,44 @@ export default {
396
715
  // command text instead and stop the event on it: clicking the command
397
716
  // folds the command, clicking anywhere else in the block still does
398
717
  // exactly what it did before this plugin loaded.
399
- found.node.onMouseUp = (event) => {
400
- if (context.renderer.getSelection?.()?.getSelectedText?.()) return;
401
- apply(state, !state.folded);
402
- event?.stopPropagation?.();
403
- };
404
-
405
- if (folding) apply(state, true);
718
+ installHandler(state, found.node, true);
719
+ apply(state, state.folded);
720
+ return true;
406
721
  }
407
722
 
408
723
  function adopt(block) {
409
- if (adoptDiff(block)) return;
410
- 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);
411
743
  }
412
744
 
413
745
  function sweep() {
414
- for (const block of adopted) if (block.isDestroyed) adopted.delete(block);
415
- 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;
416
756
  const box = transcript();
417
757
  if (!box) return;
418
758
  for (const block of scan(box, [], shell)) {
@@ -429,7 +769,7 @@ export default {
429
769
  for (const block of scan(box, [], shell)) {
430
770
  const state = known.get(block);
431
771
  if (!state || state.folded === fold) continue;
432
- apply(state, fold);
772
+ setFolded(state, fold);
433
773
  count++;
434
774
  }
435
775
  return count;
@@ -437,7 +777,7 @@ export default {
437
777
 
438
778
  let pending;
439
779
  function schedule() {
440
- if (pending) return;
780
+ if (disposed || pending) return;
441
781
  pending = setTimeout(() => {
442
782
  pending = undefined;
443
783
  sweep();
@@ -445,17 +785,28 @@ export default {
445
785
  }
446
786
 
447
787
  // V1's message.part.updated / message.updated are legacy schemas in V2 --
448
- // the client emits the session.* events instead, so the V1 names were a
449
- // 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.
450
792
  const events = [
451
- "session.message.content.updated", // a part was appended or replaced
452
- "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
453
796
  "session.tool.failed", // a failed tool still renders its block
454
797
  "session.step.ended", // the step's final content has arrived
455
798
  "session.execution.succeeded", // the turn settled; take a final look
456
799
  ];
457
800
  if (shell) events.push("session.shell.ended"); // the command now carries its "$ " prefix
458
- 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
+ });
459
810
  const timer = setInterval(sweep, SWEEP_MS);
460
811
  schedule();
461
812
 
@@ -473,8 +824,9 @@ export default {
473
824
  title: "Fold / unfold file diffs",
474
825
  group: "Plugin",
475
826
  palette: true,
476
- bind: opts.key || false,
827
+ bind: key || false,
477
828
  run() {
829
+ if (disposed) return;
478
830
  sweep();
479
831
  const fold = !folding;
480
832
  const changed = all(fold);
@@ -495,13 +847,16 @@ export default {
495
847
  group: "Plugin",
496
848
  palette: true,
497
849
  run() {
498
- const box = transcript();
850
+ if (disposed) return;
851
+ const inSession = refreshRoute().startsWith("session:");
852
+ const box = inSession ? transcript() : undefined;
499
853
  const blocks = box ? scan(box, [], shell) : [];
500
854
  const folded = blocks.filter((block) => known.get(block)?.folded).length;
855
+ const options = describeOptions(opts);
501
856
  let dump;
502
857
  try {
503
858
  const summary = [
504
- `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}`,
505
860
  ];
506
861
  for (const block of blocks) {
507
862
  const state = known.get(block);
@@ -534,39 +889,58 @@ export default {
534
889
  variant: box ? "info" : "warning",
535
890
  duration: 5000,
536
891
  message: box
537
- ? `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}` +
538
893
  (dump ? ` · dump: ${dump}` : "")
539
- : "transcript: not found — open a session first",
894
+ : `transcript: not found — open a session first · options: ${options}`,
540
895
  });
541
896
  },
542
897
  },
543
898
  ],
544
- bindings: opts.key ? ["opencode-fold-diffs.toggle"] : [],
899
+ bindings: key ? ["opencode-fold-diffs.toggle"] : [],
545
900
  }));
546
901
  }
547
902
  let stopCommands = () => {};
903
+ const registerCommandsSafely = () => {
904
+ try {
905
+ registerCommands();
906
+ } catch {}
907
+ };
548
908
  if (supportsSetupKeymap(context.app?.version)) {
549
- registerCommands();
909
+ registerCommandsSafely();
550
910
  } else {
551
911
  function FoldCommands() {
552
- registerCommands();
912
+ registerCommandsSafely();
553
913
  return null;
554
914
  }
555
- stopCommands = context.ui.slot({ append: "app", render: () => FoldCommands() });
915
+ try {
916
+ stopCommands = context.ui.slot({ append: "app", render: () => FoldCommands() });
917
+ } catch {}
556
918
  }
557
919
 
558
920
  return () => {
559
- stopCommands();
921
+ if (disposed) return;
922
+ disposed = true;
923
+ if (typeof stopCommands === "function") stopCommands();
560
924
  clearInterval(timer);
561
- if (pending) clearTimeout(pending);
925
+ if (pending) {
926
+ clearTimeout(pending);
927
+ pending = undefined;
928
+ }
562
929
  for (const off of offs) if (typeof off === "function") off();
563
930
  // The host owns these renderables and keeps them after the plugin is
564
- // disabled, so undo our folds instead of leaving the transcript collapsed.
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.
565
933
  for (const block of adopted) {
566
934
  const state = known.get(block);
567
- if (state?.folded) apply(state, false);
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);
568
940
  }
569
941
  adopted.clear();
942
+ foldedByKey.clear();
943
+ cached = undefined;
570
944
  };
571
945
  },
572
946
  };