pi-compact-tools 0.14.1 → 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
@@ -97,7 +97,10 @@ such as `read` or `bash` follow `custom_tools`, including its exclusions.
97
97
 
98
98
  On Pi 1.0.2 and newer, the public renderer API also applies compact rendering to HTML
99
99
  exports. Older hosts use a terminal-only compatibility patch; HTML exports keep
100
- Pi's native renderers. Durations use the host's tool-execution event boundaries.
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.
101
104
  When custom compact rendering is enabled, it intentionally takes precedence over
102
105
  fallback renderers supplied by earlier `next() ?? mine` resolvers for unknown tools.
103
106
  Disable `custom_tools` or exclude the tool to preserve those fallback renderers.
@@ -115,7 +118,7 @@ npm run check
115
118
  pi -e .
116
119
  ```
117
120
 
118
- 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.
119
122
 
120
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.
121
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
 
@@ -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[];
@@ -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
  };
@@ -38,6 +38,7 @@ import {
38
38
  prefixedSentence,
39
39
  prefixedText,
40
40
  railComponent,
41
+ withOutputPad,
41
42
  renderArguments,
42
43
  renderCodeDiff,
43
44
  renderCodeView,
@@ -135,6 +136,7 @@ function liveIndicator(theme: Theme, ctx: RenderContext, state: RowState, glyph?
135
136
  }
136
137
 
137
138
  function formatDuration(state: RowState): string | undefined {
139
+ if (state.durationMs !== undefined) return formatDurationMs(state.durationMs);
138
140
  if (state.startedAt === undefined) return undefined;
139
141
  return formatDurationMs((state.endedAt ?? Date.now()) - state.startedAt);
140
142
  }
@@ -154,7 +156,9 @@ function canReuseResult<TArgs>(
154
156
  && state.lastResultPreview === state.preview
155
157
  && state.lastResultError === ctx.isError
156
158
  && state.lastResultConfigRevision === state.configRevision
157
- && state.lastResultPalette === paletteOf(theme);
159
+ && state.lastResultPalette === paletteOf(theme)
160
+ && state.lastResultDurationMs === state.durationMs
161
+ && state.lastResultOutputPad === ctx.outputPad;
158
162
  }
159
163
 
160
164
  function rememberResult(state: RowState, result: AgentToolResult<unknown>, options: ToolRenderResultOptions, isError: boolean, theme: Theme): void {
@@ -203,6 +207,10 @@ function hasNestedArguments(args: ToolArgs): boolean {
203
207
 
204
208
  /** The call line: a status dot, the tool, and what it was asked, then any arguments the line leaves out. */
205
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 {
206
214
  const state = runtime.syncRow(ctx);
207
215
  runtime.syncExpansion(state, ctx.expanded, name);
208
216
  const kind = rowKind(name);
@@ -436,7 +444,9 @@ function renderRowResult(
436
444
  component = new WithinWidth(container);
437
445
  }
438
446
  rememberResult(state, result, options, ctx.isError, theme);
439
- return component;
447
+ state.lastResultDurationMs = state.durationMs;
448
+ state.lastResultOutputPad = ctx.outputPad;
449
+ return withOutputPad(component, ctx.outputPad);
440
450
  }
441
451
 
442
452
  /**
@@ -461,7 +471,12 @@ function createCustomRenderers(name: string, author: ToolDefinition<any, any, an
461
471
  const renderAuthorResult = authorRenderer
462
472
  ? (expanded: boolean) => {
463
473
  const value = slot(ctx);
464
- value.component = authorRenderer(result, { ...options, expanded }, theme, { ...ctx, lastComponent: value.component });
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
+ });
465
480
  return value.component;
466
481
  }
467
482
  : undefined;
@@ -515,7 +530,7 @@ function publicRowDefinition(name: string, base: ToolDefinition<any, any, any> |
515
530
  return decision;
516
531
  };
517
532
  if (!decide() && (sessionReady || !base)) return base;
518
- const context = (ctx: RenderContext, kind: "call" | "result", render: unknown): RenderContext => {
533
+ const context = (ctx: Parameters<RowRenderers["renderCall"]>[2], kind: "call" | "result", render: unknown): typeof ctx => {
519
534
  let owner = publicOwners.get(ctx.state);
520
535
  if (!owner) publicOwners.set(ctx.state, owner = {});
521
536
  const changed = owner[kind] !== render;
@@ -524,11 +539,11 @@ function publicRowDefinition(name: string, base: ToolDefinition<any, any, any> |
524
539
  };
525
540
  const call: RowRenderers["renderCall"] = (args, theme, ctx) => {
526
541
  const render = decide()?.renderCall ?? base?.renderCall;
527
- return render!(args, theme, context(ctx as RenderContext, "call", render));
542
+ return render!(args, theme, context(ctx, "call", render));
528
543
  };
529
544
  const result: RowRenderers["renderResult"] = (value, options, theme, ctx) => {
530
545
  const render = decide()?.renderResult ?? base?.renderResult;
531
- return render!(value, options, theme, context(ctx as RenderContext, "result", render));
546
+ return render!(value, options, theme, context(ctx, "result", render));
532
547
  };
533
548
  return {
534
549
  get renderShell() { return decide() ? "self" : base?.renderShell ?? "default"; },
@@ -700,7 +715,7 @@ export default function compactTools(pi: ExtensionAPI): void {
700
715
  });
701
716
  pi.on("tool_execution_end", (event) => {
702
717
  if (madeByTool(event)) return runtime.noteNestedCall(event.toolCallId, false);
703
- runtime.noteExecutionEnd(event.toolCallId);
718
+ runtime.noteExecutionEnd(event.toolCallId, (event as { durationMs?: number }).durationMs);
704
719
  });
705
720
  pi.on("session_shutdown", (event) => {
706
721
  thinkingCycle.dispose();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-compact-tools",
3
- "version": "0.14.1",
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,17 @@
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
+ },
2
15
  "0.14.1": {
3
16
  "Fix": [
4
17
  "Fixed unintended input-token overhead: compact rendering no longer registers or activates tools that Pi would otherwise leave inactive",