pi-better-harness 0.27.2 → 0.28.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
@@ -68,11 +68,12 @@ The bundled harness provides **Tool output: Normal / Minimal** in
68
68
  `/harness-settings`. `/tool-output minimal`, `/tool-output normal`, and
69
69
  `/tool-output` (toggle) remain compatibility shortcuts to the same preference.
70
70
  Minimal mode folds each tool call into a single-line header, including running
71
- calls. Each row has a single tool-specific icon: accent-colored while running,
72
- muted when completed, and error-colored when failed. Tool names stay muted;
73
- inline command/path arguments use the theme's distinct dim tone. Running and
74
- failed calls retain text labels so state never depends on color alone. Activity
75
- stays quieter than conversation text. Tool rows are indented two columns past
71
+ calls. The tool-specific icon and tool name share a state color: accent while
72
+ running, muted when completed, and error-colored when failed. In fullscreen Pi, a soft highlight travels through visible running names; inline
73
+ command/path arguments stay dim. Scrollback terminals keep static state colors.
74
+ Choose **Tool animation: Off** in `/harness-settings` for static running headers.
75
+ Running and failed calls retain text labels so state never depends on color or
76
+ motion alone. Tool rows are indented two columns past
76
77
  the assistant text padding. Long
77
78
  headers are truncated to the terminal width; result bodies,
78
79
  images, boxes, and tool spacers are hidden. Built-in, extension, and MCP calls
@@ -130,8 +131,10 @@ to the `/agents` catalog. `/sandbox`, `/subagents settings`, `/agents`, and
130
131
  `/goal settings` remain available in standalone installations.
131
132
  Packages without a settings screen are not listed. Pi's `/settings` is unchanged.
132
133
 
133
- The hub includes **Tool output** when the bundled renderer extension is loaded.
134
- Changes apply immediately and also save the default for future sessions.
134
+ The hub includes **Tool output** and **Tool animation** when the bundled renderer
135
+ extension is loaded. Changes apply immediately and save the default for future
136
+ sessions. The animation preference is stored in `piBetterHarness.toolAnimation`;
137
+ normal output and expanded tool details are never animated.
135
138
  The hub also owns **Completions while busy**, shared by Subagents and Background
136
139
  Tasks. Choose **Wait until idle** (the default) or **Steer active run**. Changes
137
140
  apply immediately and autosave to the current session branch, including across
@@ -5,8 +5,10 @@ import { dirname, join } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { stripVTControlCharacters } from "node:util";
7
7
  import type { Theme } from "@earendil-works/pi-coding-agent";
8
+ import { supportsToolShimmer, toolIdentity } from "./tool-identity.ts";
9
+ import { toolVisibility, type ViewportUI } from "./tool-visibility.ts";
8
10
 
