pi-compact-tools 0.14.1 → 0.15.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
@@ -30,7 +30,7 @@ Silent mode (`Ctrl+'` or `/silent`) works on top of any style. `/compact-tools o
30
30
 
31
31
  - Reads, searches, and listings fold into one line: `Searched for 2 patterns, read 3 files`.
32
32
  - `Bash`, `Update`, `Write`, and custom tools keep their own rows with `⎿` results and short previews.
33
- - Matched against Claude Code 2.1.288 in a live terminal.
33
+ - Matched against Claude Code 2.1.296 in a live terminal.
34
34
 
35
35
  ### Codex
36
36
 
@@ -39,7 +39,7 @@ Silent mode (`Ctrl+'` or `/silent`) works on top of any style. `/compact-tools o
39
39
  - Reads, searches, and listings fold into `• Explored`: `Read a.ts, b.ts`, `Search TODO in src`.
40
40
  - Commands read `Ran` or `Failed (exit 2)` with their last three output lines; opened, `$ cmd`, all its output, and `✓ • 1.23s`.
41
41
  - Edits preview their changed lines; custom tools read `• Called name`.
42
- - Matched against Codex CLI 0.160.0 in a live terminal.
42
+ - Matched against Codex CLI 0.162.1 in a live terminal.
43
43
 
44
44
  ### Compact
45
45
 
@@ -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
 
@@ -20,8 +20,15 @@ export const CODEX_MARGIN = " ";
20
20
  export const CODEX_BULLET = "•";
21
21
  /** Codex draws a tab as four columns. */
22
22
  export const CODEX_TAB = " ";
23
- /** Codex's key is ctrl+t; in Pi the key that opens a row is ctrl+o. */
24
- export const CODEX_EXPAND_KEY = "ctrl+o";
23
+ /**
24
+ * Codex's key is ctrl+t; in Pi the key that opens a row is ctrl+o. Codex 0.161 spells
25
+ * a modifier by platform (`⌃t` on macOS, `^t` on Linux, `ctrl+t` elsewhere) and so does
26
+ * this. PI_COMPACT_TOOLS_PLATFORM stands in for the platform, so the suite draws the
27
+ * same rows wherever it runs.
28
+ */
29
+ export function codexExpandKey(platform: string = process.env.PI_COMPACT_TOOLS_PLATFORM ?? process.platform): string {
30
+ return platform === "darwin" ? "⌃o" : platform === "linux" ? "^o" : "ctrl+o";
31
+ }
25
32
 
26
33
  /** How Codex prints how long a call took: `250ms`, `1.50s`, `1m 15s`. */
