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 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", () => this.setAnswering(false));
268
- pi.on("agent_end", () => {
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 block = lines.slice(index, end);
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
- block.forEach((line, offset) => {
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
- const highlighted = highlightCodeLines(lines, resolveLanguage(path, shebangLine), theme);
415
- const numberWidth = Math.max(1, ...lines.map((line) => String(line.lineNumber ?? "").length));
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
  }
@@ -50,6 +50,7 @@ export interface RowState {
50
50
  lastResultPreview?: boolean;
51
51
  lastResultError?: boolean;
52
52
  lastResultConfigRevision?: object;
53
+ lastResultPalette?: object;
53
54
  }
54
55
 
55
56
  type BaseRenderContext = Parameters<NonNullable<BuiltInDefinition["renderCall"]>>[2];
@@ -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
- const candidates: number[] = [];
93
- for (let row = top; row < bottom; row++) candidates.push(row);
94
- for (let row = top - 1; row >= 0; row--) candidates.push(row);
95
- for (const row of candidates) {
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(window[0]!) ?? []) {
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.1",
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.99.1",
54
- "@earendil-works/pi-tui": "0.99.1",
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
  },
@@ -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");