pi-compact-tools 0.14.0 → 0.14.2

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
@@ -12,7 +12,7 @@ pi install npm:pi-compact-tools
12
12
 
13
13
  Restart Pi or run `/reload`, then run `/compact-tools` to choose a style. To try it for one session without installing: `pi -e npm:pi-compact-tools`.
14
14
 
15
- Requires Node.js 22.19+ and Pi 0.85.1+. It changes only how tool calls are drawn, not what they run or what the model sees.
15
+ Requires Node.js 22.19+ and Pi 0.85.1+. **Pi 1.0.2 or later is recommended** for native renderer integration and compact HTML exports. It changes only how tool calls are drawn, not what they run or what the model sees.
16
16
 
17
17
  ## Styles
18
18
 
@@ -88,6 +88,23 @@ Optional, in `~/.pi/agent/compact-tools.json` or `<project>/.pi/compact-tools.js
88
88
  - `auto_compact`: `true` starts a Compact row collapsed; `false` shows up to `previewLines` rows. Custom tool names work too.
89
89
  - `custom_tools`: rows for tools from other packages. `exclude` keeps a tool's own look.
90
90
 
91
+ ## UI-only behavior
92
+
93
+ This package never registers or activates agent tools and does not add model-facing
94
+ messages, tool schemas, or prompt text. Host execution settings (including shell
95
+ prefixes and image resizing) remain untouched. Renderer-bearing overrides of names
96
+ such as `read` or `bash` follow `custom_tools`, including its exclusions.
97
+
98
+ On Pi 1.0.2 and newer, the public renderer API also applies compact rendering to HTML
99
+ exports. Older hosts use a terminal-only compatibility patch; HTML exports keep
100
+ Pi's native renderers. On Pi 1.1 and newer, final durations use the host's recorded
101
+ `durationMs`, including restored sessions, and tool rows follow `outputPad` (0 or 1)
102
+ without changing their internal style indentation. Older hosts keep event-based
103
+ timing and the original margins; the minimum supported Pi version is unchanged.
104
+ When custom compact rendering is enabled, it intentionally takes precedence over
105
+ fallback renderers supplied by earlier `next() ?? mine` resolvers for unknown tools.
106
+ Disable `custom_tools` or exclude the tool to preserve those fallback renderers.
107
+
91
108
  ## Controls
92
109
 
93
110
  - Click a row or group, or press `Ctrl+O`, to expand and collapse.
@@ -101,7 +118,7 @@ npm run check
101
118
  pi -e .
102
119
  ```
103
120
 
104
- Development dependencies are pinned to Pi 1.0.0; the peer minimum remains Pi 0.85.1. CI checks Pi 0.85.1, 0.99.1, and 1.0.0 on Node 22.19.0 and 24. Schema validation and deterministic test clocks are development-only dependencies, not runtime dependencies. Development scripts and tests are not included in the published package. `npm test` runs the rendering tests first and the resource/packaging checks separately so their subprocesses do not disturb animation tests.
121
+ Development dependencies are pinned to Pi 1.1.0; the peer minimum remains Pi 0.85.1. CI checks Pi 0.85.1, 0.99.1, 1.0.0, 1.0.2, and 1.1.0 on Node 22.19.0 and 24. Schema validation and deterministic test clocks are development-only dependencies, not runtime dependencies. Development scripts and tests are not included in the published package. `npm test` runs the rendering tests first and the resource/packaging checks separately so their subprocesses do not disturb animation tests.
105
122
 
106
123
  For a local before/after performance comparison, run `npx tsx tests/benchmark.ts <clean-baseline-directory>` with the same host dependencies in both directories. Measurements are informational, not timing-sensitive test assertions.
107
124
 
@@ -145,6 +145,7 @@ function prefixedRows(lines: readonly Segment[][], first: string, rest: string,
145
145
  }
146
146
 
147
147
  function durationOf(state: RowState): string | undefined {
148
+ if (state.durationMs !== undefined) return formatCodexDuration(state.durationMs);
148
149
  return state.startedAt !== undefined && state.endedAt !== undefined ? formatCodexDuration(state.endedAt - state.startedAt) : undefined;
149
150
  }
150
151
 
