pi-compact-tools 0.13.1 → 0.13.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 +4 -0
- package/extensions/compact-tools-activity.ts +10 -2
- package/extensions/compact-tools-layout.ts +9 -8
- package/extensions/compact-tools-runtime.ts +16 -0
- package/extensions/compact-tools-types.ts +1 -0
- package/extensions/compact-tools-viewport.ts +5 -7
- package/extensions/compact-tools.ts +8 -4
- package/package.json +7 -5
- package/release-notes.json +9 -0
- package/scripts/check-resources.mjs +0 -82
package/README.md
CHANGED
|
@@ -79,6 +79,10 @@ npm run check
|
|
|
79
79
|
pi -e .
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
+
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.
|
|
83
|
+
|
|
84
|
+
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.
|
|
85
|
+
|
|
82
86
|
Renders are pinned in `tests/golden/render.json`. After an intended change to the output, record it with `UPDATE_GOLDEN=1 npm test` and review the diff. Before publishing, add the version's notes to `release-notes.json` (`[]` for none).
|
|
83
87
|
|
|
84
88
|
## License
|
|
@@ -264,8 +264,16 @@ export class SilentActivityAnimator {
|
|
|
264
264
|
else if (type === "thinking_start" || type === "toolcall_start") this.setAnswering(false);
|
|
265
265
|
});
|
|
266
266
|
pi.on("tool_execution_start", () => this.setAnswering(false));
|
|
267
|
-
pi.on("turn_start", () =>
|
|
268
|
-
|
|
267
|
+
pi.on("turn_start", () => {
|
|
268
|
+
if (!this.context) return;
|
|
269
|
+
this.running = true;
|
|
270
|
+
this.answering = false;
|
|
271
|
+
this.refresh();
|
|
272
|
+
});
|
|
273
|
+
// agent_end closes a low-level run, not retries/compaction/continuations.
|
|
274
|
+
// Keep the shared clock alive until Pi declares final settlement, as the
|
|
275
|
+
// working-label controller already does.
|
|
276
|
+
pi.on("agent_settled", () => {
|
|
269
277
|
if (!this.context) return;
|
|
270
278
|
this.running = false;
|
|
271
279
|
this.refresh();
|
|
@@ -358,14 +358,11 @@ function highlightCodeLines(lines: CodeDiffLine[], language: string | undefined,
|
|
|
358
358
|
}
|
|
359
359
|
let end = index;
|
|
360
360
|
while (end < lines.length && lines[end]!.kind !== "separator") end++;
|
|
361
|
-
const
|
|
362
|
-
const contents = block.map((line) => line.content);
|
|
361
|
+
const contents = lines.slice(index, end).map((line) => line.content);
|
|
363
362
|
const highlighted = language === "markdown"
|
|
364
363
|
? highlightMarkdown(contents, theme)
|
|
365
364
|
: highlightCode(contents.join("\n"), language);
|
|
366
|
-
|
|
367
|
-
rendered[index + offset] = highlighted[offset] ?? line.content;
|
|
368
|
-
});
|
|
365
|
+
for (let offset = 0; offset < contents.length; offset++) rendered[index + offset] = highlighted[offset] ?? contents[offset]!;
|
|
369
366
|
index = end;
|
|
370
367
|
}
|
|
371
368
|
return rendered;
|
|
@@ -411,14 +408,18 @@ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, optio
|
|
|
411
408
|
// read at an offset or a diff hunk never reads one out of a comment.
|
|
412
409
|
const first = lines[0];
|
|
413
410
|
const shebangLine = first?.lineNumber === 1 && first.kind !== "separator" ? first.content : undefined;
|
|
414
|
-
|
|
415
|
-
|
|
411
|
+
// ToolExecutionComponent builds result components even when silent mode hides
|
|
412
|
+
// the row. Defer syntax work until the code is actually drawn, and keep it
|
|
413
|
+
// across width changes so resize does not highlight the same source again.
|
|
414
|
+
let highlighted: string[] | undefined;
|
|
415
|
+
const numberWidth = lines.reduce((maximum, line) => Math.max(maximum, String(line.lineNumber ?? "").length), 1);
|
|
416
416
|
const prefix = options.rail ?? paintChrome(theme, " \u2502 ");
|
|
417
417
|
const prefixWidth = visibleWidth(prefix);
|
|
418
418
|
const gutterWidth = numberWidth + (options.signs ? 3 : 2);
|
|
419
419
|
const addedTint = options.signs ? diffTintRgb(theme, "toolDiffAdded") : undefined;
|
|
420
420
|
const removedTint = options.signs ? diffTintRgb(theme, "toolDiffRemoved") : undefined;
|
|
421
421
|
return new CachedComponent((width) => {
|
|
422
|
+
highlighted ??= highlightCodeLines(lines, resolveLanguage(path, shebangLine), theme);
|
|
422
423
|
const bodyWidth = Math.max(1, width - prefixWidth);
|
|
423
424
|
const codeWidth = Math.max(1, bodyWidth - gutterWidth);
|
|
424
425
|
const output: string[] = [];
|
|
@@ -428,7 +429,7 @@ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, optio
|
|
|
428
429
|
return;
|
|
429
430
|
}
|
|
430
431
|
const tint = line.kind === "add" ? addedTint : line.kind === "remove" ? removedTint : undefined;
|
|
431
|
-
for (const [chunkIndex, chunk] of hardSliceAnsi(highlighted[index] ?? line.content, codeWidth).entries()) {
|
|
432
|
+
for (const [chunkIndex, chunk] of hardSliceAnsi(highlighted![index] ?? line.content, codeWidth).entries()) {
|
|
432
433
|
const gutter = codeGutter(
|
|
433
434
|
line.kind,
|
|
434
435
|
chunkIndex === 0 ? line.lineNumber : undefined,
|
|
@@ -207,12 +207,14 @@ export class ToolRuntime {
|
|
|
207
207
|
*/
|
|
208
208
|
noteExecutionStart(toolCallId: string): void {
|
|
209
209
|
if (!this.executionTimings.has(toolCallId)) this.executionTimings.set(toolCallId, { startedAt: Date.now() });
|
|
210
|
+
this.trimTimings();
|
|
210
211
|
}
|
|
211
212
|
|
|
212
213
|
noteExecutionEnd(toolCallId: string): void {
|
|
213
214
|
const timing = this.executionTimings.get(toolCallId) ?? { startedAt: Date.now() };
|
|
214
215
|
timing.endedAt ??= Date.now();
|
|
215
216
|
this.executionTimings.set(toolCallId, timing);
|
|
217
|
+
this.trimTimings();
|
|
216
218
|
}
|
|
217
219
|
|
|
218
220
|
createTimedExecute(definition: BuiltInDefinition): TimedExecute {
|
|
@@ -222,6 +224,7 @@ export class ToolRuntime {
|
|
|
222
224
|
const startedAt = (toolCallId ? this.executionTimings.get(toolCallId)?.startedAt : undefined) ?? Date.now();
|
|
223
225
|
if (toolCallId && !this.executionTimings.has(toolCallId)) {
|
|
224
226
|
this.executionTimings.set(toolCallId, { startedAt });
|
|
227
|
+
this.trimTimings();
|
|
225
228
|
}
|
|
226
229
|
try {
|
|
227
230
|
return await execute.apply(definition, args);
|
|
@@ -295,7 +298,20 @@ export class ToolRuntime {
|
|
|
295
298
|
timing.startedAt = state.startedAt;
|
|
296
299
|
if (state.endedAt !== undefined) timing.endedAt = state.endedAt;
|
|
297
300
|
this.executionTimings.set(toolCallId, timing);
|
|
301
|
+
this.trimTimings();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Bound every insertion, including calls never drawn (for example in silent mode). */
|
|
305
|
+
private trimTimings(): void {
|
|
298
306
|
if (this.executionTimings.size <= MAX_TRACKED_ROWS) return;
|
|
307
|
+
// Prefer settled calls so a long-running sibling keeps its start time. If all
|
|
308
|
+
// 2,001 calls are active, retain the original FIFO fallback to stay bounded.
|
|
309
|
+
for (const [id, timing] of this.executionTimings) {
|
|
310
|
+
if (timing.endedAt !== undefined) {
|
|
311
|
+
this.executionTimings.delete(id);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
299
315
|
const oldest = this.executionTimings.keys().next().value;
|
|
300
316
|
if (oldest !== undefined) this.executionTimings.delete(oldest);
|
|
301
317
|
}
|
|
@@ -89,17 +89,15 @@ export function findAnchorTop(
|
|
|
89
89
|
else starts.set(line, [index]);
|
|
90
90
|
});
|
|
91
91
|
const bottom = Math.min(before.length, top + Math.max(1, height));
|
|
92
|
-
|
|
93
|
-
for (let
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const window = before.slice(row, row + ANCHOR_LINES);
|
|
97
|
-
if (window.length < ANCHOR_LINES || !window.some(hasText)) continue;
|
|
92
|
+
// Visit the viewport first, then preceding rows, without copying candidate windows.
|
|
93
|
+
for (let candidate = 0; candidate < bottom; candidate++) {
|
|
94
|
+
const row = candidate < bottom - top ? top + candidate : bottom - candidate - 1;
|
|
95
|
+
if (row + ANCHOR_LINES > before.length || (!hasText(before[row]) && !hasText(before[row + 1]))) continue;
|
|
98
96
|
// Several places can match a short window; the one that keeps matching for
|
|
99
97
|
// longest is the same text, and distance only breaks a remaining tie.
|
|
100
98
|
let best: number | undefined;
|
|
101
99
|
let bestRun = 0;
|
|
102
|
-
for (const start of starts.get(
|
|
100
|
+
for (const start of starts.get(before[row]!) ?? []) {
|
|
103
101
|
const run = matchingRun(before, row, after, start, TIE_BREAK_LINES);
|
|
104
102
|
if (run < ANCHOR_LINES) continue;
|
|
105
103
|
if (best === undefined || run > bestRun
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import type { Component } from "@earendil-works/pi-tui";
|
|
20
20
|
import { classifyCallStatus, formatDurationMs, normalizeLineEndings, type RowStatus } from "./compact-tools-core.ts";
|
|
21
21
|
import { loadConfig } from "./compact-tools-config.ts";
|
|
22
|
+
import { paletteOf } from "./compact-tools-color.ts";
|
|
22
23
|
import { installToolRowPatch, setRowResolver, type RowRenderers, type ToolRow } from "./compact-tools-custom.ts";
|
|
23
24
|
import {
|
|
24
25
|
formatResultLineSummary,
|
|
@@ -128,6 +129,7 @@ function canReuseResult<TArgs>(
|
|
|
128
129
|
state: RowState,
|
|
129
130
|
result: AgentToolResult<unknown>,
|
|
130
131
|
options: ToolRenderResultOptions,
|
|
132
|
+
theme: Theme,
|
|
131
133
|
ctx: RenderContext<TArgs>,
|
|
132
134
|
): ctx is RenderContext<TArgs> & { lastComponent: Component } {
|
|
133
135
|
return ctx.lastComponent !== undefined
|
|
@@ -137,10 +139,11 @@ function canReuseResult<TArgs>(
|
|
|
137
139
|
&& state.lastResultExpanded === state.expanded
|
|
138
140
|
&& state.lastResultPreview === state.preview
|
|
139
141
|
&& state.lastResultError === ctx.isError
|
|
140
|
-
&& state.lastResultConfigRevision === state.configRevision
|
|
142
|
+
&& state.lastResultConfigRevision === state.configRevision
|
|
143
|
+
&& state.lastResultPalette === paletteOf(theme);
|
|
141
144
|
}
|
|
142
145
|
|
|
143
|
-
function rememberResult(state: RowState, result: AgentToolResult<unknown>, options: ToolRenderResultOptions, isError: boolean): void {
|
|
146
|
+
function rememberResult(state: RowState, result: AgentToolResult<unknown>, options: ToolRenderResultOptions, isError: boolean, theme: Theme): void {
|
|
144
147
|
state.lastResultContent = result.content;
|
|
145
148
|
state.lastResultDetails = result.details;
|
|
146
149
|
state.lastResultPartial = options.isPartial;
|
|
@@ -148,6 +151,7 @@ function rememberResult(state: RowState, result: AgentToolResult<unknown>, optio
|
|
|
148
151
|
state.lastResultPreview = state.preview;
|
|
149
152
|
state.lastResultError = isError;
|
|
150
153
|
state.lastResultConfigRevision = state.configRevision;
|
|
154
|
+
state.lastResultPalette = paletteOf(theme);
|
|
151
155
|
}
|
|
152
156
|
|
|
153
157
|
function renderControls(
|
|
@@ -376,7 +380,7 @@ function renderRowResult(
|
|
|
376
380
|
): Component {
|
|
377
381
|
const state = runtime.syncRow(ctx, !options.isPartial);
|
|
378
382
|
runtime.syncExpansion(state, ctx.expanded, name);
|
|
379
|
-
if (canReuseResult(state, result, options, ctx)) return ctx.lastComponent;
|
|
383
|
+
if (canReuseResult(state, result, options, theme, ctx)) return ctx.lastComponent;
|
|
380
384
|
const kind = rowKind(name);
|
|
381
385
|
const output = kind === "file" ? getFileOutput(name, ctx.args, result, ctx.isError) : getTextResult(result);
|
|
382
386
|
const hasEditDiff = name === "edit" && getEditDiff(result).length > 0;
|
|
@@ -402,7 +406,7 @@ function renderRowResult(
|
|
|
402
406
|
name === "edit" ? countEditChanges(result) : undefined));
|
|
403
407
|
component = container;
|
|
404
408
|
}
|
|
405
|
-
rememberResult(state, result, options, ctx.isError);
|
|
409
|
+
rememberResult(state, result, options, ctx.isError, theme);
|
|
406
410
|
return component;
|
|
407
411
|
}
|
|
408
412
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-compact-tools",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.2",
|
|
4
4
|
"description": "Compact, expandable tool rows for Pi in its own compact style or Claude Code's, with a silent mode and a GitHub Dark theme.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,14 +24,13 @@
|
|
|
24
24
|
"extensions",
|
|
25
25
|
"themes",
|
|
26
26
|
"examples",
|
|
27
|
-
"scripts",
|
|
28
27
|
"schemas",
|
|
29
28
|
"release-notes.json",
|
|
30
29
|
"README.md",
|
|
31
30
|
"LICENSE"
|
|
32
31
|
],
|
|
33
32
|
"scripts": {
|
|
34
|
-
"test": "tsx --test tests/*.test.ts",
|
|
33
|
+
"test": "tsx --test tests/*.test.ts && tsx --test tests/resources.validation.ts",
|
|
35
34
|
"check": "tsc --noEmit && npm test && npm run check:resources",
|
|
36
35
|
"check:resources": "node scripts/check-resources.mjs",
|
|
37
36
|
"prepublishOnly": "npm run check"
|
|
@@ -50,9 +49,12 @@
|
|
|
50
49
|
"@earendil-works/pi-tui": ">=0.85.1"
|
|
51
50
|
},
|
|
52
51
|
"devDependencies": {
|
|
53
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
54
|
-
"@earendil-works/pi-tui": "0.
|
|
52
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
53
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
54
|
+
"@sinonjs/fake-timers": "15.0.0",
|
|
55
55
|
"@types/node": "^24.0.0",
|
|
56
|
+
"@types/sinonjs__fake-timers": "15.0.1",
|
|
57
|
+
"ajv": "8.20.0",
|
|
56
58
|
"tsx": "^4.23.13",
|
|
57
59
|
"typescript": "^5.9.0"
|
|
58
60
|
},
|
package/release-notes.json
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
+
"0.13.2": [
|
|
3
|
+
"Verified on Pi 1.0.0 while keeping compatibility with Pi 0.85.1 and later",
|
|
4
|
+
"Silent activity keeps animating through retries and follow-up turns until final settlement",
|
|
5
|
+
"Hidden file results defer syntax highlighting until their code is actually shown",
|
|
6
|
+
"Completed tool results repaint correctly when the theme changes",
|
|
7
|
+
"Large file views render without exceeding JavaScript argument limits",
|
|
8
|
+
"Fullscreen viewport anchoring and code-row layout allocate less work",
|
|
9
|
+
"Execution timing history stays bounded even when tool rows are not drawn"
|
|
10
|
+
],
|
|
2
11
|
"0.13.1": [],
|
|
3
12
|
"0.13.0": [
|
|
4
13
|
"Follows Pi 0.99's system theme and light terminals: rails, dots, and the working label take their colors from the theme itself",
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
|
|
3
|
-
const resources = [
|
|
4
|
-
"themes/github-dark-pro.json",
|
|
5
|
-
"examples/compact-tools.json",
|
|
6
|
-
"examples/compact-tools-2.json",
|
|
7
|
-
"schemas/compact-tools.schema.json",
|
|
8
|
-
"release-notes.json",
|
|
9
|
-
];
|
|
10
|
-
|
|
11
|
-
for (const path of resources) {
|
|
12
|
-
const value = JSON.parse(await readFile(new URL(`../${path}`, import.meta.url), "utf8"));
|
|
13
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
14
|
-
throw new Error(`${path} must contain a JSON object`);
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const theme = JSON.parse(
|
|
19
|
-
await readFile(new URL("../themes/github-dark-pro.json", import.meta.url), "utf8"),
|
|
20
|
-
);
|
|
21
|
-
if (theme.name !== "github-dark-pro" || !theme.colors || typeof theme.colors !== "object") {
|
|
22
|
-
throw new Error("themes/github-dark-pro.json is not a valid pi theme document");
|
|
23
|
-
}
|
|
24
|
-
if (!theme.colors.border || !theme.colors.dim || !theme.colors.muted) {
|
|
25
|
-
throw new Error("github-dark-pro must define separator colors");
|
|
26
|
-
}
|
|
27
|
-
if (!theme.vars?.thinkingText || !theme.colors.thinkingMax) {
|
|
28
|
-
throw new Error("github-dark-pro must define thinking text and summary colors");
|
|
29
|
-
}
|
|
30
|
-
if (!theme.colors.mdQuote || !theme.colors.mdCodeBlockBorder) {
|
|
31
|
-
throw new Error("github-dark-pro must define thinking detail and code fence colors");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const example = JSON.parse(
|
|
35
|
-
await readFile(new URL("../examples/compact-tools.json", import.meta.url), "utf8"),
|
|
36
|
-
);
|
|
37
|
-
if (!example.auto_compact || example.auto_compact.edit !== false) {
|
|
38
|
-
throw new Error("example auto_compact policy must keep edit diffs visible by default");
|
|
39
|
-
}
|
|
40
|
-
if (Object.values(example.auto_compact).some((enabled) => typeof enabled !== "boolean")) {
|
|
41
|
-
throw new Error("example auto_compact values must be booleans");
|
|
42
|
-
}
|
|
43
|
-
if (!Number.isInteger(example.previewLines) || example.previewLines < 1 || example.previewLines > 100) {
|
|
44
|
-
throw new Error("example previewLines must be an integer from 1 to 100");
|
|
45
|
-
}
|
|
46
|
-
const alternateExample = JSON.parse(
|
|
47
|
-
await readFile(new URL("../examples/compact-tools-2.json", import.meta.url), "utf8"),
|
|
48
|
-
);
|
|
49
|
-
for (const [name, config] of [["compact-tools", example], ["compact-tools-2", alternateExample]]) {
|
|
50
|
-
if ("durationIndicators" in config) {
|
|
51
|
-
throw new Error(`${name} must not contain the removed durationIndicators setting`);
|
|
52
|
-
}
|
|
53
|
-
if ("spinner" in config) {
|
|
54
|
-
throw new Error(`${name} must not contain the removed spinner setting`);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const schema = JSON.parse(
|
|
59
|
-
await readFile(new URL("../schemas/compact-tools.schema.json", import.meta.url), "utf8"),
|
|
60
|
-
);
|
|
61
|
-
if (schema.properties?.durationIndicators) {
|
|
62
|
-
throw new Error("schema must not expose the removed durationIndicators setting");
|
|
63
|
-
}
|
|
64
|
-
if (schema.properties?.spinner) {
|
|
65
|
-
throw new Error("schema must not expose the removed spinner setting");
|
|
66
|
-
}
|
|
67
|
-
if (!schema.properties?.auto_compact?.properties?.edit) {
|
|
68
|
-
throw new Error("schema must describe per-tool auto_compact policies");
|
|
69
|
-
}
|
|
70
|
-
if (schema.properties?.previewLines?.default !== 10) {
|
|
71
|
-
throw new Error("schema must expose the bounded previewLines setting");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
75
|
-
const releaseNotes = JSON.parse(await readFile(new URL("../release-notes.json", import.meta.url), "utf8"));
|
|
76
|
-
// Every published version needs an entry. An empty list is a quiet release that shows no notice.
|
|
77
|
-
if (!Array.isArray(releaseNotes[manifest.version]) ||
|
|
78
|
-
releaseNotes[manifest.version].some((note) => typeof note !== "string" || !note.trim())) {
|
|
79
|
-
throw new Error(`release-notes.json must have an entry for v${manifest.version} before publishing; use [] for no notice`);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
console.log("Resource checks passed");
|