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 +7 -4
- package/extensions/compact-tools-codex-rows.ts +1 -0
- package/extensions/compact-tools-codex.ts +10 -3
- package/extensions/compact-tools-grouping.ts +10 -2
- package/extensions/compact-tools-layout.ts +170 -5
- package/extensions/compact-tools-runtime.ts +11 -2
- package/extensions/compact-tools-types.ts +8 -1
- package/extensions/compact-tools.ts +22 -7
- package/package.json +3 -3
- package/release-notes.json +22 -0
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
24
|
-
|
|
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 = ` (${
|
|
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) =>
|
|
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
|
-
|
|
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",
|
|
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,
|
|
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
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
55
|
-
"@earendil-works/pi-tui": "1.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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",
|