@@ -1,10 +1,13 @@
1
1
  import { ToolExecutionComponent, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { hookMethod } from "./compact-tools-hook.ts";
3
+ import { refreshRowShell } from "./compact-tools-rendering.ts";
2
4
 
3
5
  // Symbol.for keys are shared by every evaluation of this module. The prototype is
4
6
  // Pi's and outlives a /reload, so the patch is installed once and always asks the
5
7
  // most recently loaded extension instance, through the shared slot, what to render.
6
- const RESOLVER_KEY = Symbol.for("pi-compact-tools.custom.resolver");
7
- const PATCHED_KEY = Symbol.for("pi-compact-tools.custom.patched");
8
+ const RESOLVER_KEY = Symbol.for("pi-compact-tools.custom.resolver.v2");
9
+ const LEGACY_RESOLVER_KEY = Symbol.for("pi-compact-tools.custom.resolver");
10
+ const PATCHED_KEY = Symbol.for("pi-compact-tools.custom.patched.v2");
8
11
  const ROUTED_METHODS = ["getCallRenderer", "getResultRenderer", "getRenderShell", "hasRendererDefinition"] as const;
9
12
 
10
13
  type AnyToolDefinition = ToolDefinition<any, any, any>;
@@ -26,14 +29,12 @@ type RoutedPrototype = Record<(typeof ROUTED_METHODS)[number], (...args: unknown
26
29
  type ResolverSlot = typeof globalThis & { [RESOLVER_KEY]?: RowResolver };
27
30
 
28
31
  export function setRowResolver(resolver: RowResolver | undefined): void {
32
+ // The v1 patch may survive an npm update plus /reload. Keep its slot empty.
33
+ (globalThis as unknown as Record<symbol, unknown>)[LEGACY_RESOLVER_KEY] = undefined;
29
34
  (globalThis as ResolverSlot)[RESOLVER_KEY] = resolver;
30
35
  }
31
36
 
32
- /**
33
- * Stop answering for rows, if `resolver` is still the one answering. The patch on
34
- * Pi's rows outlives the extension, so a /reload that leaves it out must find no
35
- * resolver, and Pi's own renderers draw every row made afterwards.
36
- */
37
+ /** Remove this instance's resolver when the UI extension is unloaded. */
37
38
  export function clearRowResolver(resolver: RowResolver): void {
38
39
  if ((globalThis as ResolverSlot)[RESOLVER_KEY] === resolver) setRowResolver(undefined);
39
40
  }
@@ -48,28 +49,41 @@ export function patchToolRows(prototype: object | undefined): boolean {
48
49
  const routed = prototype as RoutedPrototype;
49
50
  if (!ROUTED_METHODS.every((method) => typeof routed[method] === "function")) return false;
50
51
  if (Object.hasOwn(prototype, PATCHED_KEY)) return true;
51
- // Decided once per row: Pi picks the row's container from these answers in its
52
- // constructor, so a row must never switch renderers after it is built. /reload
53
- // rebuilds the transcript, so config changes still reach every row.
54
- const decisions = new WeakMap<object, RowRenderers | null>();
52
+ hookMethod(prototype, "updateDisplay", "custom.refreshShell", (self, args, original) => {
53
+ refreshRowShell(self);
54
+ return original.apply(self, args);
55
+ });
56
+ // Cache within a configuration generation. session_start can apply trusted
57
+ // project settings after rows were constructed; a new resolver invalidates
58
+ // those provisional decisions, and updateDisplay reconciles the shell.
59
+ const decisions = new WeakMap<object, { resolver: RowResolver | undefined; renderers: RowRenderers | null }>();
60
+ const owners = new WeakMap<object, { call?: unknown; result?: unknown }>();
61
+ const changed = (row: object, kind: "call" | "result", renderer: unknown) => {
62
+ let owner = owners.get(row);
63
+ if (!owner) owners.set(row, owner = {});
64
+ if (owner[kind] !== renderer) {
65
+ (row as Record<string, unknown>)[kind === "call" ? "callRendererComponent" : "resultRendererComponent"] = undefined;
66
+ owner[kind] = renderer;
67
+ }
68
+ return renderer;
69
+ };
55
70
  const resolve = (row: ToolRow): RowRenderers | undefined => {
56
- let renderers = decisions.get(row);
57
- if (renderers === undefined) {
58
- try {
59
- renderers = (globalThis as ResolverSlot)[RESOLVER_KEY]?.(row) ?? null;
60
- } catch {
61
- renderers = null;
62
- }
63
- decisions.set(row, renderers);
71
+ const resolver = (globalThis as ResolverSlot)[RESOLVER_KEY];
72
+ let decision = decisions.get(row);
73
+ if (!decision || decision.resolver !== resolver) {
74
+ let renderers: RowRenderers | null;
75
+ try { renderers = resolver?.(row) ?? null; } catch { renderers = null; }
76
+ decision = { resolver, renderers };
77
+ decisions.set(row, decision);
64
78
  }
65
- return renderers ?? undefined;
79
+ return decision.renderers ?? undefined;
66
80
  };
67
81
  const original = Object.fromEntries(ROUTED_METHODS.map((method) => [method, routed[method]])) as RoutedPrototype;
68
82
  routed.getCallRenderer = function (this: ToolRow, ...args) {
69
- return resolve(this)?.renderCall ?? original.getCallRenderer.apply(this, args);
83
+ return changed(this, "call", resolve(this)?.renderCall ?? original.getCallRenderer.apply(this, args));
70
84
  };
71
85
  routed.getResultRenderer = function (this: ToolRow, ...args) {
72
- return resolve(this)?.renderResult ?? original.getResultRenderer.apply(this, args);
86
+ return changed(this, "result", resolve(this)?.renderResult ?? original.getResultRenderer.apply(this, args));
73
87
  };
74
88
  routed.getRenderShell = function (this: ToolRow, ...args) {
75
89
  return resolve(this) ? "self" : original.getRenderShell.apply(this, args);
@@ -26,6 +26,7 @@ import {
26
26
  import { formatDurationMs, type RowStatus } from "./compact-tools-core.ts";
27
27
  import { hookMethod } from "./compact-tools-hook.ts";
28
28
  import { getTextResult } from "./compact-tools-invocation.ts";
29
+ import { outputPadLines } from "./compact-tools-layout.ts";
29
30
  import { chromePainter, paintIndicator } from "./compact-tools-palette.ts";
30
31
  import type { ToolRuntime } from "./compact-tools-runtime.ts";
31
32
  import { findChat, isKind, isSilent } from "./compact-tools-silent.ts";
@@ -52,6 +53,8 @@ export interface ToolRowLike {
52
53
  result?: AgentToolResult<unknown> & { isError?: boolean };
53
54
  isPartial: boolean;
54
55
  expanded: boolean;
56
+ /** Present on Pi 1.1+ tool components, absent on legacy hosts. */
57
+ outputPad?: number;
55
58
  render(width: number): string[];
56
59
  handleMouse?(event: unknown): unknown;
57
60
  invalidate(): void;
@@ -333,7 +336,10 @@ export class ToolGroupController {
333
336
  this.states.set(key, state);
334
337
  }
335
338
  const groupState = state;
336
- const draw = (group: readonly ToolRowLike[], width: number, expanded: boolean) => this.drawCached(group, width, expanded, groupState);
339
+ const draw = (group: readonly ToolRowLike[], width: number, expanded: boolean) => {
340
+ const pad = group[0]?.outputPad;
341
+ return outputPadLines(this.drawCached(group, width + (pad === 0 ? 1 : 0), expanded, groupState), width, pad);
342
+ };
337
343
  if (this.runtime.config.style !== "codex") return new ToolGroupComponent(rows, members, state, draw, () => tui.requestRender());
338
344
  // Codex opens a group to the whole of each call, and closes it from one "− Show less" beneath them.
339
345
  const toggle = (expanded: boolean) => {
@@ -342,7 +348,9 @@ export class ToolGroupController {
342
348
  };
343
349
  const footer = (width: number) => {
344
350
  const theme = this.paint?.theme ?? this.context?.ui.theme;
345
- return theme ? [truncateToWidth(theme.fg("dim", codexDisclosure(width, true)), Math.max(1, width), "…")] : [];
351
+ const pad = rows[0]?.outputPad;
352
+ const available = width + (pad === 0 ? 1 : 0);
353
+ return theme ? outputPadLines([truncateToWidth(theme.fg("dim", codexDisclosure(available, true)), Math.max(1, available), "…")], width, pad) : [];
346
354
  };
347
355
  const group = new ToolGroupComponent(rows, members, state, draw, toggle, footer);
348
356
  // Read once the group has followed Ctrl+O, which it does as it is made. A call that joins an
@@ -15,6 +15,45 @@ import { highlightMarkdown } from "./compact-tools-markdown.ts";
15
15
  import { paintChrome } from "./compact-tools-palette.ts";
16
16
  import type { ToolArgs } from "./compact-tools-types.ts";
17
17
 
18
+ /**
19
+ * Styles include one outer margin column in their prefixes. Pi 1.1+ can request
20
+ * no outer padding; reclaim that column without changing tree/code indentation.
21
+ * Missing fields and outputPad=1 keep the legacy output byte-for-byte.
22
+ */
23
+ export function outputPadLines(lines: string[], width: number, outputPad?: number): string[] {
24
+ if (outputPad !== 0) return lines;
25
+ return lines.map((line) => stripTerminalSequences(line).startsWith(" ")
26
+ ? sliceByColumn(line, 1, Math.max(0, width), true)
27
+ : truncateToWidth(line, Math.max(0, width), "…"));
28
+ }
29
+
30
+ class UnpaddedComponent implements Component {
31
+ private source?: string[];
32
+ private width?: number;
33
+ private lines: string[] = [];
34
+
35
+ constructor(private readonly inner: Component) {}
36
+
37
+ render(width: number): string[] {
38
+ const source = this.inner.render(width + 1);
39
+ if (source !== this.source || width !== this.width) {
40
+ this.source = source;
41
+ this.width = width;
42
+ this.lines = outputPadLines(source, width, 0);
43
+ }
44
+ return this.lines;
45
+ }
46
+
47
+ invalidate(): void {
48
+ this.inner.invalidate();
49
+ this.source = undefined;
50
+ }
51
+ }
52
+
53
+ export function withOutputPad(component: Component, outputPad?: number): Component {
54
+ return outputPad === 0 ? new UnpaddedComponent(component) : component;
55
+ }
56
+
18
57
  export class CachedComponent implements Component {
19
58
  private cachedWidth?: number;
20
59
  private cachedLines?: string[];
@@ -0,0 +1,51 @@
1
+ import {
2
+ createBashToolDefinition, createEditToolDefinition, createFindToolDefinition,
3
+ createGrepToolDefinition, createLsToolDefinition, createPowerShellToolDefinition,
4
+ createReadToolDefinition, createWriteToolDefinition,
5
+ type ToolDefinition,
6
+ } from "@earendil-works/pi-coding-agent";
7
+
8
+ // Constructing definitions does not register/activate tools or run their execute
9
+ // functions. Only renderer fingerprints are retained, never model-facing fields.
10
+ const factories = {
11
+ read: createReadToolDefinition, write: createWriteToolDefinition, edit: createEditToolDefinition,
12
+ bash: createBashToolDefinition, powershell: createPowerShellToolDefinition,
13
+ grep: createGrepToolDefinition, find: createFindToolDefinition, ls: createLsToolDefinition,
14
+ };
15
+ const builtinRenderers = new Map<string, { call: string; result: string }>();
16
+ for (const [name, factory] of Object.entries(factories)) {
17
+ try {
18
+ const definition = factory(process.cwd());
19
+ builtinRenderers.set(name, { call: String(definition.renderCall), result: String(definition.renderResult) });
20
+ } catch {
21
+ // A missing/unsupported reference renderer must not prevent UI loading.
22
+ }
23
+ }
24
+
25
+ /** Conservative fallback on legacy hosts without source metadata. */
26
+ export function hasAuthorRenderers(name: string, definition: ToolDefinition<any, any, any> | undefined): boolean {
27
+ if (!definition) return false;
28
+ const builtin = builtinRenderers.get(name);
29
+ return Boolean((definition.renderCall && String(definition.renderCall) !== builtin?.call)
30
+ || (definition.renderResult && String(definition.renderResult) !== builtin?.result));
31
+ }
32
+
33
+ /** Change only a transcript row's renderer shell, never a tool registry entry. */
34
+ export function refreshRowShell(row: object): void {
35
+ const target = row as {
36
+ children?: unknown[]; selfRenderContainer?: unknown; contentBox?: unknown; contentTextRegion?: unknown;
37
+ hasRendererDefinition?(): boolean; getRenderShell?(): string;
38
+ callRendererComponent?: unknown; resultRendererComponent?: unknown;
39
+ };
40
+ if (!Array.isArray(target.children) || !target.selfRenderContainer || !target.contentBox || !target.contentTextRegion) return;
41
+ const wanted = target.hasRendererDefinition?.()
42
+ ? target.getRenderShell?.() === "self" ? target.selfRenderContainer : target.contentBox
43
+ : target.contentTextRegion;
44
+ const shells: unknown[] = [target.selfRenderContainer, target.contentBox, target.contentTextRegion];
45
+ const index = target.children.findIndex((child) => shells.includes(child));
46
+ if (index >= 0 && target.children[index] !== wanted) {
47
+ target.children[index] = wanted;
48
+ target.callRendererComponent = undefined;
49
+ target.resultRendererComponent = undefined;
50
+ }
51
+ }
@@ -20,7 +20,12 @@ const INDICATOR_INTERVAL_MS = 45;
20
20
  const UNDRAWN_ROW_MS = 1_500;
21
21
  const EXECUTION_TIMINGS_KEY = Symbol.for("pi.compact-tools.execution-timings");
22
22
 
23
- type ExecutionTiming = { startedAt: number; endedAt?: number };
23
+ type ExecutionTiming = { startedAt: number; endedAt?: number; durationMs?: number };
24
+
25
+ /** Treat missing or malformed host durations like an older host; zero is valid. */
26
+ export function validDurationMs(value: unknown): value is number {
27
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
28
+ }
24
29
  type Indicator = { invalidate: () => void; drawnAt: number };
25
30
  type TimedExecute = (...args: any[]) => Promise<AgentToolResult<unknown>>;
26
31
  type SharedState = typeof globalThis & {
@@ -186,6 +191,7 @@ export class ToolRuntime {
186
191
  // Pi can rebuild the transcript mid-run, making new rows for old calls; a call
187
192
  // keeps the run its first row was made in.
188
193
  this.restoreTiming(state, ctx.toolCallId);
194
+ if (validDurationMs(ctx.durationMs)) state.durationMs = ctx.durationMs;
189
195
  // A row is timed from when it runs, as Pi times its own: the model writing the
190
196
  // arguments is not the tool's time. A restored row never ran in front of the
191
197
  // reader, so it has no time to measure unless this process timed it before a
@@ -210,9 +216,10 @@ export class ToolRuntime {
210
216
  this.trimTimings();
211
217
  }
212
218
 
213
- noteExecutionEnd(toolCallId: string): void {
219
+ noteExecutionEnd(toolCallId: string, durationMs?: number): void {
214
220
  const timing = this.executionTimings.get(toolCallId) ?? { startedAt: Date.now() };
215
221
  timing.endedAt ??= Date.now();
222
+ if (validDurationMs(durationMs)) timing.durationMs = durationMs;
216
223
  this.executionTimings.set(toolCallId, timing);
217
224
  this.trimTimings();
218
225
  }
@@ -290,6 +297,7 @@ export class ToolRuntime {
290
297
  if (!timing) return;
291
298
  state.startedAt = timing.startedAt;
292
299
  state.endedAt = timing.endedAt;
300
+ if (validDurationMs(timing.durationMs)) state.durationMs = timing.durationMs;
293
301
  }
294
302
 
295
303
  private persistTiming(state: RowState, toolCallId: string): void {
@@ -297,6 +305,7 @@ export class ToolRuntime {
297
305
  const timing = this.executionTimings.get(toolCallId) ?? { startedAt: state.startedAt };
298
306
  timing.startedAt = state.startedAt;
299
307
  if (state.endedAt !== undefined) timing.endedAt = state.endedAt;
308
+ if (validDurationMs(state.durationMs)) timing.durationMs = state.durationMs;
300
309
  this.executionTimings.set(toolCallId, timing);
301
310
  this.trimTimings();
302
311
  }
@@ -41,6 +41,8 @@ export interface RowState {
41
41
  finished?: boolean;
42
42
  startedAt?: number;
43
43
  endedAt?: number;
44
+ /** Host-recorded final duration, when available (Pi 1.1+). */
45
+ durationMs?: number;
44
46
  resultLineSummary?: string;
45
47
  resultLineSummaryComputed?: boolean;
46
48
  lastResultContent?: unknown;
@@ -51,6 +53,8 @@ export interface RowState {
51
53
  lastResultError?: boolean;
52
54
  lastResultConfigRevision?: object;
53
55
  lastResultPalette?: object;
56
+ lastResultDurationMs?: number;
57
+ lastResultOutputPad?: number;
54
58
  /** What a Codex row's head reads from its result: a command's exit code, an edit's counts. */
55
59
  codex?: {
56
60
  exitCode?: number;
@@ -61,7 +65,10 @@ export interface RowState {
61
65
  }
62
66
 
63
67
  type BaseRenderContext = Parameters<NonNullable<BuiltInDefinition["renderCall"]>>[2];
64
- export type RenderContext<TArgs = ToolArgs> = Omit<BaseRenderContext, "args" | "state"> & {
68
+ export type RenderContext<TArgs = ToolArgs> = Omit<BaseRenderContext, "args" | "state" | "durationMs" | "outputPad"> & {
65
69
  args: TArgs;
66
70
  state: RowState;
71
+ /** Optional across hosts: older Pi versions do not provide these fields. */
72
+ durationMs?: number;
73
+ outputPad?: number;
67
74
  };
@@ -7,21 +7,12 @@ import type {
7
7
  ToolDefinition,
8
8
  ToolRenderResultOptions,
9
9
  } from "@earendil-works/pi-coding-agent";
10
- import {
11
- createBashToolDefinition,
12
- createEditToolDefinition,
13
- createFindToolDefinition,
14
- createGrepToolDefinition,
15
- createLsToolDefinition,
16
- createPowerShellToolDefinition,
17
- createReadToolDefinition,
18
- createWriteToolDefinition,
19
- ToolExecutionComponent,
20
- } from "@earendil-works/pi-coding-agent";
10
+ import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
21
11
  import type { Component } from "@earendil-works/pi-tui";
22
12
  import { classifyCallStatus, formatDurationMs, normalizeLineEndings, type RowStatus } from "./compact-tools-core.ts";
23
13
  import { loadConfig } from "./compact-tools-config.ts";
24
14
  import { paletteOf } from "./compact-tools-color.ts";
15
+ import { hasAuthorRenderers, refreshRowShell } from "./compact-tools-rendering.ts";
25
16
  import { registerCompactToolsCommand } from "./compact-tools-command.ts";
26
17
  import { clearRowResolver, installToolRowPatch, setRowResolver, type RowRenderers, type ToolRow } from "./compact-tools-custom.ts";
27
18
  import {
@@ -47,6 +38,7 @@ import {
47
38
  prefixedSentence,
48
39
  prefixedText,
49
40
  railComponent,
41
+ withOutputPad,
50
42
  renderArguments,
51
43
  renderCodeDiff,
52
44
  renderCodeView,
@@ -68,7 +60,6 @@ import { ThinkingCycleController } from "./compact-tools-thinking.ts";
68
60
  import { ViewportKeeper } from "./compact-tools-viewport.ts";
69
61
  import {
70
62
  SUPPORTED_TOOL_SET,
71
- type BuiltInDefinition,
72
63
  type CompactToolName,
73
64
  type RenderContext,
74
65
  type RowState,
@@ -80,7 +71,6 @@ const PREVIEW_DIFF_CONTEXT_LINES = 1;
80
71
 
81
72
  const runtime = new ToolRuntime();
82
73
  const RENDER_WIDGET_KEY = "compact-tools-render";
83
- const registeredTools = new Set<CompactToolName>();
84
74
  let registeredConfiguration: string | undefined;
85
75
  /**
86
76
  * Whether the last session found its directory trusted. A /reload rebuilds the
@@ -146,6 +136,7 @@ function liveIndicator(theme: Theme, ctx: RenderContext, state: RowState, glyph?
146
136
  }
147
137
 
148
138
  function formatDuration(state: RowState): string | undefined {
139
+ if (state.durationMs !== undefined) return formatDurationMs(state.durationMs);
149
140
  if (state.startedAt === undefined) return undefined;
150
141
  return formatDurationMs((state.endedAt ?? Date.now()) - state.startedAt);
151
142
  }
@@ -165,7 +156,9 @@ function canReuseResult<TArgs>(
165
156
  && state.lastResultPreview === state.preview
166
157
  && state.lastResultError === ctx.isError
167
158
  && state.lastResultConfigRevision === state.configRevision
168
- && state.lastResultPalette === paletteOf(theme);
159
+ && state.lastResultPalette === paletteOf(theme)
160
+ && state.lastResultDurationMs === state.durationMs
161
+ && state.lastResultOutputPad === ctx.outputPad;
169
162
  }
170
163
 
171
164
  function rememberResult(state: RowState, result: AgentToolResult<unknown>, options: ToolRenderResultOptions, isError: boolean, theme: Theme): void {
@@ -214,6 +207,10 @@ function hasNestedArguments(args: ToolArgs): boolean {
214
207
 
215
208
  /** The call line: a status dot, the tool, and what it was asked, then any arguments the line leaves out. */
216
209
  function renderRowCall(name: string, args: ToolArgs, theme: Theme, ctx: RenderContext): Component {
210
+ return withOutputPad(renderRowCallContent(name, args, theme, ctx), ctx.outputPad);
211
+ }
212
+
213
+ function renderRowCallContent(name: string, args: ToolArgs, theme: Theme, ctx: RenderContext): Component {
217
214
  const state = runtime.syncRow(ctx);
218
215
  runtime.syncExpansion(state, ctx.expanded, name);
219
216
  const kind = rowKind(name);
@@ -292,7 +289,7 @@ function renderRowBody(
292
289
  author: AuthorResultRenderer | undefined,
293
290
  rail?: string,
294
291
  ): Component | undefined {
295
- if (rowKind(name) !== "custom") return renderFileBody(name, args, result, output, expanded, theme, isError, rail);
292
+ if (!author && rowKind(name) !== "custom") return renderFileBody(name, args, result, output, expanded, theme, isError, rail);
296
293
  if (author) {
297
294
  try {
298
295
  const component = author(expanded);
@@ -333,6 +330,7 @@ function claudeBody(
333
330
  theme: Theme,
334
331
  author: AuthorResultRenderer | undefined,
335
332
  ): Component | undefined {
333
+ if (author && expanded) return renderRowBody(name, args, result, output, true, theme, false, author, CLAUDE_INDENT);
336
334
  const path = pathArgument(args);
337
335
  if (name === "edit") {
338
336
  return renderCodeDiff(getEditPatch(result), getEditDiff(result), path, theme,
@@ -416,13 +414,13 @@ function renderRowResult(
416
414
  runtime.syncExpansion(state, ctx.expanded, name);
417
415
  if (canReuseResult(state, result, options, theme, ctx)) return ctx.lastComponent;
418
416
  const kind = rowKind(name);
419
- const output = kind === "file" ? getFileOutput(name, ctx.args, result, ctx.isError) : getTextResult(result);
417
+ const output = kind === "file" && !author ? getFileOutput(name, ctx.args, result, ctx.isError) : getTextResult(result);
420
418
  const hasEditDiff = name === "edit" && getEditDiff(result).length > 0;
421
419
  runtime.setResultAvailable(state, name, hasEditDiff || output.length > 0 || author !== undefined);
422
420
  let component: Component;
423
421
  if (isCodex()) {
424
422
  component = renderCodexResult({
425
- name, kind, args: ctx.args, result, output, theme, state, isError: ctx.isError,
423
+ name, kind: author ? "custom" : kind, args: ctx.args, result, output, theme, state, isError: ctx.isError,
426
424
  isPartial: options.isPartial, expanded: state.expanded === true, toolCallId: ctx.toolCallId, author,
427
425
  });
428
426
  } else if (isClaude()) {
@@ -446,7 +444,9 @@ function renderRowResult(
446
444
  component = new WithinWidth(container);
447
445
  }
448
446
  rememberResult(state, result, options, ctx.isError, theme);
449
- return component;
447
+ state.lastResultDurationMs = state.durationMs;
448
+ state.lastResultOutputPad = ctx.outputPad;
449
+ return withOutputPad(component, ctx.outputPad);
450
450
  }
451
451
 
452
452
  /**
@@ -454,20 +454,30 @@ function renderRowResult(
454
454
  * author's result renderer still draws the expanded body; the row chrome, status,
455
455
  * and collapse behavior match the built-ins.
456
456
  */
457
+ let authorStates = new WeakMap<object, { state: RowState; component?: Component }>();
457
458
  function createCustomRenderers(name: string, author: ToolDefinition<any, any, any> | undefined): RowRenderers {
458
459
  // Pi hands both renderers the row's own state object, which belongs to the tool's
459
460
  // author. Compact bookkeeping is kept here so neither side overwrites the other.
460
- const state: RowState = {};
461
- let authorResult: Component | undefined;
462
- const own = (ctx: RenderContext<any>): RenderContext => ({ ...ctx, args: ctx.args ?? {}, state });
461
+ const slot = (ctx: RenderContext<any>) => {
462
+ let value = authorStates.get(ctx.state);
463
+ if (!value) authorStates.set(ctx.state, value = { state: {} });
464
+ return value;
465
+ };
466
+ const own = (ctx: RenderContext<any>): RenderContext => ({ ...ctx, args: ctx.args ?? {}, state: slot(ctx).state });
463
467
  return {
464
468
  renderCall: (args, theme, ctx) => renderRowCall(name, (args ?? {}) as ToolArgs, theme, own(ctx)),
465
469
  renderResult: (result, options, theme, ctx) => {
466
470
  const authorRenderer = author?.renderResult;
467
471
  const renderAuthorResult = authorRenderer
468
472
  ? (expanded: boolean) => {
469
- authorResult = authorRenderer(result, { ...options, expanded }, theme, { ...ctx, lastComponent: authorResult });
470
- return authorResult;
473
+ const value = slot(ctx);
474
+ // Compact owns the outer margin and the result rail. An author
475
+ // renderer is embedded content, not another host-padded shell.
476
+ // Leave the field absent on legacy hosts.
477
+ value.component = authorRenderer(result, { ...options, expanded }, theme, {
478
+ ...ctx, lastComponent: value.component, ...((ctx as { outputPad?: number }).outputPad === undefined ? {} : { outputPad: 0 }),
479
+ });
480
+ return value.component;
471
481
  }
472
482
  : undefined;
473
483
  return renderRowResult(name, result, options, theme, own(ctx), renderAuthorResult);
@@ -475,63 +485,90 @@ function createCustomRenderers(name: string, author: ToolDefinition<any, any, an
475
485
  };
476
486
  }
477
487
 
488
+ // Renderer resolvers may run repeatedly for the same call, notably HTML export.
489
+ let builtinStates = new WeakMap<object, RowState>();
490
+ const compactRendererDefinitions = new WeakSet<object>();
491
+ function builtinContext(ctx: RenderContext): RenderContext {
492
+ let state = builtinStates.get(ctx.state);
493
+ if (!state) builtinStates.set(ctx.state, state = {});
494
+ return { ...ctx, state };
495
+ }
496
+
497
+ /** UI-only routing: never register, activate, or replace model-facing tools. */
478
498
  function resolveCustomRow(row: ToolRow): RowRenderers | undefined {
499
+ if (runtime.config.style === "off") return undefined;
479
500
  const customTools = runtime.config.custom_tools;
480
- // Built-ins are governed by `tools`: ones left out keep Pi's own renderer.
481
- if (runtime.config.style === "off" || !customTools.enabled || SUPPORTED_TOOL_SET.has(row.toolName)) return undefined;
482
- if (customTools.exclude.includes(row.toolName)) return undefined;
501
+ if (SUPPORTED_TOOL_SET.has(row.toolName) && (!row.toolDefinition || compactRendererDefinitions.has(row.toolDefinition) || (row.toolDefinition.renderResult && compactRendererDefinitions.has(row.toolDefinition.renderResult)) || !hasAuthorRenderers(row.toolName, row.toolDefinition))) {
502
+ if (!runtime.config.tools.includes(row.toolName as CompactToolName)) return undefined;
503
+ const renderers: RowRenderers = {
504
+ renderCall: (args, theme, ctx) => renderRowCall(row.toolName, (args ?? {}) as ToolArgs, theme, builtinContext(ctx as RenderContext)),
505
+ renderResult: (result, options, theme, ctx) => renderRowResult(row.toolName, result, options, theme, builtinContext(ctx as RenderContext)),
506
+ };
507
+ compactRendererDefinitions.add(renderers);
508
+ compactRendererDefinitions.add(renderers.renderResult);
509
+ return renderers;
510
+ }
511
+ if (!customTools.enabled || customTools.exclude.includes(row.toolName)) return undefined;
483
512
  return createCustomRenderers(row.toolName, row.toolDefinition);
484
513
  }
485
514
 
486
- const toolFactories: Record<CompactToolName, (cwd: string) => BuiltInDefinition> = {
487
- read: createReadToolDefinition,
488
- write: createWriteToolDefinition,
489
- edit: createEditToolDefinition,
490
- bash: createBashToolDefinition,
491
- powershell: createPowerShellToolDefinition,
492
- grep: createGrepToolDefinition,
493
- find: createFindToolDefinition,
494
- ls: createLsToolDefinition,
515
+ type RendererAPI = ExtensionAPI & {
516
+ registerToolRenderer?: (resolver: (name: string, next: () => ToolDefinition<any, any, any> | undefined) => ToolDefinition<any, any, any> | undefined) => void;
495
517
  };
496
-
497
- function registerCompactTool(pi: ExtensionAPI, definition: BuiltInDefinition): void {
498
- pi.registerTool({
499
- ...definition,
500
- execute: runtime.createTimedExecute(definition),
501
- renderShell: "self",
502
- renderCall: (args: ToolArgs, theme: Theme, ctx: RenderContext) => renderRowCall(definition.name, args, theme, ctx),
503
- renderResult: (result: AgentToolResult<unknown>, options: ToolRenderResultOptions, theme: Theme, ctx: RenderContext) =>
504
- renderRowResult(definition.name, result, options, theme, ctx),
505
- } as ToolDefinition<any, any, RowState>);
506
- }
507
-
508
- /** Off leaves every tool with Pi's own renderer. */
509
- function compactedTools(): CompactToolName[] {
510
- return runtime.config.style === "off" ? [] : runtime.config.tools;
511
- }
512
-
513
- function registerTools(pi: ExtensionAPI, cwd: string): void {
514
- const enabledTools = new Set(compactedTools());
515
- for (const name of registeredTools) {
516
- // Pi exposes no unregister API, so restoring a disabled tool means re-registering
517
- // the built-in definition to release this extension's renderers.
518
- if (!enabledTools.has(name)) pi.registerTool(toolFactories[name](cwd));
519
- }
520
- registeredTools.clear();
521
- for (const name of compactedTools()) {
522
- registerCompactTool(pi, toolFactories[name](cwd));
523
- registeredTools.add(name);
518
+ let publicRendering = false;
519
+ let sessionReady = false;
520
+ let publicOwners = new WeakMap<object, { call?: unknown; result?: unknown }>();
521
+ function publicRowDefinition(name: string, base: ToolDefinition<any, any, any> | undefined): ToolDefinition<any, any, any> | undefined {
522
+ // Preserve next()'s result exactly once the session's configuration is known.
523
+ let config: unknown;
524
+ let decision: RowRenderers | undefined;
525
+ const decide = () => {
526
+ if (config !== runtime.config) {
527
+ config = runtime.config;
528
+ decision = resolveCustomRow({ toolName: name, toolDefinition: base });
529
+ }
530
+ return decision;
531
+ };
532
+ if (!decide() && (sessionReady || !base)) return base;
533
+ const context = (ctx: Parameters<RowRenderers["renderCall"]>[2], kind: "call" | "result", render: unknown): typeof ctx => {
534
+ let owner = publicOwners.get(ctx.state);
535
+ if (!owner) publicOwners.set(ctx.state, owner = {});
536
+ const changed = owner[kind] !== render;
537
+ owner[kind] = render;
538
+ return changed ? { ...ctx, lastComponent: undefined } : ctx;
539
+ };
540
+ const call: RowRenderers["renderCall"] = (args, theme, ctx) => {
541
+ const render = decide()?.renderCall ?? base?.renderCall;
542
+ return render!(args, theme, context(ctx, "call", render));
543
+ };
544
+ const result: RowRenderers["renderResult"] = (value, options, theme, ctx) => {
545
+ const render = decide()?.renderResult ?? base?.renderResult;
546
+ return render!(value, options, theme, context(ctx, "result", render));
547
+ };
548
+ return {
549
+ get renderShell() { return decide() ? "self" : base?.renderShell ?? "default"; },
550
+ get renderCall() { return decide()?.renderCall || base?.renderCall ? call : undefined; },
551
+ get renderResult() { return decide()?.renderResult || base?.renderResult ? result : undefined; },
552
+ } as ToolDefinition<any, any, any>;
553
+ }
554
+ let legacyResolver: ((row: ToolRow) => RowRenderers | undefined) | undefined;
555
+ let refreshTranscriptRows: (() => void) | undefined;
556
+ function invalidateRowDecisions(): void {
557
+ if (!publicRendering) {
558
+ legacyResolver = (row) => resolveCustomRow(row);
559
+ setRowResolver(legacyResolver);
524
560
  }
561
+ refreshTranscriptRows?.();
525
562
  }
526
563
 
527
- function configure(pi: ExtensionAPI, cwd?: string, projectTrusted = false): void {
564
+ function configure(_pi: ExtensionAPI, cwd?: string, projectTrusted = false): void {
528
565
  const resolvedCwd = cwd ?? process.cwd();
529
566
  const config = loadConfig(resolvedCwd, projectTrusted);
530
567
  const signature = JSON.stringify([resolvedCwd, config]);
531
568
  if (signature === registeredConfiguration) return;
532
569
  runtime.configure(config);
533
- registerTools(pi, resolvedCwd);
534
570
  registeredConfiguration = signature;
571
+ invalidateRowDecisions();
535
572
  }
536
573
 
537
574
  /**
@@ -547,12 +584,7 @@ function applyStyle(cwd: string, trusted: boolean): boolean {
547
584
  if (JSON.stringify({ ...config, style: current.style }) !== JSON.stringify(current)) return false;
548
585
  runtime.configure(config);
549
586
  registeredConfiguration = JSON.stringify([cwd, config]);
550
- const chat = boundTui ? findChat(boundTui) : undefined;
551
- for (const child of chat?.children ?? []) {
552
- // Pi asks a row's renderers again whenever it redraws the row; this is that redraw.
553
- if (isKind(child, ToolExecutionComponent)) (child as { updateDisplay?(): void }).updateDisplay?.();
554
- }
555
- boundTui?.requestRender();
587
+ invalidateRowDecisions();
556
588
  return true;
557
589
  }
558
590
 
@@ -576,32 +608,41 @@ function madeByTool(event: object): boolean {
576
608
  return typeof (event as { parentToolCallId?: unknown }).parentToolCallId === "string";
577
609
  }
578
610
 
579
- /** Pi's TUI while one is bound: where a style switched in place finds the rows to draw again. */
580
- let boundTui: { requestRender(): void } | undefined;
581
-
582
611
  /** Hand the animation Pi's frame request, reached through an invisible widget as the extension API allows. */
583
612
  function bindRenderer(ctx: ExtensionContext): void {
584
613
  ctx.ui.setWidget(RENDER_WIDGET_KEY, (tui) => {
585
- boundTui = tui;
586
614
  runtime.bindRenderer(() => tui.requestRender());
615
+ refreshTranscriptRows = () => {
616
+ const visit = (parent: { children?: unknown[] }) => {
617
+ for (const child of parent.children ?? []) {
618
+ if (!child || typeof child !== "object") continue;
619
+ if (isKind(child, ToolExecutionComponent)) {
620
+ const row = child as ToolRow & { updateDisplay?(): void };
621
+ refreshRowShell(row);
622
+ row.updateDisplay?.();
623
+ } else visit(child as { children?: unknown[] });
624
+ }
625
+ };
626
+ const chat = findChat(tui);
627
+ if (chat && chat.children.length > 0) { visit(chat); tui.requestRender(); }
628
+ };
587
629
  return { render: () => [], invalidate() {} };
588
630
  }, { placement: "belowEditor" });
631
+ refreshTranscriptRows?.();
589
632
  }
590
633
 
591
634
  function unbindRenderer(ctx: ExtensionContext | undefined): void {
592
- boundTui = undefined;
635
+ refreshTranscriptRows = undefined;
593
636
  runtime.bindRenderer(undefined);
594
637
  ctx?.ui.setWidget(RENDER_WIDGET_KEY, undefined, { placement: "belowEditor" });
595
638
  }
596
639
 
597
640
  export default function compactTools(pi: ExtensionAPI): void {
598
- // Pi caches this factory and re-invokes it with a fresh `pi` for every session
599
- // replacement (/resume, /new, /fork) as well as /reload, each time starting from
600
- // an empty tool registry. The module itself survives those invocations, so the
601
- // memo below has to be dropped here or configure() mistakes the previous
602
- // instance's registration for this one and never installs the renderers.
641
+ // The cached factory is invoked again after session replacement and reload.
642
+ // Reset configuration memoization, but never change the host's tool registry.
603
643
  registeredConfiguration = undefined;
604
- registeredTools.clear();
644
+ sessionReady = false;
645
+ publicOwners = new WeakMap();
605
646
 
606
647
  registerCompactToolsCommand(pi, () => runtime.config, applyStyle);
607
648
  const thinkingCycle = new ThinkingCycleController(pi);
@@ -609,12 +650,22 @@ export default function compactTools(pi: ExtensionAPI): void {
609
650
  const viewport = new ViewportKeeper();
610
651
  const silent = new SilentModeController(pi, showReleaseNotice);
611
652
  const groups = new ToolGroupController(runtime);
612
- const customRowsAvailable = installToolRowPatch();
613
- setRowResolver(resolveCustomRow);
653
+ builtinStates = new WeakMap();
654
+ authorStates = new WeakMap();
655
+ const rendererAPI = pi as RendererAPI;
656
+ publicRendering = typeof rendererAPI.registerToolRenderer === "function";
657
+ const customRowsAvailable = publicRendering || installToolRowPatch();
658
+ if (publicRendering) {
659
+ setRowResolver(undefined);
660
+ rendererAPI.registerToolRenderer!((name, next) => {
661
+ const base = next();
662
+ return publicRowDefinition(name, base as ToolDefinition<any, any, any> | undefined);
663
+ });
664
+ } else invalidateRowDecisions();
614
665
  let customRowsWarned = false;
615
666
  let renderContext: ExtensionContext | undefined;
616
- // Register once while the extension runtime is being built. In particular, this
617
- // makes the overrides available before Pi restores the active tool set on /reload.
667
+ // Configure UI routing before Pi rebuilds transcript rows. Tool declarations
668
+ // and the active loadout must remain exactly as the host selected them.
618
669
  const cwd = process.cwd();
619
670
  const lastTrust = (globalThis as TrustSlot)[TRUST_KEY];
620
671
  configure(pi, cwd, lastTrust?.cwd === realDirectory(cwd) && lastTrust.trusted);
@@ -625,11 +676,13 @@ export default function compactTools(pi: ExtensionAPI): void {
625
676
  const trusted = ctx.isProjectTrusted();
626
677
  (globalThis as TrustSlot)[TRUST_KEY] = { cwd: realDirectory(ctx.cwd), trusted };
627
678
  configure(pi, ctx.cwd, trusted);
679
+ sessionReady = true;
628
680
  unbindRenderer(renderContext);
629
681
  renderContext = undefined;
682
+ // Even off must reconcile rows built before trusted config was available.
683
+ if (ctx.mode === "tui") bindRenderer(ctx);
630
684
  if (ctx.mode === "tui" && runtime.config.style !== "off") {
631
685
  renderContext = ctx;
632
- bindRenderer(ctx);
633
686
  // First, so its input listener sees relayout keys before the thinking controller consumes them.
634
687
  viewport.bind(ctx);
635
688
  progress.bind(ctx);
@@ -637,11 +690,12 @@ export default function compactTools(pi: ExtensionAPI): void {
637
690
  silent.bind(ctx, runtime.config.mode, event.reason === "reload");
638
691
  groups.bind(ctx);
639
692
  if (!silent.isEnabled()) showReleaseNotice(ctx);
640
- if (runtime.config.custom_tools.enabled && !customRowsAvailable && !customRowsWarned) {
693
+ if ((runtime.config.custom_tools.enabled || runtime.config.tools.length > 0) && !customRowsAvailable && !customRowsWarned) {
641
694
  customRowsWarned = true;
642
- ctx.ui.notify("Compact rendering for custom tools is unavailable in this version of Pi", "warning");
695
+ ctx.ui.notify("Compact tool rendering is unavailable in this version of Pi", "warning");
643
696
  }
644
697
  } else {
698
+ if (ctx.mode === "tui") unbindRenderer(ctx);
645
699
  viewport.dispose();
646
700
  progress.dispose();
647
701
  thinkingCycle.dispose();
@@ -661,7 +715,7 @@ export default function compactTools(pi: ExtensionAPI): void {
661
715
  });
662
716
  pi.on("tool_execution_end", (event) => {
663
717
  if (madeByTool(event)) return runtime.noteNestedCall(event.toolCallId, false);
664
- runtime.noteExecutionEnd(event.toolCallId);
718
+ runtime.noteExecutionEnd(event.toolCallId, (event as { durationMs?: number }).durationMs);
665
719
  });
666
720
  pi.on("session_shutdown", (event) => {
667
721
  thinkingCycle.dispose();
@@ -673,6 +727,7 @@ export default function compactTools(pi: ExtensionAPI): void {
673
727
  renderContext = undefined;
674
728
  runtime.reset(event.reason !== "reload");
675
729
  // A reload that keeps the extension sets it again before Pi rebuilds the transcript.
676
- clearRowResolver(resolveCustomRow);
730
+ if (legacyResolver) clearRowResolver(legacyResolver);
731
+ refreshTranscriptRows = undefined;
677
732
  });
678
733
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-compact-tools",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "Tool rows for Pi in three styles: Claude, Codex, and Compact. Switch with /compact-tools; includes silent mode and a GitHub Dark theme.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -51,8 +51,8 @@
51
51
  "@earendil-works/pi-tui": ">=0.85.1"
52
52
  },
53
53
  "devDependencies": {
54
- "@earendil-works/pi-coding-agent": "1.0.0",
55
- "@earendil-works/pi-tui": "1.0.0",
54
+ "@earendil-works/pi-coding-agent": "1.1.0",
55
+ "@earendil-works/pi-tui": "1.1.0",
56
56
  "@sinonjs/fake-timers": "15.0.0",
57
57
  "@types/node": "^24.0.0",
58
58
  "@types/sinonjs__fake-timers": "15.0.1",
@@ -1,4 +1,26 @@
1
1
  {
2
+ "0.14.2": {
3
+ "New": [
4
+ "Supports Pi 1.1 recorded execution durations and output padding when those fields are available"
5
+ ],
6
+ "Fix": [
7
+ "Restored Compact and expanded Codex rows retain their recorded execution time, including zero-duration calls",
8
+ "Final host-recorded durations stay accurate when the system clock changes",
9
+ "Tool rows and folded groups honor outputPad without changing their internal style indentation",
10
+ "Embedded third-party renderers do not receive a duplicate outer margin",
11
+ "Preserves existing timing and margins on older Pi versions; the minimum supported version remains Pi 0.85.1"
12
+ ],
13
+ "Deprecated": []
14
+ },
15
+ "0.14.1": {
16
+ "Fix": [
17
+ "Fixed unintended input-token overhead: compact rendering no longer registers or activates tools that Pi would otherwise leave inactive",
18
+ "Preserves Pi’s tool execution settings and other extensions’ tool definitions and renderers",
19
+ "Fixed tool rows not updating correctly after reloads or trusted-project style changes",
20
+ "Uses Pi’s native renderer API on Pi 1.0.2 and later, including compact HTML exports",
21
+ "Supports Pi 0.85.1 and later; Pi 1.0.2 or later is recommended. On earlier Pi versions, HTML exports use Pi’s default renderers"
22
+ ]
23
+ },
2
24
  "0.14.0": {
3
25
  "New": [
4
26
  "Codex style added.",