9
- interface ToolUI {
11
+ interface ToolUI extends ViewportUI {
10
12
  requestRender(): void;
11
13
  children?: TranscriptComponent[];
12
14
  handleViewportInput?(data: string): unknown;
@@ -70,6 +72,8 @@ interface ToolPrototype {
70
72
  }
71
73
  interface HookState {
72
74
  enabled: boolean;
75
+ animationEnabled: boolean;
76
+ stopAnimation(): void;
73
77
  owners: number;
74
78
  redraw(collapse?: boolean): void;
75
79
  restore(): void;
@@ -79,6 +83,7 @@ interface HookState {
79
83
  }
80
84
  export interface MinimalOutputHook {
81
85
  setEnabled(enabled: boolean): void;
86
+ setAnimationEnabled(enabled: boolean): void;
82
87
  completeRun(): void;
83
88
  restoreCompletedCalls(ids: Iterable<string>): void;
84
89
  dispose(): void;
@@ -146,7 +151,7 @@ function harnessCallDetail(name: string, input: unknown): string | undefined {
146
151
  }
147
152
  }
148
153
 
149
- function compactCall(component: ToolComponent, width: number, theme?: Theme, indent = 3): string[] {
154
+ function compactCall(component: ToolComponent, width: number, theme?: Theme, indent = 3, animationTime?: number): string[] {
150
155
  if (width <= 0) return [];
151
156
  // Render the call alone at a wider width so terminal wrapping becomes truncation.
152
157
  const header = Boolean(component.getCallRenderer?.()) && component.callRendererComponent?.render(Math.max(4096, width + 1))
@@ -170,7 +175,7 @@ function compactCall(component: ToolComponent, width: number, theme?: Theme, ind
170
175
  const icon = TOOL_ICONS[component.toolName] ?? "\u25c7";
171
176
  const title = ` ${icon} ${label}`;
172
177
  const styled = theme
173
- ? ` ${theme.fg(failed ? "error" : running ? "accent" : "muted", icon)} ${theme.fg("muted", label)}${detail ? ` ${theme.fg("dim", detail)}` : ""}${theme.fg(failed ? "error" : "dim", suffix)}`
178
+ ? ` ${toolIdentity(icon, label, failed ? "error" : running ? "accent" : "muted", theme, animationTime)}${detail ? ` ${theme.fg("dim", detail)}` : ""}${theme.fg(failed ? "error" : "dim", suffix)}`
174
179
  : `${title}${detail ? ` ${detail}` : ""}${suffix}`;
175
180
  return [truncateToWidth(`${" ".repeat(Math.min(indent, Math.max(0, width - 1)))}${styled}`, width)];
176
181
  }
@@ -304,6 +309,50 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
304
309
  };
305
310
  const seen = new WeakSet<ToolComponent>();
306
311
  const components = new Set<WeakRef<ToolComponent>>();
312
+ const animationReferences = new WeakMap<ToolComponent, WeakRef<ToolComponent>>();
313
+ const animated = new Map<WeakRef<ToolComponent>, number>();
314
+ const settled = new WeakSet<ToolComponent>();
315
+ let animationTimer: ReturnType<typeof setInterval> | undefined;
316
+ const stopAnimation = () => {
317
+ if (animationTimer !== undefined) clearInterval(animationTimer);
318
+ animationTimer = undefined;
319
+ animated.clear();
320
+ };
321
+ const canAnimate = (component: ToolComponent) => {
322
+ const theme = getTheme?.();
323
+ return state!.enabled && state!.animationEnabled && component.ui.mode === "fullscreen"
324
+ && !!theme && supportsToolShimmer(theme)
325
+ && !component.expanded && !component.result?.isError && (!component.result || component.isPartial)
326
+ && !settled.has(component);
327
+ };
328
+ const pruneAnimation = () => {
329
+ for (const [reference, lastRender] of animated) {
330
+ const component = reference.deref();
331
+ if (!component || !canAnimate(component) || Date.now() - lastRender > 400
332
+ || toolVisibility(component.ui, component) !== true) animated.delete(reference);
333
+ }
334
+ if (!animated.size) stopAnimation();
335
+ };
336
+ const animationTime = (component: ToolComponent, width: number, indent: number) => {
337
+ if (width <= indent + 3 || !canAnimate(component) || toolVisibility(component.ui, component) === false) return undefined;
338
+ let reference = animationReferences.get(component);
339
+ if (!reference) { reference = new WeakRef(component); animationReferences.set(component, reference); }
340
+ animated.set(reference, Date.now());
341
+ if (animationTimer === undefined) {
342
+ animationTimer = setInterval(() => {
343
+ pruneAnimation();
344
+ const uis = new Set<ToolUI>();
345
+ for (const reference of animated.keys()) {
346
+ const component = reference.deref();
347
+ if (!component) continue;
348
+ uis.add(component.ui);
349
+ }
350
+ for (const ui of uis) ui.requestRender();
351
+ }, 80);
352
+ animationTimer.unref?.();
353
+ }
354
+ return toolVisibility(component.ui, component) === true ? Date.now() : undefined;
355
+ };
307
356
  let completed = new Map<string, object>();
308
357
  let groups = new WeakMap<ToolComponent, ToolGroup>();
309
358
  let layouts = new WeakMap<ToolComponent, { parent: TranscriptComponent; anchor?: TranscriptComponent }>();
@@ -344,11 +393,15 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
344
393
  };
345
394
  state = {
346
395
  enabled: false,
396
+ animationEnabled: true,
397
+ stopAnimation,
347
398
  owners: 0,
348
399
  completeRun() {
400
+ stopAnimation();
349
401
  const run = {};
350
402
  for (const reference of components) {
351
403
  const component = reference.deref();
404
+ if (component) settled.add(component);
352
405
  if (component?.result && !component.isPartial && !completed.has(component.toolCallId)) {
353
406
  completed.set(component.toolCallId, run);
354
407
  if (this.enabled) component.setExpanded(false);
@@ -357,6 +410,11 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
357
410
  this.redraw();
358
411
  },
359
412
  restoreCompletedCalls(ids) {
413
+ stopAnimation();
414
+ for (const reference of components) {
415
+ const component = reference.deref();
416
+ if (component) settled.add(component);
417
+ }
360
418
  const history = {};
361
419
  completed = new Map(Array.from(ids, id => [id, history]));
362
420
  groups = new WeakMap();
@@ -372,8 +430,10 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
372
430
  else component.invalidate();
373
431
  component.ui.requestRender();
374
432
  }
433
+ pruneAnimation();
375
434
  },
376
435
  restore() {
436
+ stopAnimation();
377
437
  clearHover();
378
438
  for (const [ui, { original, wrapper }] of inputWrappers) {
379
439
  if (ui.handleViewportInput === wrapper) ui.handleViewportInput = original;
@@ -498,6 +558,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
498
558
  this.ui.handleViewportInput = wrapper;
499
559
  }
500
560
  if (!seen.has(this)) { seen.add(this); components.add(new WeakRef(this)); }
561
+ pruneAnimation();
501
562
  if (!current.enabled || this.expanded) return originals.updateDisplay.apply(this, args);
502
563
  const showImages = this.showImages;
503
564
  const lastRenderer = this.resultRendererComponent;
@@ -517,6 +578,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
517
578
  if (args[0] === false && !mouseHandling && group) group.open = false;
518
579
  const result = originals.setExpanded.apply(this, args);
519
580
  if (group) group.expanded += Number(this.expanded) - Number(wasExpanded);
581
+ pruneAnimation();
520
582
  return result;
521
583
  },
522
584
  getResultRenderer(this: ToolComponent, ...args: unknown[]) {
@@ -531,7 +593,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
531
593
  const padding = layout?.anchor?.outputPad ?? 1;
532
594
  const indent = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) + 2 : 3;
533
595
  const group = groupFor(this, layout?.parent);
534
- if (!group) return highlight(this, compactCall(this, width, getTheme?.(), indent), width);
596
+ if (!group) return highlight(this, compactCall(this, width, getTheme?.(), indent, animationTime(this, width, indent)), width);
535
597
  const open = group.open || group.expanded > 0;
536
598
  const first = group.members[0] === this;
537
599
  const lines: string[] = [];
@@ -591,6 +653,12 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
591
653
  current.enabled = enabled;
592
654
  current.redraw(enabled);
593
655
  },
656
+ setAnimationEnabled(enabled) {
657
+ if (disposed) return;
658
+ current.animationEnabled = enabled;
659
+ if (!enabled) current.stopAnimation();
660
+ current.redraw();
661
+ },
594
662
  dispose() {
595
663
  if (disposed) return;
596
664
  disposed = true;
@@ -10,6 +10,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
10
10
  let enabled = false;
11
11
  let running = false;
12
12
  let initialDefault: boolean | undefined;
13
+ let motionEnabled = true;
13
14
 
14
15
  function defaultEnabled(): boolean {
15
16
  if (initialDefault !== undefined) return initialDefault;
@@ -52,6 +53,16 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
52
53
  enabled = false;
53
54
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
54
55
  }
56
+ try {
57
+ const motion = readHarnessSetting<{ version?: unknown; enabled?: unknown }>("toolAnimation");
58
+ if (motion !== undefined && (motion?.version !== 1 || typeof motion.enabled !== "boolean")) {
59
+ throw new Error("Invalid tool-animation default in global settings.json.");
60
+ }
61
+ motionEnabled = motion?.enabled !== false;
62
+ } catch (error) {
63
+ motionEnabled = false;
64
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
65
+ }
55
66
  for (const entry of ctx.sessionManager.getBranch()) {
56
67
  if (entry.type === "custom" && entry.customType === ENTRY) {
57
68
  const data = entry.data as { version?: unknown; enabled?: unknown } | undefined;
@@ -60,6 +71,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
60
71
  }
61
72
  // Observe transcript components from startup, so the toggle also redraws existing rows.
62
73
  if (await ensureHook(ctx)) {
74
+ hook!.setAnimationEnabled(motionEnabled);
63
75
  hook!.restoreCompletedCalls(ctx.sessionManager.getBranch().flatMap(entry =>
64
76
  entry.type === "message" && entry.message.role === "toolResult" ? [entry.message.toolCallId] : []));
65
77
  if (enabled) collapseTools(ctx);
@@ -93,7 +105,21 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
93
105
  id: "tool-output", label: "Tool output", values: ["Normal", "Minimal"],
94
106
  get: () => enabled ? "Minimal" : "Normal", change,
95
107
  };
96
- const register = () => pi.events.emit("harness-settings:register", setting);
108
+ const animationSetting: SettingsControl = {
109
+ id: "tool-animation", label: "Tool animation", values: ["Shimmer", "Off"],
110
+ get: () => motionEnabled ? "Shimmer" : "Off",
111
+ async change(value, ctx) {
112
+ if (value !== "Shimmer" && value !== "Off") throw new Error("Invalid tool animation mode.");
113
+ if (!await ensureHook(ctx)) throw new Error("Tool animation settings are unavailable in this Pi runtime.");
114
+ updateHarnessSetting("toolAnimation", () => ({ version: 1, enabled: value === "Shimmer" }));
115
+ motionEnabled = value === "Shimmer";
116
+ hook!.setAnimationEnabled(motionEnabled);
117
+ },
118
+ };
119
+ const register = () => {
120
+ pi.events.emit("harness-settings:register", setting);
121
+ pi.events.emit("harness-settings:register", animationSetting);
122
+ };
97
123
  let stopSettings: (() => void) | undefined;
98
124
  const subscribe = () => { stopSettings ??= pi.events.on("harness-settings:request", register); };
99
125
  subscribe();
@@ -115,6 +141,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
115
141
  enabled = false;
116
142
  running = false;
117
143
  initialDefault = undefined;
144
+ motionEnabled = true;
118
145
  });
119
146
 
120
147
  pi.registerCommand("tool-output", {
@@ -0,0 +1,66 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+
3
+ type RGB = [number, number, number];
4
+ interface ConcreteColor { kind: string; index?: number; r?: number; g?: number; b?: number }
5
+ const concreteColors = (theme: Theme) => (theme as Theme & { colors?: Record<string, ConcreteColor> }).colors;
6
+ const PERIOD = 2400;
7
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
8
+
9
+ function themeRgb(theme: Theme, token: "accent" | "text"): RGB | undefined {
10
+ const color = concreteColors(theme)?.[token];
11
+ if (color?.kind === "rgb" && color.r !== undefined && color.g !== undefined && color.b !== undefined) {
12
+ return [color.r, color.g, color.b];
13
+ }
14
+ return rgb(theme.getFgAnsi(token));
15
+ }
16
+
17
+ export function supportsToolShimmer(theme: Theme): boolean {
18
+ const base = themeRgb(theme, "accent");
19
+ const light = themeRgb(theme, "text");
20
+ const accent = concreteColors(theme)?.accent;
21
+ return (!!base && !!light && base.some((value, index) => value !== light[index]))
22
+ || (accent?.kind === "indexed" && accent.index !== undefined && accent.index >= 0 && accent.index < 16);
23
+ }
24
+
25
+ function rgb(ansi: string): RGB | undefined {
26
+ const direct = /\x1b\[38;2;(\d+);(\d+);(\d+)m/.exec(ansi);
27
+ if (direct) return [Number(direct[1]), Number(direct[2]), Number(direct[3])];
28
+ const indexed = /\x1b\[38;5;(\d+)m/.exec(ansi);
29
+ if (!indexed) return undefined;
30
+ const index = Number(indexed[1]);
31
+ // The first 16 colors are terminal-defined; keep them untouched rather than guess.
32
+ if (index < 16 || index > 255) return undefined;
33
+ if (index >= 232) { const gray = 8 + (index - 232) * 10; return [gray, gray, gray]; }
34
+ const cube = index - 16;
35
+ const channel = (value: number) => value === 0 ? 0 : 55 + value * 40;
36
+ return [channel(Math.floor(cube / 36)), channel(Math.floor(cube / 6) % 6), channel(cube % 6)];
37
+ }
38
+
39
+ function foreground(color: RGB, mode: "truecolor" | "256color"): string {
40
+ if (mode === "truecolor") return `\x1b[38;2;${color.join(";")}m`;
41
+ const channel = (value: number) => Math.max(0, Math.min(5, Math.round((value - 55) / 40)));
42
+ const index = 16 + channel(color[0]) * 36 + channel(color[1]) * 6 + channel(color[2]);
43
+ return `\x1b[38;5;${index}m`;
44
+ }
45
+
46
+ export function toolIdentity(icon: string, label: string, tone: "accent" | "muted" | "error", theme: Theme, time?: number): string {
47
+ if (tone !== "accent" || time === undefined) return theme.fg(tone, `${icon} ${label}`);
48
+ const base = themeRgb(theme, "accent");
49
+ const light = themeRgb(theme, "text");
50
+ if (!supportsToolShimmer(theme)) return theme.fg(tone, `${icon} ${label}`);
51
+ const letters = Array.from(graphemes.segment(label), part => part.segment);
52
+ const radius = Math.max(2, Math.min(4, letters.length / 3));
53
+ const position = (time % PERIOD) / PERIOD * (letters.length + radius * 2) - radius;
54
+ const mode = theme.getColorMode();
55
+ const name = letters.map((letter, index) => {
56
+ const distance = Math.abs(index - position) / radius;
57
+ const shine = distance < 1 ? (1 + Math.cos(distance * Math.PI)) * 0.48 : 0;
58
+ if (!base || !light) {
59
+ const intensity = shine > 0.72 ? "\x1b[1m" : shine > 0.18 ? "" : "\x1b[2m";
60
+ return `\x1b[22m${intensity}${letter}`;
61
+ }
62
+ const color = base.map((value, channel) => Math.round(value + (light[channel] - value) * shine)) as RGB;
63
+ return `${foreground(color, mode)}${letter}`;
64
+ }).join("");
65
+ return theme.fg(tone, `${icon} \x1b[22m${name}\x1b[22m`);
66
+ }
@@ -0,0 +1,61 @@
1
+ interface Rect { x: number; y: number; width: number; height: number }
2
+ interface LegacyComponent {
3
+ mouseLayout?: { children: { component: LegacyComponent; height: number }[] };
4
+ paddingX?: number;
5
+ paddingY?: number;
6
+ }
7
+ interface LayoutBox {
8
+ component: LegacyComponent;
9
+ rect: Rect;
10
+ clip: Rect;
11
+ children: LayoutBox[];
12
+ lineOffset?: number;
13
+ }
14
+ export interface ViewportUI { mode?: string; currentLayout?: { root: LayoutBox }; hasOverlay?(): boolean; hasActiveSelection?(): boolean }
15
+ const frames = new WeakMap<ViewportUI, { root: LayoutBox; known: Set<object>; visible: Set<object> }>();
16
+
17
+ function intersects(a: Rect, b: Rect): boolean {
18
+ return a.width > 0 && a.height > 0 && b.width > 0 && b.height > 0
19
+ && a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
20
+ }
21
+
22
+ // The guarded fullscreen adapter uses the last painted layout, never calls render()
23
+ // to measure visibility, and leaves scrollback hosts on static state colors.
24
+ export function toolVisibility(ui: ViewportUI, component: object): boolean | undefined {
25
+ if (ui.mode !== "fullscreen" || ui.hasOverlay?.() || ui.hasActiveSelection?.()) return false;
26
+ const root = ui.currentLayout?.root;
27
+ if (!root) return undefined;
28
+ if (!root.rect || !root.clip || !Array.isArray(root.children)) return false;
29
+ let frame = frames.get(ui);
30
+ if (frame?.root !== root) {
31
+ frame = { root, known: new Set(), visible: new Set() };
32
+ const current = frame;
33
+ const legacy = (child: LegacyComponent, rect: Rect, clip: Rect) => {
34
+ if (!child || typeof child !== "object") return;
35
+ current.known.add(child);
36
+ if (!intersects(rect, clip)) return;
37
+ current.visible.add(child);
38
+ const children = child.mouseLayout?.children;
39
+ if (!children) return;
40
+ const paddingY = child.paddingY ?? 0;
41
+ const paddingX = child.paddingX ?? 0;
42
+ if (children.reduce((height, entry) => height + entry.height, 0) + paddingY * 2 !== rect.height) return;
43
+ let y = rect.y + paddingY;
44
+ for (const entry of children) {
45
+ legacy(entry.component, { x: rect.x + paddingX, y, width: Math.max(0, rect.width - paddingX * 2), height: entry.height }, clip);
46
+ y += entry.height;
47
+ }
48
+ };
49
+ const visit = (box: LayoutBox) => {
50
+ if (!box?.component || !box.rect || !box.clip || !Array.isArray(box.children)) return;
51
+ current.known.add(box.component);
52
+ if (!intersects(box.rect, box.clip)) return;
53
+ current.visible.add(box.component);
54
+ if (box.children.length) for (const child of box.children) visit(child);
55
+ else legacy(box.component, { ...box.rect, y: box.rect.y - (box.lineOffset ?? 0) }, box.clip);
56
+ };
57
+ visit(root);
58
+ frames.set(ui, frame);
59
+ }
60
+ return frame.known.has(component) ? frame.visible.has(component) : undefined;
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.27.2",
3
+ "version": "0.28.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",