@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.
- package/README.md +15 -12
- package/index.js +37 -6
- package/package.json +2 -4
- package/rpc.js +20 -0
- 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) —
|
|
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
|
|
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.**
|
|
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 —
|
|
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`
|
|
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
|
|
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, `
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
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
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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
|
-
|
|
115
|
-
|
|
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"
|
|
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
|
|
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
|
|
130
|
-
|
|
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
|
|
144
|
-
// the shell output viewer
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
function
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
|
267
|
-
const
|
|
268
|
-
|
|
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 =
|
|
493
|
+
const shellPeek = integerOption(opts.bash_lines, DEFAULTS.bash_lines);
|
|
494
|
+
const key = typeof opts.key === "string" ? opts.key : "";
|
|
271
495
|
|
|
272
|
-
// Folded
|
|
273
|
-
//
|
|
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
|
-
|
|
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 =
|
|
302
|
-
node.maxHeight = peekRow ? state.peek :
|
|
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
|
|
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
|
|
309
|
-
//
|
|
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 :
|
|
316
|
-
state.block.paddingTop = fold ? 0 :
|
|
317
|
-
state.block.paddingBottom = fold ? 0 :
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
616
|
+
return context.renderer.getSelection?.()?.getSelectedText?.();
|
|
324
617
|
} catch {
|
|
325
|
-
|
|
326
|
-
return;
|
|
618
|
+
return undefined;
|
|
327
619
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
|
449
|
-
//
|
|
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.
|
|
452
|
-
"session.tool.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
:
|
|
894
|
+
: `transcript: not found — open a session first · options: ${options}`,
|
|
540
895
|
});
|
|
541
896
|
},
|
|
542
897
|
},
|
|
543
898
|
],
|
|
544
|
-
bindings:
|
|
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
|
-
|
|
909
|
+
registerCommandsSafely();
|
|
550
910
|
} else {
|
|
551
911
|
function FoldCommands() {
|
|
552
|
-
|
|
912
|
+
registerCommandsSafely();
|
|
553
913
|
return null;
|
|
554
914
|
}
|
|
555
|
-
|
|
915
|
+
try {
|
|
916
|
+
stopCommands = context.ui.slot({ append: "app", render: () => FoldCommands() });
|
|
917
|
+
} catch {}
|
|
556
918
|
}
|
|
557
919
|
|
|
558
920
|
return () => {
|
|
559
|
-
|
|
921
|
+
if (disposed) return;
|
|
922
|
+
disposed = true;
|
|
923
|
+
if (typeof stopCommands === "function") stopCommands();
|
|
560
924
|
clearInterval(timer);
|
|
561
|
-
if (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
|
|
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
|
|
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
|
};
|