@cardinal4/opencode-fold-diffs 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -14
- package/index.js +37 -6
- package/package.json +2 -4
- package/rpc.js +20 -0
- package/tui.js +502 -111
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# opencode-fold-diffs
|
|
2
2
|
|
|
3
3
|
> [!NOTE]
|
|
4
|
-
> **Unofficial OpenCode V2 port of [Tanner Bruhn](https://github.com/tannerbruhn)'s [`opencode-fold-diffs`](https://github.com/tannerbruhn/opencode-fold-diffs) (npm: `opencode-fold-diffs`) — the OpenCode V1 plugin.** This fork — [`cardin/opencode-fold-diffs`](https://github.com/cardin/opencode-fold-diffs), published as [`@cardinal4/opencode-fold-diffs`](https://www.npmjs.com/package/@cardinal4/opencode-fold-diffs) —
|
|
4
|
+
> **Unofficial OpenCode V2 port of [Tanner Bruhn](https://github.com/tannerbruhn)'s [`opencode-fold-diffs`](https://github.com/tannerbruhn/opencode-fold-diffs) (npm: `opencode-fold-diffs`) — the OpenCode V1 plugin.** This fork — [`cardin/opencode-fold-diffs`](https://github.com/cardin/opencode-fold-diffs), published as [`@cardinal4/opencode-fold-diffs`](https://www.npmjs.com/package/@cardinal4/opencode-fold-diffs) — targets **OpenCode V2** (`@opencode/cli` 2.x, API-checked against `opencode 2.2.3` and OpenTUI 0.8.x). Install this fork on V2, upstream on V1. Tanner has not reviewed or endorsed it; report bugs here, not upstream.
|
|
5
5
|
|
|
6
6
|
**Every `write`, `edit` and `apply_patch` dumps the whole file or the whole diff into your transcript, and stays there.** This plugin folds those blocks down to their header line — `# Wrote 40 lines · click to expand src/app.ts` — and opens them again on click, or with a fold/unfold-all key if you configure one.
|
|
7
7
|
|
|
@@ -33,7 +33,9 @@ Click the row to open it. If you set the `key` option, that binding folds or unf
|
|
|
33
33
|
opencode plugin add @cardinal4/opencode-fold-diffs
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
That installs the package and adds it to the global `opencode.json`. The package ships a server entry (`index.js`), a portable RPC bridge (`rpc.js`) and a TUI entry (`tui.js`); the TUI part loads automatically with the server entry, so no second configuration is needed.
|
|
37
|
+
|
|
38
|
+
If the terminal connects to a **remote server**, list the package in `~/.config/opencode/cli.json` (or `$XDG_CONFIG_HOME/opencode/cli.json`) instead so the local TUI loads the TUI part. Do not list it in both files:
|
|
37
39
|
|
|
38
40
|
```json
|
|
39
41
|
{
|
|
@@ -42,9 +44,7 @@ Then list it in your CLI config so the terminal loads the TUI entrypoint. Add it
|
|
|
42
44
|
}
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
With options:
|
|
47
|
+
Options can go on the plugin entry in either file:
|
|
48
48
|
|
|
49
49
|
```json
|
|
50
50
|
{
|
|
@@ -61,6 +61,8 @@ With options:
|
|
|
61
61
|
}
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
OpenCode 2.2.3 gives an `opencode.json` server plugin its options but drops them from the TUI entry it derives from the same listing. The server part therefore publishes its options over portable RPC, and the TUI asks for them at startup. If that bridge is unavailable (an older host, or a remote server that never registered it), `cli.json` remains the only source the TUI can read; **Fold diffs: diagnose** reports the effective option object either way. When both files set a key, `cli.json` wins.
|
|
65
|
+
|
|
64
66
|
Restart OpenCode afterwards; plugins load at startup.
|
|
65
67
|
|
|
66
68
|
### From a local checkout
|
|
@@ -103,19 +105,19 @@ Clone this repository and point the plugin entry at the checkout. The package sh
|
|
|
103
105
|
|
|
104
106
|
The V2 TUI plugin API (`@opencode/plugin/tui`) has no slot for message parts, so a plugin cannot render a tool block itself. It has to reach the renderables the host already made:
|
|
105
107
|
|
|
106
|
-
1. **Find the transcript.**
|
|
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** and
|
|
120
|
+
Written against **opencode v2.0.24**, re-checked against **v2.0.27** and **v2.2.0**, and API-checked against **v2.2.3** / OpenTUI 0.8.x; the `session.*` events it listens to exist from v2.0.10 on, so an older V2 install gets the fix without upgrading. The tree-walking, block matching, fold/unfold and toggle logic run green against a mock renderer tree shaped like V2's (`node --test`), and the fold was checked against a real transcript: `visible = false` (Yoga `display: none`) is what actually hides a body, not `maxHeight`.
|
|
119
121
|
|
|
120
122
|
To confirm it loaded, restart (`opencode service restart`) and check `/plugins` lists `opencode-fold-diffs` and `Ctrl+P` → **Fold / unfold file diffs** is in the palette. If nothing folds, run **Fold diffs: diagnose** — the toast reports what the plugin can see:
|
|
121
123
|
|
|
@@ -125,6 +127,9 @@ To confirm it loaded, restart (`opencode service restart`) and check `/plugins`
|
|
|
125
127
|
| `transcript: yes · blocks: 0` | The transcript was found but no file blocks matched — the host render tree differs from what this plugin expects. |
|
|
126
128
|
| `transcript: yes · blocks: N · folded: N` | Detection and folding ran; if the blocks still look expanded, the fold did not take on the renderables. |
|
|
127
129
|
| `stats: off` | Folding works but the header could not be restated (Solid owns the label node). |
|
|
130
|
+
| `options: {...}` | The effective merged options. Compare this with the entry in `opencode.json`/`cli.json`; if it is empty and you configured options in a remote `cli.json`, the local TUI never saw them. |
|
|
131
|
+
|
|
132
|
+
The diagnose dump includes up to 80 characters of text from each renderable, so it can contain prompts or tool output. Redact it before sharing; set `dump: false` to skip writing it.
|
|
128
133
|
|
|
129
134
|
Edits with fewer than `min_lines` changed lines (default 6) are left expanded on purpose — a one-line change is already its own summary. Set `min_lines: 0` to fold every file block.
|
|
130
135
|
|
|
@@ -136,7 +141,7 @@ The port follows V2's new plugin API and render tree, so the V1 code could not b
|
|
|
136
141
|
- `api.renderer` → `context.renderer`, `api.route.current.name` → `context.ui.router.current().type`, `api.event.on` → `context.data.on`, `api.ui.toast` → `context.ui.toast.show`, `api.keymap.registerLayer` → `context.keymap.layer` (called straight from `setup` on 2.0.23+, from an `app`-slot component before that).
|
|
137
142
|
- Event names are V2's `session.*` events; `message.updated` / `message.part.updated` are legacy schemas that never fire.
|
|
138
143
|
- Tool headers are a label node plus a path node, so detection and the stats suffix target the label.
|
|
139
|
-
- `bash` defaults to `false` because V2 trims long commands itself, and config moves from `tui.json` to `cli.json
|
|
144
|
+
- `bash` defaults to `false` because V2 trims long commands itself, and config moves from `tui.json` to `opencode.json` (bridged to the TUI by `rpc.js`) or `cli.json` for a remote TUI (which the TUI reads directly and which wins on conflicts).
|
|
140
145
|
|
|
141
146
|
## License
|
|
142
147
|
|
package/index.js
CHANGED
|
@@ -1,18 +1,49 @@
|
|
|
1
1
|
// Server entrypoint for opencode-fold-diffs.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
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));
|
|
265
|
+
}
|
|
266
|
+
return score;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function findTranscript(root) {
|
|
270
|
+
const found = [];
|
|
271
|
+
collectScrollboxes(root, found);
|
|
272
|
+
let best;
|
|
273
|
+
let bestScore = -Infinity;
|
|
274
|
+
for (const candidate of found) {
|
|
275
|
+
const score = transcriptScore(candidate.node, candidate.flags);
|
|
276
|
+
if (score > bestScore) {
|
|
277
|
+
best = candidate.node;
|
|
278
|
+
bestScore = score;
|
|
279
|
+
}
|
|
167
280
|
}
|
|
281
|
+
return best;
|
|
168
282
|
}
|
|
169
283
|
|
|
170
284
|
// A V2 BlockTool renders the header first: a row box whose first two children
|
|
@@ -212,21 +326,127 @@ function summarise(nodes) {
|
|
|
212
326
|
let lines = 0;
|
|
213
327
|
let diffs = 0;
|
|
214
328
|
for (const node of nodes) {
|
|
329
|
+
if (!node || node.isDestroyed) continue;
|
|
215
330
|
if (isDiff(node)) {
|
|
216
331
|
diffs++;
|
|
217
|
-
|
|
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
|
-
lines += node
|
|
358
|
+
lines += contentLines(textValue(node) ?? "");
|
|
225
359
|
}
|
|
226
360
|
if (diffs) return { size: added + removed, label: `+${added} −${removed}` };
|
|
227
361
|
return { size: lines, label: `${lines} ${lines === 1 ? "line" : "lines"}` };
|
|
228
362
|
}
|
|
229
363
|
|
|
364
|
+
// --- host style ----------------------------------------------------------
|
|
365
|
+
//
|
|
366
|
+
// Setter-only props in 0.5 became getters in OpenTUI 0.8. Capture what the host
|
|
367
|
+
// actually set instead of assuming BlockTool's values, then put it back when a
|
|
368
|
+
// block unfolds or the plugin is disabled.
|
|
369
|
+
|
|
370
|
+
function readStyle(node, getter, args, property, fallback) {
|
|
371
|
+
try {
|
|
372
|
+
if (typeof node?.[getter] === "function") {
|
|
373
|
+
const value = node[getter](...args);
|
|
374
|
+
if (value !== undefined) return value;
|
|
375
|
+
}
|
|
376
|
+
} catch {}
|
|
377
|
+
try {
|
|
378
|
+
const value = node?.[property];
|
|
379
|
+
if (value !== undefined) return value;
|
|
380
|
+
} catch {}
|
|
381
|
+
return fallback;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function maxHeightOf(node, fallback) {
|
|
385
|
+
const value = readStyle(node, "getMaxHeight", [], "maxHeight", fallback);
|
|
386
|
+
// "auto" is the unset value in Yoga. For a shell command the host's real
|
|
387
|
+
// clamp (two rows) is the better restore point than an explicit auto.
|
|
388
|
+
return value === "auto" ? fallback : value;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function chromeOf(node) {
|
|
392
|
+
return {
|
|
393
|
+
gap: readStyle(node, "getGap", [GUTTER_ROW], "gap", 1),
|
|
394
|
+
paddingTop: readStyle(node, "getPadding", [EDGE_TOP], "paddingTop", 1),
|
|
395
|
+
paddingBottom: readStyle(node, "getPadding", [EDGE_BOTTOM], "paddingBottom", 1),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// --- options -------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
function plainOptions(value) {
|
|
402
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function integerOption(value, fallback) {
|
|
406
|
+
const number = Number(value);
|
|
407
|
+
if (!Number.isFinite(number)) return fallback;
|
|
408
|
+
return Math.min(MAX_ROWS, Math.max(0, Math.trunc(number)));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function describeOptions(value) {
|
|
412
|
+
try {
|
|
413
|
+
return JSON.stringify(value);
|
|
414
|
+
} catch {
|
|
415
|
+
return "?";
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function withTimeout(promise, ms) {
|
|
420
|
+
return new Promise((resolve) => {
|
|
421
|
+
let done = false;
|
|
422
|
+
let timer;
|
|
423
|
+
const finish = (value) => {
|
|
424
|
+
if (done) return;
|
|
425
|
+
done = true;
|
|
426
|
+
clearTimeout(timer);
|
|
427
|
+
resolve(value);
|
|
428
|
+
};
|
|
429
|
+
timer = setTimeout(() => finish(undefined), ms);
|
|
430
|
+
Promise.resolve(promise).then(finish, () => finish(undefined));
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Best-effort: the server part answers with the options OpenCode gave it from
|
|
435
|
+
// opencode.json. A missing/older server or a host without portable RPC just
|
|
436
|
+
// leaves the cli.json values (possibly none) in place.
|
|
437
|
+
async function bridgeOptions(context) {
|
|
438
|
+
try {
|
|
439
|
+
if (typeof context?.client?.rpc !== "function") return {};
|
|
440
|
+
const client = context.client.rpc(OPTIONS_RPC);
|
|
441
|
+
if (typeof client?.get !== "function") return {};
|
|
442
|
+
const result = await withTimeout(client.get({}), BRIDGE_TIMEOUT_MS);
|
|
443
|
+
const options = plainOptions(result?.options);
|
|
444
|
+
return options;
|
|
445
|
+
} catch {
|
|
446
|
+
return {};
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
230
450
|
// Debug-only: one line per renderable, for aligning the matcher with a real
|
|
231
451
|
// transcript when a live run reports `blocks: 0`. Truncated so a big session
|
|
232
452
|
// still writes quickly.
|
|
@@ -261,31 +481,100 @@ export const PLUGIN_ID = "opencode-fold-diffs";
|
|
|
261
481
|
|
|
262
482
|
export default {
|
|
263
483
|
id: PLUGIN_ID,
|
|
264
|
-
setup(context) {
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
-
|
|
484
|
+
async setup(context) {
|
|
485
|
+
const cliOptions = plainOptions(context.options);
|
|
486
|
+
const serverOptions = await bridgeOptions(context);
|
|
487
|
+
// cli.json is the local TUI's own configuration and wins on a conflict;
|
|
488
|
+
// opencode.json supplies everything it does not set.
|
|
489
|
+
const opts = { ...DEFAULTS, ...serverOptions, ...cliOptions };
|
|
490
|
+
const peek = integerOption(opts.lines, DEFAULTS.lines);
|
|
491
|
+
const floor = integerOption(opts.min_lines, DEFAULTS.min_lines);
|
|
268
492
|
const shell = opts.bash === true;
|
|
269
|
-
const shellPeek =
|
|
493
|
+
const shellPeek = integerOption(opts.bash_lines, DEFAULTS.bash_lines);
|
|
494
|
+
const key = typeof opts.key === "string" ? opts.key : "";
|
|
270
495
|
|
|
271
|
-
// Folded
|
|
272
|
-
//
|
|
496
|
+
// Folded state by stable part id, so a block that is remounted keeps the
|
|
497
|
+
// mode it was toggled to. WeakMap state below is per-renderable and dies
|
|
498
|
+
// with the renderable; this Map is the part's memory.
|
|
499
|
+
const foldedByKey = new Map();
|
|
273
500
|
const known = new WeakMap();
|
|
501
|
+
// WeakMap has no iterator, so keep a strong set of adopted blocks only for
|
|
502
|
+
// cleanup. Destroyed blocks are pruned on the sweep to avoid retaining a
|
|
503
|
+
// closed session's renderables.
|
|
504
|
+
const adopted = new Set();
|
|
274
505
|
// The mode new blocks adopt. ctrl+o flips it, so "expand everything" also
|
|
275
506
|
// means "and stop folding what arrives next", the way a verbose toggle works.
|
|
276
507
|
let folding = opts.folded !== false;
|
|
277
508
|
// Set once the header rewrite is proven not to take, so we stop retrying it.
|
|
278
509
|
let titles = opts.stats !== false;
|
|
510
|
+
let disposed = false;
|
|
279
511
|
|
|
280
512
|
let cached;
|
|
513
|
+
let routed;
|
|
514
|
+
|
|
515
|
+
function currentRoute() {
|
|
516
|
+
try {
|
|
517
|
+
return context.ui.router.current();
|
|
518
|
+
} catch {
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Route/session changes destroy or replace the transcript and invalidate
|
|
524
|
+
// per-session fold memory. Detecting it here also makes the cache safe to
|
|
525
|
+
// keep between the (now much slower) fallback sweeps.
|
|
526
|
+
function refreshRoute() {
|
|
527
|
+
const route = currentRoute();
|
|
528
|
+
const token =
|
|
529
|
+
route?.type === "session" ? `session:${route.sessionID ?? ""}` : String(route?.type ?? "");
|
|
530
|
+
if (token !== routed) {
|
|
531
|
+
routed = token;
|
|
532
|
+
cached = undefined;
|
|
533
|
+
foldedByKey.clear();
|
|
534
|
+
}
|
|
535
|
+
return token;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function attached(node) {
|
|
539
|
+
const root = context.renderer.root;
|
|
540
|
+
let current = node;
|
|
541
|
+
for (let i = 0; current && i < 2000; i++) {
|
|
542
|
+
if (current === root) return true;
|
|
543
|
+
current = current.parent;
|
|
544
|
+
}
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
|
|
281
548
|
function transcript() {
|
|
282
|
-
|
|
549
|
+
refreshRoute();
|
|
550
|
+
if (cached && !cached.isDestroyed && attached(cached)) return cached;
|
|
283
551
|
cached = findTranscript(context.renderer.root);
|
|
284
552
|
return cached;
|
|
285
553
|
}
|
|
286
554
|
|
|
555
|
+
function setTitle(state, fold) {
|
|
556
|
+
if (!state.title) return;
|
|
557
|
+
const next = fold && titles ? `${state.text} ${state.suffix}` : state.text;
|
|
558
|
+
if (plain(state.title) === next) {
|
|
559
|
+
state.titleChanged = fold && titles;
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
state.title.content = next;
|
|
564
|
+
} catch {
|
|
565
|
+
titles = false;
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const applied = plain(state.title);
|
|
569
|
+
if (fold && titles) {
|
|
570
|
+
if (applied === next) state.titleChanged = true;
|
|
571
|
+
else titles = false;
|
|
572
|
+
} else {
|
|
573
|
+
state.titleChanged = applied !== state.text;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
287
577
|
function apply(state, fold) {
|
|
288
|
-
state.folded = fold;
|
|
289
578
|
state.body.forEach((node, index) => {
|
|
290
579
|
try {
|
|
291
580
|
// `visible = false` sets Yoga display:none, which removes the body
|
|
@@ -293,36 +582,53 @@ export default {
|
|
|
293
582
|
// clamped the box to zero rows but OpenTUI still painted the diff.
|
|
294
583
|
// A positive `lines` peek keeps the first body visible instead.
|
|
295
584
|
const peekRow = fold && index === 0 && state.peek > 0;
|
|
296
|
-
node.visible =
|
|
297
|
-
node.maxHeight = peekRow ? state.peek :
|
|
585
|
+
node.visible = fold ? peekRow : (state.visible[index] ?? true);
|
|
586
|
+
node.maxHeight = peekRow ? state.peek : state.maxHeight[index];
|
|
298
587
|
node.overflow = fold ? "hidden" : state.overflow[index];
|
|
299
588
|
} catch {}
|
|
300
589
|
});
|
|
301
|
-
// With the body at zero height
|
|
590
|
+
// With the body at zero height the block's own padding and the gap it
|
|
302
591
|
// keeps between children are all that is left. Collapse the chrome too so
|
|
303
|
-
// a folded block reads as the single row it now is
|
|
304
|
-
//
|
|
305
|
-
// these setters no getters to read the originals back from. Never for a
|
|
592
|
+
// a folded block reads as the single row it now is, restoring exactly the
|
|
593
|
+
// values the host had rather than BlockTool's assumed 1/1/1. Never for a
|
|
306
594
|
// shell block: its output is a sibling of the command and stays on screen,
|
|
307
595
|
// so the chrome is still holding something up.
|
|
308
596
|
if (state.chrome) {
|
|
309
597
|
try {
|
|
310
|
-
state.block.gap = fold ? 0 :
|
|
311
|
-
state.block.paddingTop = fold ? 0 :
|
|
312
|
-
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;
|
|
313
601
|
} catch {}
|
|
314
602
|
}
|
|
315
|
-
|
|
316
|
-
|
|
603
|
+
// Restore the label even after `titles` was turned off globally: a block
|
|
604
|
+
// whose suffix was applied before the failure must not keep it.
|
|
605
|
+
setTitle(state, fold);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function setFolded(state, fold) {
|
|
609
|
+
state.folded = fold;
|
|
610
|
+
if (state.key) foldedByKey.set(state.key, fold);
|
|
611
|
+
apply(state, fold);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function selection() {
|
|
317
615
|
try {
|
|
318
|
-
|
|
616
|
+
return context.renderer.getSelection?.()?.getSelectedText?.();
|
|
319
617
|
} catch {
|
|
320
|
-
|
|
321
|
-
return;
|
|
618
|
+
return undefined;
|
|
322
619
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function installHandler(state, target, stop) {
|
|
623
|
+
state.handlerTarget = target;
|
|
624
|
+
state.handler = (event) => {
|
|
625
|
+
if (disposed || state.block.isDestroyed) return;
|
|
626
|
+
// Copy-on-select is a drag ending on the block; that is not a click.
|
|
627
|
+
if (selection()) return;
|
|
628
|
+
setFolded(state, !state.folded);
|
|
629
|
+
if (stop) event?.stopPropagation?.();
|
|
630
|
+
};
|
|
631
|
+
target.onMouseUp = state.handler;
|
|
326
632
|
}
|
|
327
633
|
|
|
328
634
|
function adoptDiff(block) {
|
|
@@ -347,41 +653,61 @@ export default {
|
|
|
347
653
|
const state = {
|
|
348
654
|
block,
|
|
349
655
|
body,
|
|
656
|
+
visible: body.map((node) => node.visible),
|
|
657
|
+
maxHeight: body.map((node) => maxHeightOf(node, undefined)),
|
|
350
658
|
overflow: body.map((node) => node.overflow),
|
|
351
659
|
title: header.node,
|
|
352
660
|
text: header.label,
|
|
353
661
|
suffix: `${stats.label} · click to expand`,
|
|
662
|
+
signature: `${stats.size}|${stats.label}`,
|
|
354
663
|
peek,
|
|
355
664
|
chrome: peek === 0,
|
|
665
|
+
chromeStyle: chromeOf(block),
|
|
356
666
|
folded: false,
|
|
667
|
+
titleChanged: false,
|
|
668
|
+
key: stableKey(block),
|
|
669
|
+
kind: "diff",
|
|
357
670
|
};
|
|
671
|
+
const remembered = state.key === undefined ? undefined : foldedByKey.get(state.key);
|
|
672
|
+
state.folded = remembered === undefined ? folding : remembered;
|
|
673
|
+
if (state.key !== undefined) foldedByKey.set(state.key, state.folded);
|
|
358
674
|
known.set(block, state);
|
|
359
|
-
|
|
360
|
-
block
|
|
361
|
-
|
|
362
|
-
if (context.renderer.getSelection?.()?.getSelectedText?.()) return;
|
|
363
|
-
apply(state, !state.folded);
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
if (folding) apply(state, true);
|
|
675
|
+
adopted.add(block);
|
|
676
|
+
installHandler(state, block, false);
|
|
677
|
+
apply(state, state.folded);
|
|
367
678
|
return true;
|
|
368
679
|
}
|
|
369
680
|
|
|
370
681
|
function adoptShell(block) {
|
|
371
682
|
const found = shellCommand(block);
|
|
372
|
-
if (!found) return;
|
|
373
|
-
if (commandRows(found.node, found.text) < floor) return;
|
|
683
|
+
if (!found) return false;
|
|
684
|
+
if (commandRows(found.node, found.text) < floor) return false;
|
|
374
685
|
|
|
375
686
|
const state = {
|
|
376
687
|
block,
|
|
377
688
|
body: [found.node],
|
|
689
|
+
visible: [found.node.visible],
|
|
690
|
+
// The host clamps a running/long command to two rows. If the getter is
|
|
691
|
+
// unavailable, two is still a better restore point than undefined.
|
|
692
|
+
maxHeight: [maxHeightOf(found.node, 2)],
|
|
378
693
|
overflow: [found.node.overflow],
|
|
379
694
|
title: undefined,
|
|
695
|
+
text: "",
|
|
696
|
+
suffix: "",
|
|
697
|
+
signature: "",
|
|
380
698
|
peek: shellPeek,
|
|
381
699
|
chrome: false,
|
|
700
|
+
chromeStyle: undefined,
|
|
382
701
|
folded: false,
|
|
702
|
+
titleChanged: false,
|
|
703
|
+
key: stableKey(block),
|
|
704
|
+
kind: "shell",
|
|
383
705
|
};
|
|
706
|
+
const remembered = state.key === undefined ? undefined : foldedByKey.get(state.key);
|
|
707
|
+
state.folded = remembered === undefined ? folding : remembered;
|
|
708
|
+
if (state.key !== undefined) foldedByKey.set(state.key, state.folded);
|
|
384
709
|
known.set(block, state);
|
|
710
|
+
adopted.add(block);
|
|
385
711
|
|
|
386
712
|
// The block's own onMouseUp belongs to the host here -- it is what expands
|
|
387
713
|
// the collapsed command/output -- and opentui declares the handler as a
|
|
@@ -389,22 +715,44 @@ export default {
|
|
|
389
715
|
// command text instead and stop the event on it: clicking the command
|
|
390
716
|
// folds the command, clicking anywhere else in the block still does
|
|
391
717
|
// exactly what it did before this plugin loaded.
|
|
392
|
-
found.node
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
event?.stopPropagation?.();
|
|
396
|
-
};
|
|
397
|
-
|
|
398
|
-
if (folding) apply(state, true);
|
|
718
|
+
installHandler(state, found.node, true);
|
|
719
|
+
apply(state, state.folded);
|
|
720
|
+
return true;
|
|
399
721
|
}
|
|
400
722
|
|
|
401
723
|
function adopt(block) {
|
|
402
|
-
if (adoptDiff(block)) return;
|
|
403
|
-
if (shell) adoptShell(block);
|
|
724
|
+
if (adoptDiff(block)) return true;
|
|
725
|
+
if (shell) return adoptShell(block);
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Recompute a folded block's stats without dropping its adoption. A tool
|
|
730
|
+
// that streams more content into the same renderables used to keep the
|
|
731
|
+
// first summary forever because the sweep skipped every known block.
|
|
732
|
+
function refreshStats(state) {
|
|
733
|
+
if (state.kind !== "diff") return;
|
|
734
|
+
const heavy = [];
|
|
735
|
+
for (const child of state.body) bulk(child, heavy);
|
|
736
|
+
if (!heavy.length) return;
|
|
737
|
+
const stats = summarise(heavy);
|
|
738
|
+
const signature = `${stats.size}|${stats.label}`;
|
|
739
|
+
if (signature === state.signature) return;
|
|
740
|
+
state.signature = signature;
|
|
741
|
+
state.suffix = `${stats.label} · click to expand`;
|
|
742
|
+
if (state.folded) setTitle(state, true);
|
|
404
743
|
}
|
|
405
744
|
|
|
406
745
|
function sweep() {
|
|
407
|
-
if (
|
|
746
|
+
if (disposed) return;
|
|
747
|
+
for (const block of adopted) {
|
|
748
|
+
if (block.isDestroyed) {
|
|
749
|
+
adopted.delete(block);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
const state = known.get(block);
|
|
753
|
+
if (state) refreshStats(state);
|
|
754
|
+
}
|
|
755
|
+
if (!refreshRoute().startsWith("session:")) return;
|
|
408
756
|
const box = transcript();
|
|
409
757
|
if (!box) return;
|
|
410
758
|
for (const block of scan(box, [], shell)) {
|
|
@@ -421,7 +769,7 @@ export default {
|
|
|
421
769
|
for (const block of scan(box, [], shell)) {
|
|
422
770
|
const state = known.get(block);
|
|
423
771
|
if (!state || state.folded === fold) continue;
|
|
424
|
-
|
|
772
|
+
setFolded(state, fold);
|
|
425
773
|
count++;
|
|
426
774
|
}
|
|
427
775
|
return count;
|
|
@@ -429,7 +777,7 @@ export default {
|
|
|
429
777
|
|
|
430
778
|
let pending;
|
|
431
779
|
function schedule() {
|
|
432
|
-
if (pending) return;
|
|
780
|
+
if (disposed || pending) return;
|
|
433
781
|
pending = setTimeout(() => {
|
|
434
782
|
pending = undefined;
|
|
435
783
|
sweep();
|
|
@@ -437,17 +785,28 @@ export default {
|
|
|
437
785
|
}
|
|
438
786
|
|
|
439
787
|
// V1's message.part.updated / message.updated are legacy schemas in V2 --
|
|
440
|
-
// the client emits the session.* events instead
|
|
441
|
-
//
|
|
788
|
+
// the client emits the session.* events instead. `session.message.content.
|
|
789
|
+
// updated` is not exposed by the public TUI event union, so the tool events
|
|
790
|
+
// (plus the step and execution endings) are the ones this plugin can rely
|
|
791
|
+
// on; the slow fallback sweep covers anything else.
|
|
442
792
|
const events = [
|
|
443
|
-
"session.
|
|
444
|
-
"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
|
|
445
796
|
"session.tool.failed", // a failed tool still renders its block
|
|
446
797
|
"session.step.ended", // the step's final content has arrived
|
|
447
798
|
"session.execution.succeeded", // the turn settled; take a final look
|
|
448
799
|
];
|
|
449
800
|
if (shell) events.push("session.shell.ended"); // the command now carries its "$ " prefix
|
|
450
|
-
|
|
801
|
+
// An older host may not know every event name; a missing subscription must
|
|
802
|
+
// not take the whole plugin down.
|
|
803
|
+
const offs = events.map((name) => {
|
|
804
|
+
try {
|
|
805
|
+
return context.data.on(name, schedule);
|
|
806
|
+
} catch {
|
|
807
|
+
return undefined;
|
|
808
|
+
}
|
|
809
|
+
});
|
|
451
810
|
const timer = setInterval(sweep, SWEEP_MS);
|
|
452
811
|
schedule();
|
|
453
812
|
|
|
@@ -465,8 +824,9 @@ export default {
|
|
|
465
824
|
title: "Fold / unfold file diffs",
|
|
466
825
|
group: "Plugin",
|
|
467
826
|
palette: true,
|
|
468
|
-
bind:
|
|
827
|
+
bind: key || false,
|
|
469
828
|
run() {
|
|
829
|
+
if (disposed) return;
|
|
470
830
|
sweep();
|
|
471
831
|
const fold = !folding;
|
|
472
832
|
const changed = all(fold);
|
|
@@ -487,13 +847,16 @@ export default {
|
|
|
487
847
|
group: "Plugin",
|
|
488
848
|
palette: true,
|
|
489
849
|
run() {
|
|
490
|
-
|
|
850
|
+
if (disposed) return;
|
|
851
|
+
const inSession = refreshRoute().startsWith("session:");
|
|
852
|
+
const box = inSession ? transcript() : undefined;
|
|
491
853
|
const blocks = box ? scan(box, [], shell) : [];
|
|
492
854
|
const folded = blocks.filter((block) => known.get(block)?.folded).length;
|
|
855
|
+
const options = describeOptions(opts);
|
|
493
856
|
let dump;
|
|
494
857
|
try {
|
|
495
858
|
const summary = [
|
|
496
|
-
`transcript=${Boolean(box)} blocks=${blocks.length} folded=${folded} stats=${titles ? "on" : "off"}`,
|
|
859
|
+
`transcript=${Boolean(box)} blocks=${blocks.length} folded=${folded} stats=${titles ? "on" : "off"} options=${options}`,
|
|
497
860
|
];
|
|
498
861
|
for (const block of blocks) {
|
|
499
862
|
const state = known.get(block);
|
|
@@ -515,8 +878,10 @@ export default {
|
|
|
515
878
|
if (target) {
|
|
516
879
|
const lines = [];
|
|
517
880
|
dumpNode(box ?? context.renderer.root, 0, lines, 600);
|
|
518
|
-
dump = target;
|
|
519
881
|
writeFileSync(target, summary.concat("", lines).join("\n"));
|
|
882
|
+
// Only advertise the path once the write succeeded; the
|
|
883
|
+
// catch below keeps a failed write from taking down diagnose.
|
|
884
|
+
dump = target;
|
|
520
885
|
}
|
|
521
886
|
} catch {}
|
|
522
887
|
context.ui.toast.show({
|
|
@@ -524,32 +889,58 @@ export default {
|
|
|
524
889
|
variant: box ? "info" : "warning",
|
|
525
890
|
duration: 5000,
|
|
526
891
|
message: box
|
|
527
|
-
? `transcript: yes · blocks: ${blocks.length} · folded: ${folded} · stats: ${titles ? "on" : "off"}` +
|
|
892
|
+
? `transcript: yes · blocks: ${blocks.length} · folded: ${folded} · stats: ${titles ? "on" : "off"} · options: ${options}` +
|
|
528
893
|
(dump ? ` · dump: ${dump}` : "")
|
|
529
|
-
:
|
|
894
|
+
: `transcript: not found — open a session first · options: ${options}`,
|
|
530
895
|
});
|
|
531
896
|
},
|
|
532
897
|
},
|
|
533
898
|
],
|
|
534
|
-
bindings:
|
|
899
|
+
bindings: key ? ["opencode-fold-diffs.toggle"] : [],
|
|
535
900
|
}));
|
|
536
901
|
}
|
|
537
902
|
let stopCommands = () => {};
|
|
903
|
+
const registerCommandsSafely = () => {
|
|
904
|
+
try {
|
|
905
|
+
registerCommands();
|
|
906
|
+
} catch {}
|
|
907
|
+
};
|
|
538
908
|
if (supportsSetupKeymap(context.app?.version)) {
|
|
539
|
-
|
|
909
|
+
registerCommandsSafely();
|
|
540
910
|
} else {
|
|
541
911
|
function FoldCommands() {
|
|
542
|
-
|
|
912
|
+
registerCommandsSafely();
|
|
543
913
|
return null;
|
|
544
914
|
}
|
|
545
|
-
|
|
915
|
+
try {
|
|
916
|
+
stopCommands = context.ui.slot({ append: "app", render: () => FoldCommands() });
|
|
917
|
+
} catch {}
|
|
546
918
|
}
|
|
547
919
|
|
|
548
920
|
return () => {
|
|
549
|
-
|
|
921
|
+
if (disposed) return;
|
|
922
|
+
disposed = true;
|
|
923
|
+
if (typeof stopCommands === "function") stopCommands();
|
|
550
924
|
clearInterval(timer);
|
|
551
|
-
if (pending)
|
|
925
|
+
if (pending) {
|
|
926
|
+
clearTimeout(pending);
|
|
927
|
+
pending = undefined;
|
|
928
|
+
}
|
|
552
929
|
for (const off of offs) if (typeof off === "function") off();
|
|
930
|
+
// The host owns these renderables and keeps them after the plugin is
|
|
931
|
+
// disabled, so undo our folds and remove our handlers instead of leaving
|
|
932
|
+
// the transcript collapsed or a late click able to re-fold it.
|
|
933
|
+
for (const block of adopted) {
|
|
934
|
+
const state = known.get(block);
|
|
935
|
+
if (!state) continue;
|
|
936
|
+
try {
|
|
937
|
+
if (state.handlerTarget) state.handlerTarget.onMouseUp = undefined;
|
|
938
|
+
} catch {}
|
|
939
|
+
if (state.folded || state.titleChanged) apply(state, false);
|
|
940
|
+
}
|
|
941
|
+
adopted.clear();
|
|
942
|
+
foldedByKey.clear();
|
|
943
|
+
cached = undefined;
|
|
553
944
|
};
|
|
554
945
|
},
|
|
555
946
|
};
|