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 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. Click the disclosure again to refold the
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: { requestRender(): void; children?: TranscriptComponent[] };
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 = `${icon} ${label}`;
99
+ const title = ` ${icon} ${label}`;
94
100
  const styled = theme
95
- ? `${theme.fg(failed ? "error" : running ? "accent" : "muted", icon)} ${theme.fg("muted", label)}${detail ? ` ${theme.fg("dim", detail)}` : ""}${theme.fg(failed ? "error" : "dim", suffix)}`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
5
5
  "license": "MIT",
6
6
  "type": "module",