27
34
  export function formatCodexDuration(milliseconds: number): string {
@@ -41,7 +48,7 @@ export function codexDisclosure(width: number, expanded: boolean, hiddenLines?:
41
48
  if (expanded) return `${indent}− Show less`;
42
49
  if (hiddenLines === undefined) return `${indent}+ Show details`;
43
50
  const label = `+ ${hiddenLines} ${hiddenLines === 1 ? "line" : "lines"}`;
44
- const hint = ` (${CODEX_EXPAND_KEY} to expand)`;
51
+ const hint = ` (${codexExpandKey()} to expand)`;
45
52
  return visibleWidth(indent + label + hint) <= width ? indent + label + hint : indent + label;
46
53
  }
47
54
 
@@ -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
@@ -8,13 +8,52 @@ import {
8
8
  visibleWidth,
9
9
  wrapTextWithAnsi,
10
10
  } from "@earendil-works/pi-tui";
11
- import { diffTintRgb, fillRgb } from "./compact-tools-color.ts";
11
+ import { diffTintRgb, fillRgb, type Rgb } from "./compact-tools-color.ts";
12
12
  import { normalizeLineEndings } from "./compact-tools-core.ts";
13
13
  import { resolveLanguage } from "./compact-tools-language.ts";
14
14
  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[];
@@ -408,10 +447,123 @@ function codeGutter(
408
447
  theme: Theme,
409
448
  ): string {
410
449
  const number = (lineNumber === undefined ? "" : String(lineNumber)).padStart(numberWidth);
411
- if (!signs) return theme.fg("toolDiffContext", `${number} `);
450
+ if (!signs) return theme.fg("toolDiffContext", ` ${number} `);
412
451
  const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
413
452
  const color = kind === "add" ? "toolDiffAdded" : kind === "remove" ? "toolDiffRemoved" : "toolDiffContext";
414
- return theme.fg(color, `${number} ${sign} `);
453
+ return theme.fg(color, ` ${number} ${sign}`);
454
+ }
455
+
456
+ type CharRange = [start: number, end: number];
457
+
458
+ /** Most token pairs a word diff compares; a longer pair of lines is left untinted instead of taking quadratic time. */
459
+ const WORD_DIFF_MAX_CELLS = 40_000;
460
+ /** How alike two lines must be, by shared characters, before their changed words are marked. */
461
+ const WORD_DIFF_MIN_SIMILARITY = 0.6;
462
+ /** How much of the theme color a changed word is tinted with, against the row's own tint. */
463
+ const WORD_TINT = 0.32;
464
+
465
+ /**
466
+ * The changed words of a removed row and the added row that replaces it, as Claude
467
+ * Code marks them: a brighter tint behind each word the pair does not share. Two
468
+ * rows with little in common are rewrites rather than edits and mark nothing.
469
+ */
470
+ function wordDiffRanges(removed: string, added: string): { removed: CharRange[]; added: CharRange[] } | undefined {
471
+ const split = (text: string) => text.match(/[\p{L}\p{N}_]+|\s+|[^\s\p{L}\p{N}_]/gu) ?? [];
472
+ const before = split(removed);
473
+ const after = split(added);
474
+ if (before.length * after.length > WORD_DIFF_MAX_CELLS) return undefined;
475
+ const width = after.length + 1;
476
+ const lengths = new Uint16Array((before.length + 1) * width);
477
+ for (let i = before.length - 1; i >= 0; i--) {
478
+ for (let j = after.length - 1; j >= 0; j--) {
479
+ lengths[i * width + j] = before[i] === after[j]
480
+ ? lengths[(i + 1) * width + j + 1]! + 1
481
+ : Math.max(lengths[(i + 1) * width + j]!, lengths[i * width + j + 1]!);
482
+ }
483
+ }
484
+ const removedChanged = new Array<boolean>(before.length).fill(true);
485
+ const addedChanged = new Array<boolean>(after.length).fill(true);
486
+ let shared = 0;
487
+ for (let i = 0, j = 0; i < before.length && j < after.length;) {
488
+ if (before[i] === after[j]) {
489
+ removedChanged[i] = addedChanged[j] = false;
490
+ shared += before[i]!.length;
491
+ i++;
492
+ j++;
493
+ } else if (lengths[(i + 1) * width + j]! >= lengths[i * width + j + 1]!) i++;
494
+ else j++;
495
+ }
496
+ if (shared === 0 || (2 * shared) / (removed.length + added.length) < WORD_DIFF_MIN_SIMILARITY) return undefined;
497
+ const ranges = (text: string, tokens: string[], changed: boolean[]): CharRange[] => {
498
+ const output: CharRange[] = [];
499
+ let offset = 0;
500
+ tokens.forEach((token, index) => {
501
+ const last = output.at(-1);
502
+ if (changed[index]) {
503
+ if (last && last[1] === offset) last[1] = offset + token.length;
504
+ else output.push([offset, offset + token.length]);
505
+ }
506
+ offset += token.length;
507
+ });
508
+ // A range starts and ends on a word, not on the blanks beside it.
509
+ return output
510
+ .map(([start, end]): CharRange => [
511
+ end - text.slice(start, end).trimStart().length,
512
+ start + text.slice(start, end).trimEnd().length,
513
+ ])
514
+ .filter(([start, end]) => end > start);
515
+ };
516
+ const markedRemoved = ranges(removed, before, removedChanged);
517
+ const markedAdded = ranges(added, after, addedChanged);
518
+ return markedRemoved.length || markedAdded.length ? { removed: markedRemoved, added: markedAdded } : undefined;
519
+ }
520
+
521
+ /** Changed-word ranges for every removed row paired, in order, with the added row beside it. */
522
+ function pairWordRanges(lines: readonly CodeDiffLine[]): Map<number, CharRange[]> {
523
+ const marked = new Map<number, CharRange[]>();
524
+ let index = 0;
525
+ while (index < lines.length) {
526
+ if (lines[index]!.kind !== "remove") {
527
+ index++;
528
+ continue;
529
+ }
530
+ const removedStart = index;
531
+ while (index < lines.length && lines[index]!.kind === "remove") index++;
532
+ const addedStart = index;
533
+ while (index < lines.length && lines[index]!.kind === "add") index++;
534
+ for (let offset = 0; offset < Math.min(addedStart - removedStart, index - addedStart); offset++) {
535
+ const pair = wordDiffRanges(lines[removedStart + offset]!.content, lines[addedStart + offset]!.content);
536
+ if (!pair) continue;
537
+ if (pair.removed.length) marked.set(removedStart + offset, pair.removed);
538
+ if (pair.added.length) marked.set(addedStart + offset, pair.added);
539
+ }
540
+ }
541
+ return marked;
542
+ }
543
+
544
+ /** A row's text with a brighter fill behind the given character ranges and `base` behind the rest. */
545
+ function fillWithWords(
546
+ theme: Theme,
547
+ base: Rgb,
548
+ word: Rgb,
549
+ content: string,
550
+ ranges: readonly CharRange[],
551
+ chunk: string,
552
+ chunkStart: number,
553
+ padding: number,
554
+ ): string {
555
+ const chunkWidth = visibleWidth(chunk);
556
+ let output = "";
557
+ let cursor = 0;
558
+ for (const [start, end] of ranges) {
559
+ const from = Math.max(cursor, visibleWidth(content.slice(0, start)) - chunkStart);
560
+ const to = Math.min(chunkWidth, visibleWidth(content.slice(0, end)) - chunkStart);
561
+ if (to <= from) continue;
562
+ if (from > cursor) output += fillRgb(theme, base, sliceByColumn(chunk, cursor, from - cursor, true));
563
+ output += fillRgb(theme, word, sliceByColumn(chunk, from, to - from, true));
564
+ cursor = to;
565
+ }
566
+ return output + fillRgb(theme, base, sliceByColumn(chunk, cursor, chunkWidth - cursor, true) + " ".repeat(padding));
415
567
  }
416
568
 
417
569
  type CodeRowsOptions = {
@@ -439,8 +591,12 @@ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, optio
439
591
  const gutterWidth = numberWidth + (options.signs ? 3 : 2);
440
592
  const addedTint = options.signs ? diffTintRgb(theme, "toolDiffAdded") : undefined;
441
593
  const removedTint = options.signs ? diffTintRgb(theme, "toolDiffRemoved") : undefined;
594
+ const addedWordTint = options.signs ? diffTintRgb(theme, "toolDiffAdded", WORD_TINT) : undefined;
595
+ const removedWordTint = options.signs ? diffTintRgb(theme, "toolDiffRemoved", WORD_TINT) : undefined;
596
+ let words: Map<number, CharRange[]> | undefined;
442
597
  return new CachedComponent((width) => {
443
598
  highlighted ??= highlightCodeLines(lines, resolveLanguage(path, shebangLine), theme);
599
+ if (options.signs) words ??= pairWordRanges(lines);
444
600
  const bodyWidth = Math.max(1, width - prefixWidth);
445
601
  const codeWidth = Math.max(1, bodyWidth - gutterWidth);
446
602
  const output: string[] = [];
@@ -450,6 +606,8 @@ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, optio
450
606
  return;
451
607
  }
452
608
  const tint = line.kind === "add" ? addedTint : line.kind === "remove" ? removedTint : undefined;
609
+ const wordRanges = words?.get(index);
610
+ const wordTint = line.kind === "add" ? addedWordTint : removedWordTint;
453
611
  for (const [chunkIndex, chunk] of hardSliceAnsi(highlighted![index] ?? line.content, codeWidth).entries()) {
454
612
  const gutter = codeGutter(
455
613
  line.kind,
@@ -463,8 +621,15 @@ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, optio
463
621
  output.push(prefix + body);
464
622
  continue;
465
623
  }
466
- const padded = body + " ".repeat(Math.max(0, bodyWidth - visibleWidth(body)));
467
- output.push(prefix + fillRgb(theme, tint, padded));
624
+ const padding = Math.max(0, bodyWidth - visibleWidth(body));
625
+ if (wordRanges && wordTint) {
626
+ output.push(
627
+ prefix + fillRgb(theme, tint, gutter)
628
+ + fillWithWords(theme, tint, wordTint, line.content, wordRanges, chunk, chunkIndex * codeWidth, padding),
629
+ );
630
+ continue;
631
+ }
632
+ output.push(prefix + fillRgb(theme, tint, body + " ".repeat(padding)));
468
633
  }
469
634
  });
470
635
  if (options.footer) {
@@ -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.15.0",
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.15.0": {
3
+ "New": [
4
+ "Claude style now marks the changed words in an edit diff, like Claude Code 2.1.296: a removed line and the added line that replaces it get a brighter tint on the words that differ. Lines that are mostly rewritten are left alone",
5
+ "Codex style spells the expand key the way Codex 0.162 does: `⌃o` on macOS, `^o` on Linux, `ctrl+o` elsewhere"
6
+ ],
7
+ "Fix": [
8
+ "Line numbers in edit diffs and Write previews moved one column right and lost one trailing space, matching Claude Code 2.1.296. Code stays in the same column"
9
+ ]
10
+ },
11
+ "0.14.2": {
12
+ "New": [
13
+ "Supports Pi 1.1 recorded execution durations and output padding when those fields are available"
14
+ ],
15
+ "Fix": [
16
+ "Restored Compact and expanded Codex rows retain their recorded execution time, including zero-duration calls",
17
+ "Final host-recorded durations stay accurate when the system clock changes",
18
+ "Tool rows and folded groups honor outputPad without changing their internal style indentation",
19
+ "Embedded third-party renderers do not receive a duplicate outer margin",
20
+ "Preserves existing timing and margins on older Pi versions; the minimum supported version remains Pi 0.85.1"
21
+ ],
22
+ "Deprecated": []
23
+ },
2
24
  "0.14.1": {
3
25
  "Fix": [
4
26
  "Fixed unintended input-token overhead: compact rendering no longer registers or activates tools that Pi would otherwise leave inactive",