Repository navigation
Expand file tree
/
Copy pathtui.js
More file actions
555 lines (512 loc) · 21.4 KB
/
Copy pathtui.js
File metadata and controls
555 lines (512 loc) · 21.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
// Fold write / edit / apply_patch blocks -- and, optionally, long bash commands
// -- in the OpenCode V2 transcript.
//
// opencode already collapses bash OUTPUT to ten lines and (since V2) also trims
// a long bash COMMAND to two, both with click-to-expand. The file tools that
// produce the most scrollback -- write, edit and apply_patch -- are the ones it
// does not touch: they render the whole diff, or the whole written file,
// forever. The three upstream requests for a setting (#9089 minimal diff
// display, #14511 a toggle keybind, #19074 collapse tool output) were all
// closed without one, so this does it from a plugin.
//
// A folded block renders as its header line -- "# Wrote 40 lines · click to
// expand src/app.ts" -- and opens on click, or with the optional `key`
// binding for every block at once.
//
// Options (cli.json -> { "package": "@cardinal4/opencode-fold-diffs", "options": { ... } }):
// lines lines of the body left visible when folded (default 0, title only)
// min_lines leave blocks with fewer content lines alone (default 6)
// stats append "40 lines · click to expand" to the header (default true)
// folded new blocks start folded (default true)
// key binding that folds/unfolds every block (default "", none)
// bash fold long bash commands too (default false on V2)
// bash_lines rows of the command left visible when folded (default 1)
//
// The plugin is loaded by the OpenCode V2 CLI from the package's "./tui" export
// and receives a plugin Context (see @opencode/plugin/tui). It exports a plain
// { id, setup } definition; Plugin.define() is an identity helper, so the
// shape is the same without a runtime dependency on @opencode/plugin.
import { writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const DEFAULTS = {
lines: 0,
min_lines: 6,
stats: true,
folded: true,
// No binding by default: ctrl+o belongs to OpenCode's "Open recent sessions
// and projects". Set `key` to opt in to a fold/unfold-all shortcut.
key: "",
// V2 collapses a long command to two lines and its output to ten, both with
// click-to-expand, so the command is no longer the "the host never trims it"
// gap it was in V1. Off by default; turn on to tighten it to one line.
bash: false,
bash_lines: 1,
// Where "Fold diffs: diagnose" writes its tree dump. "" is the default
// (/tmp/opencode-fold-diffs-tree.txt); false turns the dump off.
dump: "",
};
// Header labels of the file-writing tools, as V2 renders them. Unlike V1, V2
// splits a block header into a label text node ("# Wrote", "← Edit", ...) and a
// separate path node, so these match the label alone. The stats suffix a folded
// header carries is stripped by baseLabel() before matching, so re-adopting a
// block on a later scan is idempotent.
const LABELS = ["# Wrote", "← Edit", "← Patched", "# Created", "# Deleted", "# Moved"];
function isLabel(text) {
return LABELS.includes(text);
}
// The stats suffix this plugin appends to a folded header. Stripping it makes
// re-adopting a block idempotent: a hot reload rebuilds the plugin, re-walks the
// same transcript and must not append the suffix a second time.
const SUFFIX = / (?:\d+ lines?|\+\d+ −\d+) · click to expand$/;
function baseLabel(text) {
const match = SUFFIX.exec(text);
return match ? text.slice(0, match.index) : text;
}
// A shell block carries no header of its own, and V2 writes the command with a
// "$ " in front of it.
const PROMPT = "$ ";
// How often to re-scan when nothing is streaming. Events cover the live case;
// this catches a session opened from history, whose parts arrive as one batch
// before any event this plugin sees.
const SWEEP_MS = 2000;
function children(node) {
return typeof node?.getChildren === "function" ? node.getChildren() : [];
}
function plain(node) {
const value = node?.plainText;
return typeof value === "string" ? value : undefined;
}
// Duck-typing, not instanceof: the classes live in the host's bundled
// @opentui/core and are minified, so their names are not stable. A diff
// renderable is the only thing in the tree carrying a `diff` string. A code
// renderable is the only thing whose `content` is a string -- text renderables
// carry a styled-text `content` object and expose `plainText` instead. Never key
// off `filetype`: OpenCode stopped setting it in 2.0.16 and restored it in
// 2.0.27, and requiring it silently stopped every write block from folding.
function isDiff(node) {
return typeof node?.diff === "string";
}
function isCode(node) {
return typeof node?.content === "string";
}
// The command text of a bash block, or nothing. V2 wraps the command and its
// output in one box, and the command is the first child of that box prefixed
// with "$ ". While the tool is still running the host renders the command in a
// row next to a spinner, so a running command is skipped and picked up by a
// later sweep once it settles.
function shellCommand(block) {
for (const child of children(block)) {
const inner = children(child);
if (!inner.length) continue;
// solid's <Show> leaves childless placeholders, so scan rather than assume
// the command is the first grandchild.
for (const node of inner) {
const text = plain(node);
if (typeof text === "string" && text.startsWith(PROMPT)) return { node, text };
}
}
return;
}
// Rows the command occupies, not lines it contains: a single-line command long
// enough to wrap is exactly the kind worth folding. `height` is the laid-out
// row count and reads 0 before the first layout, so the line count is the floor.
function commandRows(node, text) {
const height = typeof node?.height === "number" ? node.height : 0;
return Math.max(text.split("\n").length, height);
}
function bulk(node, found = []) {
if (!node || node.isDestroyed) return found;
if (isDiff(node) || isCode(node)) {
found.push(node);
return found;
}
for (const child of children(node)) bulk(child, found);
return found;
}
// The session transcript is the scrollbox that sticks to the bottom. Since V2
// the shell output viewer is also a sticky scrollbox, but it is a dialog and
// must never be mistaken for the transcript, so it is excluded by id. Staying
// inside the transcript is what keeps the permission dialog's diff preview
// untouched -- you should always see in full what you are about to approve.
const SHELL_VIEWER_ID = "shell-output-scroll";
function isTranscript(node) {
return (
typeof node?.scrollTo === "function" &&
typeof node?.scrollHeight === "number" &&
node.stickyScroll === true &&
node.stickyStart === "bottom" &&
node.id !== SHELL_VIEWER_ID
);
}
function findTranscript(node) {
if (!node || node.isDestroyed) return;
if (isTranscript(node)) return node;
if (typeof node.scrollTo === "function") return;
for (const child of children(node)) {
const hit = findTranscript(child);
if (hit) return hit;
}
}
// A V2 BlockTool renders the header first: a row box whose first two children
// are the label text ("# Wrote") and the path value. Anything else is not one
// of ours. The row is returned with the label node so a folded header can be
// restated with its stats.
function blockHeader(block) {
for (const row of children(block)) {
const rowKids = children(row);
// Label plus the path value. The guard also keeps the plain-text read --
// which rebuilds a string every call -- off the leaf nodes, and the
// transcript is mostly leaf nodes.
if (rowKids.length < 2) continue;
// <Show> may leave childless placeholders around the label, so scan the
// row for the label rather than assume it is the first child. It must still
// have a sibling after it: that is the path value.
for (let i = 0; i < rowKids.length - 1; i++) {
const label = plain(rowKids[i]);
if (typeof label !== "string") continue;
const base = baseLabel(label);
if (!isLabel(base)) continue;
return { row, node: rowKids[i], label: base };
}
}
return;
}
function scan(node, hits = [], shell = false) {
if (!node || node.isDestroyed) return hits;
// A matched block never contains another one, so stop descending.
if (blockHeader(node) || (shell && shellCommand(node))) {
hits.push(node);
return hits;
}
for (const child of children(node)) scan(child, hits, shell);
return hits;
}
// "+12 −3" from a unified diff, "42 lines" from a written file. Counted off the
// renderable's own props, so it stays right even for parts the TUI store has
// already dropped.
function summarise(nodes) {
let added = 0;
let removed = 0;
let lines = 0;
let diffs = 0;
for (const node of nodes) {
if (isDiff(node)) {
diffs++;
for (const line of node.diff.split("\n")) {
if (line.startsWith("+++") || line.startsWith("---")) continue;
if (line.startsWith("+")) added++;
else if (line.startsWith("-")) removed++;
}
continue;
}
lines += node.content.split("\n").length;
}
if (diffs) return { size: added + removed, label: `+${added} −${removed}` };
return { size: lines, label: `${lines} ${lines === 1 ? "line" : "lines"}` };
}
// Debug-only: one line per renderable, for aligning the matcher with a real
// transcript when a live run reports `blocks: 0`. Truncated so a big session
// still writes quickly.
function dumpNode(node, depth, lines, limit) {
if (!node || node.isDestroyed || lines.length >= limit) return;
const kids = children(node);
const text = plain(node);
const tags = [];
if (isDiff(node)) tags.push("diff");
if (isCode(node)) tags.push("code");
if (node.stickyScroll === true) tags.push("sticky");
lines.push(
`${" ".repeat(depth)}${node.constructor?.name ?? "node"} kids=${kids.length}` +
(typeof text === "string" ? ` text=${JSON.stringify(text.slice(0, 80))}` : "") +
(tags.length ? ` [${tags.join(",")}]` : "") +
` vis=${node.visible} h=${node.height} ov=${node.overflow}`,
);
for (const kid of kids) dumpNode(kid, depth + 1, lines, limit);
}
// 2.0.23 was the first V2 release whose `keymap.layer` may be called from
// setup; before that it reads Solid context and needs a mounted component.
// An unparseable version takes the fallback, which works on every V2 build.
function supportsSetupKeymap(version) {
const match = /(\d+)\.(\d+)\.(\d+)/.exec(String(version ?? ""));
if (!match) return false;
const [major, minor, patch] = match.slice(1).map(Number);
return major > 2 || (major === 2 && (minor > 0 || patch >= 23));
}
export const PLUGIN_ID = "opencode-fold-diffs";
export default {
id: PLUGIN_ID,
setup(context) {
const opts = { ...DEFAULTS, ...(context.options ?? {}) };
const peek = Math.max(0, Number(opts.lines) || 0);
const floor = Math.max(0, Number(opts.min_lines) || 0);
const shell = opts.bash === true;
const shellPeek = Math.max(0, Number(opts.bash_lines) || 0);
// Folded blocks, by their block renderable. WeakMap so a session switch,
// which destroys the renderables, drops the state with them.
const known = new WeakMap();
// The mode new blocks adopt. ctrl+o flips it, so "expand everything" also
// means "and stop folding what arrives next", the way a verbose toggle works.
let folding = opts.folded !== false;
// Set once the header rewrite is proven not to take, so we stop retrying it.
let titles = opts.stats !== false;
let cached;
function transcript() {
if (cached && !cached.isDestroyed) return cached;
cached = findTranscript(context.renderer.root);
return cached;
}
function apply(state, fold) {
state.folded = fold;
state.body.forEach((node, index) => {
try {
// `visible = false` sets Yoga display:none, which removes the body
// from layout entirely. A zero max-height alone was not enough: Yoga
// clamped the box to zero rows but OpenTUI still painted the diff.
// A positive `lines` peek keeps the first body visible instead.
const peekRow = fold && index === 0 && state.peek > 0;
node.visible = !fold || peekRow;
node.maxHeight = peekRow ? state.peek : undefined;
node.overflow = fold ? "hidden" : state.overflow[index];
} catch {}
});
// With the body at zero height, the block's own padding and the gap it
// keeps between children are all that is left. Collapse the chrome too so
// a folded block reads as the single row it now is. The restored values
// are BlockTool's own (paddingTop/Bottom 1, gap 1) because opentui gives
// these setters no getters to read the originals back from. Never for a
// shell block: its output is a sibling of the command and stays on screen,
// so the chrome is still holding something up.
if (state.chrome) {
try {
state.block.gap = fold ? 0 : 1;
state.block.paddingTop = fold ? 0 : 1;
state.block.paddingBottom = fold ? 0 : 1;
} catch {}
}
if (!titles || !state.title) return;
const next = fold ? `${state.text} ${state.suffix}` : state.text;
try {
state.title.content = next;
} catch {
titles = false;
return;
}
// The host owns that text node. If solid is not letting go of it there is
// nothing to be gained by asking again on every block.
if (plain(state.title) !== next) titles = false;
}
function adoptDiff(block) {
const header = blockHeader(block);
if (!header) return false;
// Everything after the header that actually carries a diff or a file
// body. Diagnostics and the error line carry neither, so an edit that
// broke the build still says so while folded.
const body = [];
const heavy = [];
for (const child of children(block)) {
if (child === header.row) continue;
const found = bulk(child);
if (!found.length) continue;
body.push(child);
heavy.push(...found);
}
if (!body.length) return false;
const stats = summarise(heavy);
if (stats.size < floor) return false;
const state = {
block,
body,
overflow: body.map((node) => node.overflow),
title: header.node,
text: header.label,
suffix: `${stats.label} · click to expand`,
peek,
chrome: peek === 0,
folded: false,
};
known.set(block, state);
block.onMouseUp = () => {
// Copy-on-select is a drag ending on the block; that is not a click.
if (context.renderer.getSelection?.()?.getSelectedText?.()) return;
apply(state, !state.folded);
};
if (folding) apply(state, true);
return true;
}
function adoptShell(block) {
const found = shellCommand(block);
if (!found) return;
if (commandRows(found.node, found.text) < floor) return;
const state = {
block,
body: [found.node],
overflow: [found.node.overflow],
title: undefined,
peek: shellPeek,
chrome: false,
folded: false,
};
known.set(block, state);
// The block's own onMouseUp belongs to the host here -- it is what expands
// the collapsed command/output -- and opentui declares the handler as a
// setter with no getter, so it cannot be read back and chained. Take the
// command text instead and stop the event on it: clicking the command
// folds the command, clicking anywhere else in the block still does
// exactly what it did before this plugin loaded.
found.node.onMouseUp = (event) => {
if (context.renderer.getSelection?.()?.getSelectedText?.()) return;
apply(state, !state.folded);
event?.stopPropagation?.();
};
if (folding) apply(state, true);
}
function adopt(block) {
if (adoptDiff(block)) return;
if (shell) adoptShell(block);
}
function sweep() {
if (context.ui.router.current().type !== "session") return;
const box = transcript();
if (!box) return;
for (const block of scan(box, [], shell)) {
if (known.has(block)) continue;
adopt(block);
}
}
function all(fold) {
folding = fold;
const box = transcript();
if (!box) return 0;
let count = 0;
for (const block of scan(box, [], shell)) {
const state = known.get(block);
if (!state || state.folded === fold) continue;
apply(state, fold);
count++;
}
return count;
}
let pending;
function schedule() {
if (pending) return;
pending = setTimeout(() => {
pending = undefined;
sweep();
}, 120);
}
// V1's message.part.updated / message.updated are legacy schemas in V2 --
// the client emits the session.* events instead, so the V1 names were a
// silent no-op and a new block only folded on the sweeping timer.
const events = [
"session.message.content.updated", // a part was appended or replaced
"session.tool.success", // a tool body (diff or file) appeared
"session.tool.failed", // a failed tool still renders its block
"session.step.ended", // the step's final content has arrived
"session.execution.succeeded", // the turn settled; take a final look
];
if (shell) events.push("session.shell.ended"); // the command now carries its "$ " prefix
const offs = events.map((name) => context.data.on(name, schedule));
const timer = setInterval(sweep, SWEEP_MS);
schedule();
// The command layer itself is identical everywhere; only who registers it
// differs. 2.0.23+ runs `keymap.layer` from setup and owns its teardown.
// Older V2 reads Solid context when registering, so there the layer has to
// be created by a mounted component. The `app` slot supplies one.
function registerCommands() {
context.keymap.layer(() => ({
mode: "global",
priority: 100,
commands: [
{
id: "opencode-fold-diffs.toggle",
title: "Fold / unfold file diffs",
group: "Plugin",
palette: true,
bind: opts.key || false,
run() {
sweep();
const fold = !folding;
const changed = all(fold);
context.ui.toast.show({
variant: "info",
message: changed
? `${fold ? "Folded" : "Unfolded"} ${changed} ${changed === 1 ? "block" : "blocks"}`
: `New file blocks will be ${fold ? "folded" : "unfolded"}`,
duration: 2000,
});
},
},
{
// Reports what the plugin can see, so "nothing happens" can be told
// apart from "no transcript yet" or "blocks not matched".
id: "opencode-fold-diffs.diagnose",
title: "Fold diffs: diagnose",
group: "Plugin",
palette: true,
run() {
const box = transcript();
const blocks = box ? scan(box, [], shell) : [];
const folded = blocks.filter((block) => known.get(block)?.folded).length;
let dump;
try {
const summary = [
`transcript=${Boolean(box)} blocks=${blocks.length} folded=${folded} stats=${titles ? "on" : "off"}`,
];
for (const block of blocks) {
const state = known.get(block);
const head = blockHeader(block);
summary.push(
`block "${head?.label ?? "?"}" folded=${state?.folded} body=[` +
(state?.body ?? [])
.map((node) => `${node.constructor?.name}(vis=${node.visible},ov=${node.overflow})`)
.join(", ") +
"]",
);
}
const target =
opts.dump === false
? undefined
: typeof opts.dump === "string" && opts.dump
? opts.dump
: join(tmpdir(), "opencode-fold-diffs-tree.txt");
if (target) {
const lines = [];
dumpNode(box ?? context.renderer.root, 0, lines, 600);
dump = target;
writeFileSync(target, summary.concat("", lines).join("\n"));
}
} catch {}
context.ui.toast.show({
title: "opencode-fold-diffs",
variant: box ? "info" : "warning",
duration: 5000,
message: box
? `transcript: yes · blocks: ${blocks.length} · folded: ${folded} · stats: ${titles ? "on" : "off"}` +
(dump ? ` · dump: ${dump}` : "")
: "transcript: not found — open a session first",
});
},
},
],
bindings: opts.key ? ["opencode-fold-diffs.toggle"] : [],
}));
}
let stopCommands = () => {};
if (supportsSetupKeymap(context.app?.version)) {
registerCommands();
} else {
function FoldCommands() {
registerCommands();
return null;
}
stopCommands = context.ui.slot({ append: "app", render: () => FoldCommands() });
}
return () => {
stopCommands();
clearInterval(timer);
if (pending) clearTimeout(pending);
for (const off of offs) if (typeof off === "function") off();
};
},
};