@cardinal4/opencode-fold-diffs 0.3.0
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/LICENSE +21 -0
- package/README.md +155 -0
- package/index.js +18 -0
- package/package.json +51 -0
- package/tui.js +519 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tanman24
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# opencode-fold-diffs
|
|
2
|
+
|
|
3
|
+
**Every `write` and `edit` 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.
|
|
4
|
+
|
|
5
|
+
This branch targets **OpenCode V2** (the `@opencode/cli` 2.x line, `opencode v2.0.x`). The V1 plugin implementation does not run in V2; see [Migrating from V1](#migrating-from-v1).
|
|
6
|
+
|
|
7
|
+
## What V2 already folds
|
|
8
|
+
|
|
9
|
+
V2 tightened the transcript on its own, and this plugin deliberately leaves those parts alone:
|
|
10
|
+
|
|
11
|
+
- `read` / `glob` / `grep` calls fold into a one-line exploration group (`Explored — 3 reads, 2 searches`), click to expand.
|
|
12
|
+
- A bash **command** is trimmed to two lines and its **output** to ten, both click to expand.
|
|
13
|
+
|
|
14
|
+
The tools V2 still renders in full are exactly the ones that fill the scrollback: `write`, `edit`, `apply_patch`. Upstream has been asked three times — [#9089](https://github.com/anomalyco/opencode/issues/9089) (minimal diff display), [#14511](https://github.com/anomalyco/opencode/issues/14511) (a toggle keybind, like Claude Code's `ctrl+o`), [#19074](https://github.com/anomalyco/opencode/issues/19074) (collapse tool output) — and all three were closed without a setting. This is that setting, from outside.
|
|
15
|
+
|
|
16
|
+
## What you see
|
|
17
|
+
|
|
18
|
+
Before:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
← Edit src/session/index.ts
|
|
22
|
+
│ 1 import { createMemo } from "solid-js"
|
|
23
|
+
│ 2 - const [expanded, setExpanded] = createSignal(false)
|
|
24
|
+
│ 3 + const [expanded, setExpanded] = createSignal(props.open)
|
|
25
|
+
│ … 40 more lines
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
After:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
← Edit +12 −3 · click to expand src/session/index.ts
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Click the row to open it. If you set the `key` option, that binding folds or unfolds every block in the session and sets what newly arriving blocks do — same as a verbose toggle. By default there is no binding, so OpenCode's own shortcuts (including `ctrl+o`) are left alone.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
opencode plugin add @cardinal4/opencode-fold-diffs
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then list it in your CLI config so the terminal loads the TUI entrypoint. Add it to `~/.config/opencode/cli.json` (or `$XDG_CONFIG_HOME/opencode/cli.json`):
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"$schema": "https://opencode.ai/v2/cli.json",
|
|
47
|
+
"plugins": ["@cardinal4/opencode-fold-diffs"]
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This package is TUI-only (it has no server entrypoint), so configure it in `cli.json`. CLI-only plugins also stay active when the terminal connects to a remote server.
|
|
52
|
+
|
|
53
|
+
With options:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"plugins": [
|
|
58
|
+
{
|
|
59
|
+
"package": "@cardinal4/opencode-fold-diffs",
|
|
60
|
+
"options": {
|
|
61
|
+
"lines": 3,
|
|
62
|
+
"min_lines": 10,
|
|
63
|
+
"key": "ctrl+f"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Restart OpenCode afterwards; plugins load at startup.
|
|
71
|
+
|
|
72
|
+
### From a local checkout
|
|
73
|
+
|
|
74
|
+
Clone this repository and point the plugin entry at the checkout. The package ships a server entry (`index.js`) and a TUI entry (`tui.js`); OpenCode discovers a plugin directory by its server entry and loads the `tui` entry beside it, so the plugin shows up by id instead of as an anonymous entry.
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"plugins": ["/home/me/projects_l/opencode-fold-diffs"]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Options
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
"plugins": [["@cardinal4/opencode-fold-diffs", { "lines": 3, "min_lines": 10, "key": "ctrl+f" }]]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
| Option | Default | Meaning |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `lines` | `0` | Lines of the body left showing when folded. `0` is the header only, and also tightens the block's padding so it occupies one row. Any positive number leaves a peek at the top. |
|
|
91
|
+
| `min_lines` | `6` | Blocks with fewer changed lines than this are left alone — a two-line edit is already its own summary. |
|
|
92
|
+
| `stats` | `true` | Append `+12 −3 · click to expand` to the header. |
|
|
93
|
+
| `folded` | `true` | Whether blocks start folded. `false` gives you only the toggle. |
|
|
94
|
+
| `key` | `""` | Optional binding for fold/unfold-all. Empty by default so no OpenCode shortcut is overridden; set e.g. `"ctrl+shift+d"` to opt in. |
|
|
95
|
+
| `bash` | `false` | Fold long bash commands too. Off by default on V2 because the host already trims them to two lines. |
|
|
96
|
+
| `bash_lines` | `1` | Rows of the command left showing when folded. `1` keeps the line that says what the thing was. |
|
|
97
|
+
| `dump` | `""` | Where **Fold diffs: diagnose** writes its tree dump. `""` means `/tmp/opencode-fold-diffs-tree.txt`; `false` disables the dump. |
|
|
98
|
+
|
|
99
|
+
> `ctrl+o` belongs to OpenCode's **Open recent sessions and projects** (`open.menu`), so this plugin does not bind it. Choose an unused key for the `key` option.
|
|
100
|
+
|
|
101
|
+
## What it does not touch
|
|
102
|
+
|
|
103
|
+
- **Permission dialogs.** The diff you approve renders in full, always. The plugin only ever walks inside the transcript scrollbox, and the permission preview is not in it.
|
|
104
|
+
- **Diagnostics and errors.** Only the children carrying a diff or a file body get folded, so an edit that introduced a type error still says so with the block closed.
|
|
105
|
+
- **Bash output, and the click that expands it.** Only the *command* is ever folded, and only when `bash` is enabled; the output keeps the host's ten-line collapse. The block's own click handler belongs to the host, so the plugin attaches to the command text instead and calls `stopPropagation()`: clicking the command folds the command, clicking anywhere else in the block does exactly what it did before.
|
|
106
|
+
- **Todos, questions, and the generic fallback.** They keep the host's own collapse behaviour.
|
|
107
|
+
|
|
108
|
+
## How it works, for anyone extending it
|
|
109
|
+
|
|
110
|
+
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:
|
|
111
|
+
|
|
112
|
+
1. **Find the transcript.** The only scrollbox in the tree with `stickyScroll && stickyStart === "bottom"`. The sidebar, dialogs, autocomplete and diff viewer all have scrollboxes; none of them are sticky.
|
|
113
|
+
2. **Find the blocks.** A V2 `BlockTool` renders its header first as a row box whose first two children are the label text (`# Wrote`, `← Edit`, `← Patched`, `# Created`, `# Deleted`) and the path value. File blocks are matched on that label; a bash block carries no header, so it is found by shape — a child whose first grandchild is the `$ `-prefixed command.
|
|
114
|
+
3. **Fold.** Set `visible = false` on the children carrying the bulk. That sets Yoga `display: none`, so the body leaves layout instead of leaving a hole. A zero `max-height` alone is not enough — the box collapses to zero rows but OpenTUI still paints the diff. A positive `lines` value keeps the first body visible and uses `maxHeight` for it instead. The block's own chrome (`gap`, `paddingTop`, `paddingBottom` of 1) is tightened to zero so a folded block occupies one row.
|
|
115
|
+
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.
|
|
116
|
+
5. **Restate the header.** The stats suffix is appended to the label text node, not the path. If solid will not let go of that node the plugin stops trying and folds without the suffix.
|
|
117
|
+
|
|
118
|
+
Renderable classes are matched by duck-typing (`typeof node.diff === "string"`, `content` + `filetype` for code) rather than `instanceof`, because the opentui classes are minified in the shipped binary and their names are not stable.
|
|
119
|
+
|
|
120
|
+
Blocks are re-scanned on `message.part.updated` and `message.updated`, plus a 2 s sweep that catches a session opened from history, whose parts land before any event this plugin sees.
|
|
121
|
+
|
|
122
|
+
## Migrating from V1
|
|
123
|
+
|
|
124
|
+
V1 plugin implementations do not run in V2. This branch made these changes:
|
|
125
|
+
|
|
126
|
+
- Entrypoint is a `{ id, setup(context) }` definition (`Plugin.define()` is an identity helper, so the shape is the same). `setup` returns the cleanup function instead of `api.lifecycle.onDispose`.
|
|
127
|
+
- `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`.
|
|
128
|
+
- Tool headers are no longer one string. V1 rendered `"← Edit src/app.ts"`; V2 renders a label node plus a path node, so detection and the stats suffix target the label.
|
|
129
|
+
- `bash` defaults to `false`, because V2 now trims long commands to two lines itself.
|
|
130
|
+
- Config moves from `tui.json` to `cli.json` (or `opencode.json(c)`).
|
|
131
|
+
|
|
132
|
+
## Testing
|
|
133
|
+
|
|
134
|
+
1. Restart so the plugin loads: `opencode service restart`, then relaunch the TUI.
|
|
135
|
+
2. Confirm it loaded: `/plugins` should list `opencode-fold-diffs` by id, and `Ctrl+P` → **Fold / unfold file diffs** should be in the palette.
|
|
136
|
+
3. Ask the agent for a small edit. The block should render as a single header row, `← Edit +2 −1 · click to expand path`. Click it to open, click again to close.
|
|
137
|
+
4. If you configured a `key`, press it to fold or unfold every block in the session. Otherwise use the palette command **Fold / unfold file diffs**.
|
|
138
|
+
5. If nothing folds, run `Ctrl+P` → **Fold diffs: diagnose**. The toast reports what the plugin can see:
|
|
139
|
+
|
|
140
|
+
| Result | Meaning |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `transcript: not found` | No session view is open, so there is nothing to fold. |
|
|
143
|
+
| `transcript: yes · blocks: 0` | The transcript was found but no file blocks matched — the host render tree differs from what this plugin expects. |
|
|
144
|
+
| `transcript: yes · blocks: N · folded: N` | Detection and folding ran; if the blocks still look expanded, the fold did not take on the renderables. |
|
|
145
|
+
| `stats: off` | Folding works but the header could not be restated (Solid owns the label node). |
|
|
146
|
+
|
|
147
|
+
## Status
|
|
148
|
+
|
|
149
|
+
Written against **opencode v2.0.10 / v2.0.11**. 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`.
|
|
150
|
+
|
|
151
|
+
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.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Server entrypoint for opencode-fold-diffs.
|
|
2
|
+
//
|
|
3
|
+
// All rendering happens in the CLI runtime (tui.js). This file only exists so a
|
|
4
|
+
// local plugin directory is a complete plugin: OpenCode discovers a plugin
|
|
5
|
+
// directory by its index (server) entry and loads the `tui` entry beside it, so
|
|
6
|
+
// the plugin shows up by id instead of as an anonymous entry.
|
|
7
|
+
//
|
|
8
|
+
// `Plugin.define()` is an identity helper, so a plain { id, setup } object is
|
|
9
|
+
// the same definition without a runtime dependency on @opencode/plugin.
|
|
10
|
+
|
|
11
|
+
export const PLUGIN_ID = "opencode-fold-diffs";
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
id: PLUGIN_ID,
|
|
15
|
+
setup() {
|
|
16
|
+
// Intentional no-op: the transcript folding is registered by tui.js.
|
|
17
|
+
},
|
|
18
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cardinal4/opencode-fold-diffs",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "OpenCode V2 TUI plugin: write, edit and apply_patch blocks render folded to their header line, click or ctrl+o to open them.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"opencode",
|
|
9
|
+
"opencode-plugin",
|
|
10
|
+
"opencode-tui",
|
|
11
|
+
"tui",
|
|
12
|
+
"diff",
|
|
13
|
+
"collapse",
|
|
14
|
+
"fold",
|
|
15
|
+
"transcript",
|
|
16
|
+
"scrollback"
|
|
17
|
+
],
|
|
18
|
+
"main": "./index.js",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./index.js",
|
|
21
|
+
"./server": "./index.js",
|
|
22
|
+
"./tui": "./tui.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"index.js",
|
|
26
|
+
"tui.js",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --test"
|
|
32
|
+
},
|
|
33
|
+
"author": {
|
|
34
|
+
"name": "Tanner Bruhn",
|
|
35
|
+
"url": "https://github.com/tannerbruhn"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/cardin/opencode-fold-diffs.git"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/cardin/opencode-fold-diffs#readme",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/cardin/opencode-fold-diffs/issues"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"opencode": ">=2.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/tui.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
// Fold write / edit / apply_patch blocks -- and, optionally, long bash commands
|
|
2
|
+
// -- in the OpenCode V2 transcript.
|
|
3
|
+
//
|
|
4
|
+
// opencode already collapses bash OUTPUT to ten lines and (since V2) also trims
|
|
5
|
+
// a long bash COMMAND to two, both with click-to-expand. The file tools that
|
|
6
|
+
// produce the most scrollback -- write, edit and apply_patch -- are the ones it
|
|
7
|
+
// does not touch: they render the whole diff, or the whole written file,
|
|
8
|
+
// forever. The three upstream requests for a setting (#9089 minimal diff
|
|
9
|
+
// display, #14511 a toggle keybind, #19074 collapse tool output) were all
|
|
10
|
+
// closed without one, so this does it from a plugin.
|
|
11
|
+
//
|
|
12
|
+
// A folded block renders as its header line -- "# Wrote 40 lines · click to
|
|
13
|
+
// expand src/app.ts" -- and opens on click, or with ctrl+o for every block at
|
|
14
|
+
// once.
|
|
15
|
+
//
|
|
16
|
+
// Options (cli.json -> ["opencode-fold-diffs", { ... }] or opencode.json(c)):
|
|
17
|
+
// lines lines of the body left visible when folded (default 0, title only)
|
|
18
|
+
// min_lines leave blocks with fewer content lines alone (default 6)
|
|
19
|
+
// stats append "40 lines · click to expand" to the header (default true)
|
|
20
|
+
// folded new blocks start folded (default true)
|
|
21
|
+
// key binding that folds/unfolds every block (default "ctrl+o")
|
|
22
|
+
// bash fold long bash commands too (default false on V2)
|
|
23
|
+
// bash_lines rows of the command left visible when folded (default 1)
|
|
24
|
+
//
|
|
25
|
+
// The plugin is loaded by the OpenCode V2 CLI from the package's "./tui" export
|
|
26
|
+
// and receives a plugin Context (see @opencode/plugin/tui). It exports a plain
|
|
27
|
+
// { id, setup } definition; Plugin.define() is an identity helper, so the
|
|
28
|
+
// shape is the same without a runtime dependency on @opencode/plugin.
|
|
29
|
+
|
|
30
|
+
import { writeFileSync } from "node:fs";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
|
|
34
|
+
const DEFAULTS = {
|
|
35
|
+
lines: 0,
|
|
36
|
+
min_lines: 6,
|
|
37
|
+
stats: true,
|
|
38
|
+
folded: true,
|
|
39
|
+
// No binding by default: ctrl+o belongs to OpenCode's "Open recent sessions
|
|
40
|
+
// and projects". Set `key` to opt in to a fold/unfold-all shortcut.
|
|
41
|
+
key: "",
|
|
42
|
+
// V2 collapses a long command to two lines and its output to ten, both with
|
|
43
|
+
// click-to-expand, so the command is no longer the "the host never trims it"
|
|
44
|
+
// gap it was in V1. Off by default; turn on to tighten it to one line.
|
|
45
|
+
bash: false,
|
|
46
|
+
bash_lines: 1,
|
|
47
|
+
// Where "Fold diffs: diagnose" writes its tree dump. "" is the default
|
|
48
|
+
// (/tmp/opencode-fold-diffs-tree.txt); false turns the dump off.
|
|
49
|
+
dump: "",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Header labels of the file-writing tools, as V2 renders them. Unlike V1, V2
|
|
53
|
+
// splits a block header into a label text node ("# Wrote", "← Edit", ...) and a
|
|
54
|
+
// separate path node, so these match the label alone. The trailing alternation
|
|
55
|
+
// keeps a header that already carries the stats suffix matching on later scans.
|
|
56
|
+
const LABELS = ["# Wrote", "← Edit", "← Patched", "# Created", "# Deleted", "# Moved"];
|
|
57
|
+
|
|
58
|
+
function isLabel(text) {
|
|
59
|
+
return LABELS.includes(text);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The stats suffix this plugin appends to a folded header. Stripping it makes
|
|
63
|
+
// re-adopting a block idempotent: a hot reload rebuilds the plugin, re-walks the
|
|
64
|
+
// same transcript and must not append the suffix a second time.
|
|
65
|
+
const SUFFIX = / (?:\d+ lines?|\+\d+ −\d+) · click to expand$/;
|
|
66
|
+
|
|
67
|
+
function baseLabel(text) {
|
|
68
|
+
const match = SUFFIX.exec(text);
|
|
69
|
+
return match ? text.slice(0, match.index) : text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A shell block carries no header of its own, and V2 writes the command with a
|
|
73
|
+
// "$ " in front of it.
|
|
74
|
+
const PROMPT = "$ ";
|
|
75
|
+
|
|
76
|
+
// How often to re-scan when nothing is streaming. Events cover the live case;
|
|
77
|
+
// this catches a session opened from history, whose parts arrive as one batch
|
|
78
|
+
// before any event this plugin sees.
|
|
79
|
+
const SWEEP_MS = 2000;
|
|
80
|
+
|
|
81
|
+
function children(node) {
|
|
82
|
+
return typeof node?.getChildren === "function" ? node.getChildren() : [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function plain(node) {
|
|
86
|
+
const value = node?.plainText;
|
|
87
|
+
return typeof value === "string" ? value : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Duck-typing, not instanceof: the classes live in the host's bundled
|
|
91
|
+
// @opentui/core and are minified, so their names are not stable. A diff
|
|
92
|
+
// renderable is the only thing in the tree carrying a `diff` string, and a code
|
|
93
|
+
// renderable the only thing pairing `content` with `filetype`.
|
|
94
|
+
function isDiff(node) {
|
|
95
|
+
return typeof node?.diff === "string";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isCode(node) {
|
|
99
|
+
return typeof node?.content === "string" && typeof node?.filetype === "string";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The command text of a bash block, or nothing. V2 wraps the command and its
|
|
103
|
+
// output in one box, and the command is the first child of that box prefixed
|
|
104
|
+
// with "$ ". While the tool is still running the host renders the command in a
|
|
105
|
+
// row next to a spinner, so a running command is skipped and picked up by a
|
|
106
|
+
// later sweep once it settles.
|
|
107
|
+
function shellCommand(block) {
|
|
108
|
+
for (const child of children(block)) {
|
|
109
|
+
const inner = children(child);
|
|
110
|
+
if (!inner.length) continue;
|
|
111
|
+
// solid's <Show> leaves childless placeholders, so scan rather than assume
|
|
112
|
+
// the command is the first grandchild.
|
|
113
|
+
for (const node of inner) {
|
|
114
|
+
const text = plain(node);
|
|
115
|
+
if (typeof text === "string" && text.startsWith(PROMPT)) return { node, text };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Rows the command occupies, not lines it contains: a single-line command long
|
|
122
|
+
// enough to wrap is exactly the kind worth folding. `height` is the laid-out
|
|
123
|
+
// row count and reads 0 before the first layout, so the line count is the floor.
|
|
124
|
+
function commandRows(node, text) {
|
|
125
|
+
const height = typeof node?.height === "number" ? node.height : 0;
|
|
126
|
+
return Math.max(text.split("\n").length, height);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function bulk(node, found = []) {
|
|
130
|
+
if (!node || node.isDestroyed) return found;
|
|
131
|
+
if (isDiff(node) || isCode(node)) {
|
|
132
|
+
found.push(node);
|
|
133
|
+
return found;
|
|
134
|
+
}
|
|
135
|
+
for (const child of children(node)) bulk(child, found);
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The transcript is the only scrollbox in the TUI that asks to stick to the
|
|
140
|
+
// bottom. Staying inside it is what keeps the permission dialog's diff preview
|
|
141
|
+
// untouched -- you should always see in full what you are about to approve.
|
|
142
|
+
function isTranscript(node) {
|
|
143
|
+
return (
|
|
144
|
+
typeof node?.scrollTo === "function" &&
|
|
145
|
+
typeof node?.scrollHeight === "number" &&
|
|
146
|
+
node.stickyScroll === true &&
|
|
147
|
+
node.stickyStart === "bottom"
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function findTranscript(node) {
|
|
152
|
+
if (!node || node.isDestroyed) return;
|
|
153
|
+
if (isTranscript(node)) return node;
|
|
154
|
+
if (typeof node.scrollTo === "function") return;
|
|
155
|
+
for (const child of children(node)) {
|
|
156
|
+
const hit = findTranscript(child);
|
|
157
|
+
if (hit) return hit;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// A V2 BlockTool renders the header first: a row box whose first two children
|
|
162
|
+
// are the label text ("# Wrote") and the path value. Anything else is not one
|
|
163
|
+
// of ours. The row is returned with the label node so a folded header can be
|
|
164
|
+
// restated with its stats.
|
|
165
|
+
function blockHeader(block) {
|
|
166
|
+
for (const row of children(block)) {
|
|
167
|
+
const rowKids = children(row);
|
|
168
|
+
// Label plus the path value. The guard also keeps the plain-text read --
|
|
169
|
+
// which rebuilds a string every call -- off the leaf nodes, and the
|
|
170
|
+
// transcript is mostly leaf nodes.
|
|
171
|
+
if (rowKids.length < 2) continue;
|
|
172
|
+
// <Show> may leave childless placeholders around the label, so scan the
|
|
173
|
+
// row for the label rather than assume it is the first child. It must still
|
|
174
|
+
// have a sibling after it: that is the path value.
|
|
175
|
+
for (let i = 0; i < rowKids.length - 1; i++) {
|
|
176
|
+
const label = plain(rowKids[i]);
|
|
177
|
+
if (typeof label !== "string") continue;
|
|
178
|
+
const base = baseLabel(label);
|
|
179
|
+
if (!isLabel(base)) continue;
|
|
180
|
+
return { row, node: rowKids[i], label: base };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function scan(node, hits = [], shell = false) {
|
|
187
|
+
if (!node || node.isDestroyed) return hits;
|
|
188
|
+
// A matched block never contains another one, so stop descending.
|
|
189
|
+
if (blockHeader(node) || (shell && shellCommand(node))) {
|
|
190
|
+
hits.push(node);
|
|
191
|
+
return hits;
|
|
192
|
+
}
|
|
193
|
+
for (const child of children(node)) scan(child, hits, shell);
|
|
194
|
+
return hits;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// "+12 −3" from a unified diff, "42 lines" from a written file. Counted off the
|
|
198
|
+
// renderable's own props, so it stays right even for parts the TUI store has
|
|
199
|
+
// already dropped.
|
|
200
|
+
function summarise(nodes) {
|
|
201
|
+
let added = 0;
|
|
202
|
+
let removed = 0;
|
|
203
|
+
let lines = 0;
|
|
204
|
+
let diffs = 0;
|
|
205
|
+
for (const node of nodes) {
|
|
206
|
+
if (isDiff(node)) {
|
|
207
|
+
diffs++;
|
|
208
|
+
for (const line of node.diff.split("\n")) {
|
|
209
|
+
if (line.startsWith("+++") || line.startsWith("---")) continue;
|
|
210
|
+
if (line.startsWith("+")) added++;
|
|
211
|
+
else if (line.startsWith("-")) removed++;
|
|
212
|
+
}
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
lines += node.content.split("\n").length;
|
|
216
|
+
}
|
|
217
|
+
if (diffs) return { size: added + removed, label: `+${added} −${removed}` };
|
|
218
|
+
return { size: lines, label: `${lines} ${lines === 1 ? "line" : "lines"}` };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Debug-only: one line per renderable, for aligning the matcher with a real
|
|
222
|
+
// transcript when a live run reports `blocks: 0`. Truncated so a big session
|
|
223
|
+
// still writes quickly.
|
|
224
|
+
function dumpNode(node, depth, lines, limit) {
|
|
225
|
+
if (!node || node.isDestroyed || lines.length >= limit) return;
|
|
226
|
+
const kids = children(node);
|
|
227
|
+
const text = plain(node);
|
|
228
|
+
const tags = [];
|
|
229
|
+
if (isDiff(node)) tags.push("diff");
|
|
230
|
+
if (isCode(node)) tags.push("code");
|
|
231
|
+
if (node.stickyScroll === true) tags.push("sticky");
|
|
232
|
+
lines.push(
|
|
233
|
+
`${" ".repeat(depth)}${node.constructor?.name ?? "node"} kids=${kids.length}` +
|
|
234
|
+
(typeof text === "string" ? ` text=${JSON.stringify(text.slice(0, 80))}` : "") +
|
|
235
|
+
(tags.length ? ` [${tags.join(",")}]` : "") +
|
|
236
|
+
` vis=${node.visible} h=${node.height} ov=${node.overflow}`,
|
|
237
|
+
);
|
|
238
|
+
for (const kid of kids) dumpNode(kid, depth + 1, lines, limit);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export const PLUGIN_ID = "opencode-fold-diffs";
|
|
242
|
+
|
|
243
|
+
export default {
|
|
244
|
+
id: PLUGIN_ID,
|
|
245
|
+
setup(context) {
|
|
246
|
+
const opts = { ...DEFAULTS, ...(context.options ?? {}) };
|
|
247
|
+
const peek = Math.max(0, Number(opts.lines) || 0);
|
|
248
|
+
const floor = Math.max(0, Number(opts.min_lines) || 0);
|
|
249
|
+
const shell = opts.bash === true;
|
|
250
|
+
const shellPeek = Math.max(0, Number(opts.bash_lines) || 0);
|
|
251
|
+
|
|
252
|
+
// Folded blocks, by their block renderable. WeakMap so a session switch,
|
|
253
|
+
// which destroys the renderables, drops the state with them.
|
|
254
|
+
const known = new WeakMap();
|
|
255
|
+
// The mode new blocks adopt. ctrl+o flips it, so "expand everything" also
|
|
256
|
+
// means "and stop folding what arrives next", the way a verbose toggle works.
|
|
257
|
+
let folding = opts.folded !== false;
|
|
258
|
+
// Set once the header rewrite is proven not to take, so we stop retrying it.
|
|
259
|
+
let titles = opts.stats !== false;
|
|
260
|
+
|
|
261
|
+
let cached;
|
|
262
|
+
function transcript() {
|
|
263
|
+
if (cached && !cached.isDestroyed) return cached;
|
|
264
|
+
cached = findTranscript(context.renderer.root);
|
|
265
|
+
return cached;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function apply(state, fold) {
|
|
269
|
+
state.folded = fold;
|
|
270
|
+
state.body.forEach((node, index) => {
|
|
271
|
+
try {
|
|
272
|
+
// `visible = false` sets Yoga display:none, which removes the body
|
|
273
|
+
// from layout entirely. A zero max-height alone was not enough: Yoga
|
|
274
|
+
// clamped the box to zero rows but OpenTUI still painted the diff.
|
|
275
|
+
// A positive `lines` peek keeps the first body visible instead.
|
|
276
|
+
const peekRow = fold && index === 0 && state.peek > 0;
|
|
277
|
+
node.visible = !fold || peekRow;
|
|
278
|
+
node.maxHeight = peekRow ? state.peek : undefined;
|
|
279
|
+
node.overflow = fold ? "hidden" : state.overflow[index];
|
|
280
|
+
} catch {}
|
|
281
|
+
});
|
|
282
|
+
// With the body at zero height, the block's own padding and the gap it
|
|
283
|
+
// keeps between children are all that is left. Collapse the chrome too so
|
|
284
|
+
// a folded block reads as the single row it now is. The restored values
|
|
285
|
+
// are BlockTool's own (paddingTop/Bottom 1, gap 1) because opentui gives
|
|
286
|
+
// these setters no getters to read the originals back from. Never for a
|
|
287
|
+
// shell block: its output is a sibling of the command and stays on screen,
|
|
288
|
+
// so the chrome is still holding something up.
|
|
289
|
+
if (state.chrome) {
|
|
290
|
+
try {
|
|
291
|
+
state.block.gap = fold ? 0 : 1;
|
|
292
|
+
state.block.paddingTop = fold ? 0 : 1;
|
|
293
|
+
state.block.paddingBottom = fold ? 0 : 1;
|
|
294
|
+
} catch {}
|
|
295
|
+
}
|
|
296
|
+
if (!titles || !state.title) return;
|
|
297
|
+
const next = fold ? `${state.text} ${state.suffix}` : state.text;
|
|
298
|
+
try {
|
|
299
|
+
state.title.content = next;
|
|
300
|
+
} catch {
|
|
301
|
+
titles = false;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// The host owns that text node. If solid is not letting go of it there is
|
|
305
|
+
// nothing to be gained by asking again on every block.
|
|
306
|
+
if (plain(state.title) !== next) titles = false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function adoptDiff(block) {
|
|
310
|
+
const header = blockHeader(block);
|
|
311
|
+
if (!header) return false;
|
|
312
|
+
// Everything after the header that actually carries a diff or a file
|
|
313
|
+
// body. Diagnostics and the error line carry neither, so an edit that
|
|
314
|
+
// broke the build still says so while folded.
|
|
315
|
+
const body = [];
|
|
316
|
+
const heavy = [];
|
|
317
|
+
for (const child of children(block)) {
|
|
318
|
+
if (child === header.row) continue;
|
|
319
|
+
const found = bulk(child);
|
|
320
|
+
if (!found.length) continue;
|
|
321
|
+
body.push(child);
|
|
322
|
+
heavy.push(...found);
|
|
323
|
+
}
|
|
324
|
+
if (!body.length) return false;
|
|
325
|
+
const stats = summarise(heavy);
|
|
326
|
+
if (stats.size < floor) return false;
|
|
327
|
+
|
|
328
|
+
const state = {
|
|
329
|
+
block,
|
|
330
|
+
body,
|
|
331
|
+
overflow: body.map((node) => node.overflow),
|
|
332
|
+
title: header.node,
|
|
333
|
+
text: header.label,
|
|
334
|
+
suffix: `${stats.label} · click to expand`,
|
|
335
|
+
peek,
|
|
336
|
+
chrome: peek === 0,
|
|
337
|
+
folded: false,
|
|
338
|
+
};
|
|
339
|
+
known.set(block, state);
|
|
340
|
+
|
|
341
|
+
block.onMouseUp = () => {
|
|
342
|
+
// Copy-on-select is a drag ending on the block; that is not a click.
|
|
343
|
+
if (context.renderer.getSelection?.()?.getSelectedText?.()) return;
|
|
344
|
+
apply(state, !state.folded);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
if (folding) apply(state, true);
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function adoptShell(block) {
|
|
352
|
+
const found = shellCommand(block);
|
|
353
|
+
if (!found) return;
|
|
354
|
+
if (commandRows(found.node, found.text) < floor) return;
|
|
355
|
+
|
|
356
|
+
const state = {
|
|
357
|
+
block,
|
|
358
|
+
body: [found.node],
|
|
359
|
+
overflow: [found.node.overflow],
|
|
360
|
+
title: undefined,
|
|
361
|
+
peek: shellPeek,
|
|
362
|
+
chrome: false,
|
|
363
|
+
folded: false,
|
|
364
|
+
};
|
|
365
|
+
known.set(block, state);
|
|
366
|
+
|
|
367
|
+
// The block's own onMouseUp belongs to the host here -- it is what expands
|
|
368
|
+
// the collapsed command/output -- and opentui declares the handler as a
|
|
369
|
+
// setter with no getter, so it cannot be read back and chained. Take the
|
|
370
|
+
// command text instead and stop the event on it: clicking the command
|
|
371
|
+
// folds the command, clicking anywhere else in the block still does
|
|
372
|
+
// exactly what it did before this plugin loaded.
|
|
373
|
+
found.node.onMouseUp = (event) => {
|
|
374
|
+
if (context.renderer.getSelection?.()?.getSelectedText?.()) return;
|
|
375
|
+
apply(state, !state.folded);
|
|
376
|
+
event?.stopPropagation?.();
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
if (folding) apply(state, true);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function adopt(block) {
|
|
383
|
+
if (adoptDiff(block)) return;
|
|
384
|
+
if (shell) adoptShell(block);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function sweep() {
|
|
388
|
+
if (context.ui.router.current().type !== "session") return;
|
|
389
|
+
const box = transcript();
|
|
390
|
+
if (!box) return;
|
|
391
|
+
for (const block of scan(box, [], shell)) {
|
|
392
|
+
if (known.has(block)) continue;
|
|
393
|
+
adopt(block);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function all(fold) {
|
|
398
|
+
folding = fold;
|
|
399
|
+
const box = transcript();
|
|
400
|
+
if (!box) return 0;
|
|
401
|
+
let count = 0;
|
|
402
|
+
for (const block of scan(box, [], shell)) {
|
|
403
|
+
const state = known.get(block);
|
|
404
|
+
if (!state || state.folded === fold) continue;
|
|
405
|
+
apply(state, fold);
|
|
406
|
+
count++;
|
|
407
|
+
}
|
|
408
|
+
return count;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let pending;
|
|
412
|
+
function schedule() {
|
|
413
|
+
if (pending) return;
|
|
414
|
+
pending = setTimeout(() => {
|
|
415
|
+
pending = undefined;
|
|
416
|
+
sweep();
|
|
417
|
+
}, 120);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const offs = [
|
|
421
|
+
context.data.on("message.part.updated", schedule),
|
|
422
|
+
context.data.on("message.updated", schedule),
|
|
423
|
+
];
|
|
424
|
+
const timer = setInterval(sweep, SWEEP_MS);
|
|
425
|
+
schedule();
|
|
426
|
+
|
|
427
|
+
// `keymap.layer` reads Solid context, so it must run while a component is
|
|
428
|
+
// rendering, not directly during setup. A null component mounted in the
|
|
429
|
+
// `app` slot owns the layer for the plugin's lifetime.
|
|
430
|
+
function FoldCommands() {
|
|
431
|
+
context.keymap.layer(() => ({
|
|
432
|
+
mode: "global",
|
|
433
|
+
priority: 100,
|
|
434
|
+
commands: [
|
|
435
|
+
{
|
|
436
|
+
id: "opencode-fold-diffs.toggle",
|
|
437
|
+
title: "Fold / unfold file diffs",
|
|
438
|
+
group: "Plugin",
|
|
439
|
+
palette: true,
|
|
440
|
+
bind: opts.key || false,
|
|
441
|
+
run() {
|
|
442
|
+
sweep();
|
|
443
|
+
const fold = !folding;
|
|
444
|
+
const changed = all(fold);
|
|
445
|
+
context.ui.toast.show({
|
|
446
|
+
variant: "info",
|
|
447
|
+
message: changed
|
|
448
|
+
? `${fold ? "Folded" : "Unfolded"} ${changed} ${changed === 1 ? "block" : "blocks"}`
|
|
449
|
+
: `New file blocks will be ${fold ? "folded" : "unfolded"}`,
|
|
450
|
+
duration: 2000,
|
|
451
|
+
});
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
// Reports what the plugin can see, so "nothing happens" can be told
|
|
456
|
+
// apart from "no transcript yet" or "blocks not matched".
|
|
457
|
+
id: "opencode-fold-diffs.diagnose",
|
|
458
|
+
title: "Fold diffs: diagnose",
|
|
459
|
+
group: "Plugin",
|
|
460
|
+
palette: true,
|
|
461
|
+
run() {
|
|
462
|
+
const box = transcript();
|
|
463
|
+
const blocks = box ? scan(box, [], shell) : [];
|
|
464
|
+
const folded = blocks.filter((block) => known.get(block)?.folded).length;
|
|
465
|
+
let dump;
|
|
466
|
+
try {
|
|
467
|
+
const summary = [
|
|
468
|
+
`transcript=${Boolean(box)} blocks=${blocks.length} folded=${folded} stats=${titles ? "on" : "off"}`,
|
|
469
|
+
];
|
|
470
|
+
for (const block of blocks) {
|
|
471
|
+
const state = known.get(block);
|
|
472
|
+
const head = blockHeader(block);
|
|
473
|
+
summary.push(
|
|
474
|
+
`block "${head?.label ?? "?"}" folded=${state?.folded} body=[` +
|
|
475
|
+
(state?.body ?? [])
|
|
476
|
+
.map((node) => `${node.constructor?.name}(vis=${node.visible},ov=${node.overflow})`)
|
|
477
|
+
.join(", ") +
|
|
478
|
+
"]",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
const target =
|
|
482
|
+
opts.dump === false
|
|
483
|
+
? undefined
|
|
484
|
+
: typeof opts.dump === "string" && opts.dump
|
|
485
|
+
? opts.dump
|
|
486
|
+
: join(tmpdir(), "opencode-fold-diffs-tree.txt");
|
|
487
|
+
if (target) {
|
|
488
|
+
const lines = [];
|
|
489
|
+
dumpNode(box ?? context.renderer.root, 0, lines, 600);
|
|
490
|
+
dump = target;
|
|
491
|
+
writeFileSync(target, summary.concat("", lines).join("\n"));
|
|
492
|
+
}
|
|
493
|
+
} catch {}
|
|
494
|
+
context.ui.toast.show({
|
|
495
|
+
title: "opencode-fold-diffs",
|
|
496
|
+
variant: box ? "info" : "warning",
|
|
497
|
+
duration: 5000,
|
|
498
|
+
message: box
|
|
499
|
+
? `transcript: yes · blocks: ${blocks.length} · folded: ${folded} · stats: ${titles ? "on" : "off"}` +
|
|
500
|
+
(dump ? ` · dump: ${dump}` : "")
|
|
501
|
+
: "transcript: not found — open a session first",
|
|
502
|
+
});
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
bindings: opts.key ? ["opencode-fold-diffs.toggle"] : [],
|
|
507
|
+
}));
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
const stopCommands = context.ui.slot({ append: "app", render: () => FoldCommands() });
|
|
511
|
+
|
|
512
|
+
return () => {
|
|
513
|
+
stopCommands();
|
|
514
|
+
clearInterval(timer);
|
|
515
|
+
if (pending) clearTimeout(pending);
|
|
516
|
+
for (const off of offs) if (typeof off === "function") off();
|
|
517
|
+
};
|
|
518
|
+
},
|
|
519
|
+
};
|