pi-better-harness 0.23.0 → 0.24.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/README.md +3 -1
- package/extensions/minimal-output/hook.ts +67 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,7 +82,9 @@ into one disclosure row showing the call count and any failures. Only the failur
|
|
|
82
82
|
count uses the error color; the disclosure and total stay muted. Assistant text
|
|
83
83
|
stays visible, and restored history uses the same folded view. In newer Pi
|
|
84
84
|
fullscreen mode, click a block disclosure to reveal its call rows, then click a
|
|
85
|
-
call to expand its original details.
|
|
85
|
+
call to expand its original details. Hover highlights compact call and disclosure
|
|
86
|
+
rows using the selection background (reverse video for themes without one), and
|
|
87
|
+
tool icons sit centered in a three-column gutter. Click the disclosure again to refold the
|
|
86
88
|
block; native expanded call/result clicks collapse individual details. Regular
|
|
87
89
|
terminals keep mouse input for terminal selection and scrollback; Ctrl+O expands
|
|
88
90
|
all original tool details in both modes and folds them again on the next toggle.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
1
|
+
import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
2
2
|
import { readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
@@ -6,6 +6,11 @@ import { pathToFileURL } from "node:url";
|
|
|
6
6
|
import { stripVTControlCharacters } from "node:util";
|
|
7
7
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
|
|
9
|
+
interface ToolUI {
|
|
10
|
+
requestRender(): void;
|
|
11
|
+
children?: TranscriptComponent[];
|
|
12
|
+
handleViewportInput?(data: string): unknown;
|
|
13
|
+
}
|
|
9
14
|
interface ToolComponent {
|
|
10
15
|
toolName: string;
|
|
11
16
|
toolCallId: string;
|
|
@@ -16,7 +21,7 @@ interface ToolComponent {
|
|
|
16
21
|
result?: { isError?: boolean };
|
|
17
22
|
showImages: boolean;
|
|
18
23
|
resultRendererComponent?: unknown;
|
|
19
|
-
ui:
|
|
24
|
+
ui: ToolUI;
|
|
20
25
|
invalidate(): void;
|
|
21
26
|
setExpanded(expanded: boolean): void;
|
|
22
27
|
render(width: number): string[];
|
|
@@ -30,7 +35,7 @@ interface TranscriptComponent extends Component {
|
|
|
30
35
|
interface ToolGroup { members: ToolComponent[]; open: boolean; expanded: number }
|
|
31
36
|
|
|
32
37
|
type Method = (this: ToolComponent, ...args: unknown[]) => unknown;
|
|
33
|
-
interface ToolMouseEvent { type: string; button: string; y: number; shift?: boolean; alt?: boolean; ctrl?: boolean }
|
|
38
|
+
interface ToolMouseEvent { type: string; button: string; x: number; y: number; screenX: number; screenY: number; width: number; shift?: boolean; alt?: boolean; ctrl?: boolean }
|
|
34
39
|
type MouseHandler = (this: ToolComponent, event: ToolMouseEvent) => unknown;
|
|
35
40
|
interface ToolPrototype {
|
|
36
41
|
handleMouse?: MouseHandler;
|
|
@@ -49,6 +54,7 @@ interface HookState {
|
|
|
49
54
|
restore(): void;
|
|
50
55
|
completeRun(): void;
|
|
51
56
|
restoreCompletedCalls(ids: Iterable<string>): void;
|
|
57
|
+
handleTerminalInput(data: string): void;
|
|
52
58
|
}
|
|
53
59
|
export interface MinimalOutputHook {
|
|
54
60
|
setEnabled(enabled: boolean): void;
|
|
@@ -90,9 +96,9 @@ function compactCall(component: ToolComponent, width: number, theme?: Theme, ind
|
|
|
90
96
|
: component.toolName.replace(/_/g, " ").replace(/^./, letter => letter.toUpperCase());
|
|
91
97
|
const suffix = failed ? " (failed)" : running ? " (running)" : "";
|
|
92
98
|
const icon = TOOL_ICONS[component.toolName] ?? "\u25c7";
|
|
93
|
-
const title =
|
|
99
|
+
const title = ` ${icon} ${label}`;
|
|
94
100
|
const styled = theme
|
|
95
|
-
?
|
|
101
|
+
? ` ${theme.fg(failed ? "error" : running ? "accent" : "muted", icon)} ${theme.fg("muted", label)}${detail ? ` ${theme.fg("dim", detail)}` : ""}${theme.fg(failed ? "error" : "dim", suffix)}`
|
|
96
102
|
: `${title}${detail ? ` ${detail}` : ""}${suffix}`;
|
|
97
103
|
return [truncateToWidth(`${" ".repeat(Math.min(indent, Math.max(0, width - 1)))}${styled}`, width)];
|
|
98
104
|
}
|
|
@@ -184,6 +190,25 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
184
190
|
...(typeof prototype.handleMouse === "function" ? { handleMouse: prototype.handleMouse } : {}),
|
|
185
191
|
};
|
|
186
192
|
let mouseHandling = false;
|
|
193
|
+
const inputWrappers = new Map<ToolUI, { original: NonNullable<ToolUI["handleViewportInput"]>; wrapper: NonNullable<ToolUI["handleViewportInput"]> }>();
|
|
194
|
+
let hovered: { component: ToolComponent; row: number; left: number; top: number; width: number } | undefined;
|
|
195
|
+
const clearHover = () => {
|
|
196
|
+
if (!hovered) return;
|
|
197
|
+
const component = hovered.component;
|
|
198
|
+
hovered = undefined;
|
|
199
|
+
component.ui.requestRender();
|
|
200
|
+
};
|
|
201
|
+
const highlight = (component: ToolComponent, lines: string[], width: number): string[] => {
|
|
202
|
+
if (hovered?.component !== component) return lines;
|
|
203
|
+
const theme = getTheme?.();
|
|
204
|
+
if (width !== hovered.width || hovered.row >= lines.length) { clearHover(); return lines; }
|
|
205
|
+
if (theme) {
|
|
206
|
+
const row = lines[hovered.row] + " ".repeat(Math.max(0, width - visibleWidth(lines[hovered.row])));
|
|
207
|
+
const shaded = theme.bg("selectedBg", row);
|
|
208
|
+
lines[hovered.row] = shaded.startsWith("\x1b[49m") ? theme.inverse(row) : shaded;
|
|
209
|
+
}
|
|
210
|
+
return lines;
|
|
211
|
+
};
|
|
187
212
|
const seen = new WeakSet<ToolComponent>();
|
|
188
213
|
const components = new Set<WeakRef<ToolComponent>>();
|
|
189
214
|
let completed = new Map<string, object>();
|
|
@@ -246,6 +271,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
246
271
|
this.redraw();
|
|
247
272
|
},
|
|
248
273
|
redraw(collapse = false) {
|
|
274
|
+
clearHover();
|
|
249
275
|
for (const reference of components) {
|
|
250
276
|
const component = reference.deref();
|
|
251
277
|
if (!component) { components.delete(reference); continue; }
|
|
@@ -255,6 +281,11 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
255
281
|
}
|
|
256
282
|
},
|
|
257
283
|
restore() {
|
|
284
|
+
clearHover();
|
|
285
|
+
for (const [ui, { original, wrapper }] of inputWrappers) {
|
|
286
|
+
if (ui.handleViewportInput === wrapper) ui.handleViewportInput = original;
|
|
287
|
+
}
|
|
288
|
+
inputWrappers.clear();
|
|
258
289
|
for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "render"] as const) {
|
|
259
290
|
// Do not remove another extension's subsequently installed wrapper.
|
|
260
291
|
if (prototype[name] === wrappers[name]) Object.assign(prototype, { [name]: originals[name] });
|
|
@@ -263,10 +294,30 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
263
294
|
delete prototype[HOOK];
|
|
264
295
|
components.clear();
|
|
265
296
|
},
|
|
297
|
+
handleTerminalInput(data) {
|
|
298
|
+
if (!hovered) return;
|
|
299
|
+
// Pi routes moves only to the new target; observe input to clear a row on exit.
|
|
300
|
+
const motion = /^\x1b\[<35;(\d+);(\d+)M$/.exec(data);
|
|
301
|
+
if (motion) {
|
|
302
|
+
const x = Number(motion[1]) - 1;
|
|
303
|
+
const y = Number(motion[2]) - 1;
|
|
304
|
+
if (y === hovered.top && x >= hovered.left && x < hovered.left + hovered.width) return;
|
|
305
|
+
}
|
|
306
|
+
clearHover();
|
|
307
|
+
},
|
|
266
308
|
};
|
|
267
309
|
const current = state;
|
|
268
310
|
const wrappers = {
|
|
269
311
|
updateDisplay(this: ToolComponent, ...args: unknown[]) {
|
|
312
|
+
if (typeof this.ui.handleViewportInput === "function" && !inputWrappers.has(this.ui)) {
|
|
313
|
+
const original = this.ui.handleViewportInput;
|
|
314
|
+
const wrapper = function(this: ToolUI, data: string) {
|
|
315
|
+
current.handleTerminalInput(data);
|
|
316
|
+
return original.call(this, data);
|
|
317
|
+
};
|
|
318
|
+
inputWrappers.set(this.ui, { original, wrapper });
|
|
319
|
+
this.ui.handleViewportInput = wrapper;
|
|
320
|
+
}
|
|
270
321
|
if (!seen.has(this)) { seen.add(this); components.add(new WeakRef(this)); }
|
|
271
322
|
if (!current.enabled || this.expanded) return originals.updateDisplay.apply(this, args);
|
|
272
323
|
const showImages = this.showImages;
|
|
@@ -281,6 +332,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
281
332
|
}
|
|
282
333
|
},
|
|
283
334
|
setExpanded(this: ToolComponent, ...args: unknown[]) {
|
|
335
|
+
clearHover();
|
|
284
336
|
const group = groups.get(this);
|
|
285
337
|
const wasExpanded = this.expanded;
|
|
286
338
|
if (args[0] === false && !mouseHandling && group) group.open = false;
|
|
@@ -300,7 +352,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
300
352
|
const padding = layout?.anchor?.outputPad ?? 1;
|
|
301
353
|
const indent = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) + 2 : 3;
|
|
302
354
|
const group = groupFor(this, layout?.parent);
|
|
303
|
-
if (!group) return compactCall(this, width, getTheme?.(), indent);
|
|
355
|
+
if (!group) return highlight(this, compactCall(this, width, getTheme?.(), indent), width);
|
|
304
356
|
const open = group.open || group.expanded > 0;
|
|
305
357
|
const first = group.members[0] === this;
|
|
306
358
|
const lines: string[] = [];
|
|
@@ -315,11 +367,19 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
315
367
|
lines.push(truncateToWidth(`${" ".repeat(Math.min(indent, Math.max(0, width - 1)))}${styled}`, width));
|
|
316
368
|
}
|
|
317
369
|
if (open) lines.push(...compactCall(this, width, getTheme?.(), indent + 2));
|
|
318
|
-
return lines;
|
|
370
|
+
return highlight(this, lines, width);
|
|
319
371
|
},
|
|
320
372
|
};
|
|
321
373
|
const mouseWrapper: MouseHandler = function(event) {
|
|
322
374
|
if (current.enabled && !this.expanded) {
|
|
375
|
+
if (event.type === "move" && event.button === "none") {
|
|
376
|
+
const lines = this.render(event.width);
|
|
377
|
+
if (event.y < 0 || event.y >= lines.length) { clearHover(); return undefined; }
|
|
378
|
+
if (hovered?.component === this && hovered.row === event.y) return { handled: true, render: false };
|
|
379
|
+
clearHover();
|
|
380
|
+
hovered = { component: this, row: event.y, left: event.screenX - event.x, top: event.screenY, width: event.width };
|
|
381
|
+
return { handled: true, render: true };
|
|
382
|
+
}
|
|
323
383
|
if (event.type !== "click" || event.button !== "left" || event.shift || event.alt || event.ctrl) return undefined;
|
|
324
384
|
const group = groupFor(this, layoutFor(this)?.parent);
|
|
325
385
|
if (group?.members[0] === this && event.y === 0) {
|
package/package.json
CHANGED