pi-compact-tools 0.16.1 → 0.16.5

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.
@@ -1,9 +1,8 @@
1
- import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
1
  import { DEFAULT_CONFIG } from "./compact-tools-config.ts";
2
+ import { onTick } from "./compact-tools-clock.ts";
3
3
  import { isSilent } from "./compact-tools-silent.ts";
4
4
  import {
5
5
  SUPPORTED_TOOL_SET,
6
- type BuiltInDefinition,
7
6
  type CompactToolName,
8
7
  type CompactToolsConfig,
9
8
  type RenderContext,
@@ -27,7 +26,6 @@ export function validDurationMs(value: unknown): value is number {
27
26
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
28
27
  }
29
28
  type Indicator = { invalidate: () => void; drawnAt: number };
30
- type TimedExecute = (...args: any[]) => Promise<AgentToolResult<unknown>>;
31
29
  type SharedState = typeof globalThis & {
32
30
  [EXECUTION_TIMINGS_KEY]?: Map<string, ExecutionTiming>;
33
31
  };
@@ -38,7 +36,7 @@ export class ToolRuntime {
38
36
  private readonly executionTimings: Map<string, ExecutionTiming>;
39
37
  private readonly indicators = new Map<string, Indicator>();
40
38
  private indicatorFrame = 0;
41
- private indicatorTimer: ReturnType<typeof setInterval> | undefined;
39
+ private stopIndicatorTicks: (() => void) | undefined;
42
40
  /** Pi's frame request, once bound to its terminal; rows then repaint without being rebuilt. */
43
41
  private requestRender: (() => void) | undefined;
44
42
  /** Whether the agent is working; undefined until the host says, which counts as working. */
@@ -49,12 +47,6 @@ export class ToolRuntime {
49
47
  * more than one run's calls.
50
48
  */
51
49
  private readonly currentCalls = new Set<string>();
52
- /**
53
- * Calls a tool is making through `ctx.executeTool()`, as codemode scripts do. They
54
- * never get a row of their own, so no execution timing is kept for them.
55
- * The independent nested-summary observer keeps only their outcome counts.
56
- */
57
- private readonly nestedCalls = new Set<string>();
58
50
 
59
51
  constructor(private readonly clock: () => number = () => performance.now()) {
60
52
  const shared = globalThis as SharedState;
@@ -111,7 +103,6 @@ export class ToolRuntime {
111
103
  setBusy(busy: boolean): void {
112
104
  if (busy && this.busy !== true) {
113
105
  this.currentCalls.clear();
114
- this.nestedCalls.clear();
115
106
  }
116
107
  this.busy = busy;
117
108
  if (busy) return;
@@ -129,12 +120,6 @@ export class ToolRuntime {
129
120
  this.currentCalls.add(toolCallId);
130
121
  }
131
122
 
132
- /** A call another tool made began (`running`) or finished; Pi says so with `parentToolCallId`. */
133
- noteNestedCall(toolCallId: string, running: boolean): void {
134
- if (running) this.nestedCalls.add(toolCallId);
135
- else this.nestedCalls.delete(toolCallId);
136
- }
137
-
138
123
  /**
139
124
  * Whether a call can be running: only one Pi announced in the run going on. A
140
125
  * row restored from the session, one left without a result when its run ended,
@@ -158,10 +143,7 @@ export class ToolRuntime {
158
143
  } else {
159
144
  this.indicators.set(toolCallId, { invalidate, drawnAt: this.clock() });
160
145
  }
161
- if (!this.indicatorTimer) {
162
- this.indicatorTimer = setInterval(() => this.tick(), INDICATOR_INTERVAL_MS);
163
- this.indicatorTimer.unref?.();
164
- }
146
+ this.stopIndicatorTicks ??= onTick(() => this.tick(), INDICATOR_INTERVAL_MS);
165
147
  return this.indicatorFrame;
166
148
  }
167
149
 
@@ -211,12 +193,7 @@ export class ToolRuntime {
211
193
  return state;
212
194
  }
213
195
 
214
- /**
215
- * Record when a tool this extension does not wrap starts and ends. Pi reports
216
- * both to extensions before it redraws the row, and the row draws its status
217
- * dot before its result, so without this a finished custom tool kept the
218
- * running dot. Wrapped built-ins already have timings, which are kept.
219
- */
196
+ /** Record top-level execution events without wrapping model-facing tools. */
220
197
  noteExecutionStart(toolCallId: string): void {
221
198
  if (!this.executionTimings.has(toolCallId)) this.executionTimings.set(toolCallId, { startedAt: Date.now() });
222
199
  this.trimTimings();
@@ -230,24 +207,6 @@ export class ToolRuntime {
230
207
  this.trimTimings();
231
208
  }
232
209
 
233
- createTimedExecute(definition: BuiltInDefinition): TimedExecute {
234
- const execute = definition.execute as TimedExecute;
235
- return async (...args: any[]) => {
236
- const toolCallId = typeof args[0] === "string" && !this.nestedCalls.has(args[0]) ? args[0] : undefined;
237
- const startedAt = (toolCallId ? this.executionTimings.get(toolCallId)?.startedAt : undefined) ?? Date.now();
238
- if (toolCallId && !this.executionTimings.has(toolCallId)) {
239
- this.executionTimings.set(toolCallId, { startedAt });
240
- this.trimTimings();
241
- }
242
- try {
243
- return await execute.apply(definition, args);
244
- } finally {
245
- const timing = toolCallId ? this.executionTimings.get(toolCallId) : undefined;
246
- if (timing) timing.endedAt = Date.now();
247
- }
248
- };
249
- }
250
-
251
210
  private tick(): void {
252
211
  this.indicatorFrame++;
253
212
  const requestRender = this.requestRender;
@@ -269,15 +228,15 @@ export class ToolRuntime {
269
228
 
270
229
  private removeIndicator(toolCallId: string): void {
271
230
  this.indicators.delete(toolCallId);
272
- if (this.indicators.size > 0 || !this.indicatorTimer) return;
273
- clearInterval(this.indicatorTimer);
274
- this.indicatorTimer = undefined;
231
+ if (this.indicators.size > 0) return;
232
+ this.stopIndicatorTicks?.();
233
+ this.stopIndicatorTicks = undefined;
275
234
  this.indicatorFrame = 0;
276
235
  }
277
236
 
278
237
  private stopIndicators(): void {
279
- if (this.indicatorTimer) clearInterval(this.indicatorTimer);
280
- this.indicatorTimer = undefined;
238
+ this.stopIndicatorTicks?.();
239
+ this.stopIndicatorTicks = undefined;
281
240
  this.indicators.clear();
282
241
  this.indicatorFrame = 0;
283
242
  }
@@ -311,9 +311,9 @@ export class SilentModeController {
311
311
  return sharedState().enabled;
312
312
  }
313
313
 
314
- bind(ctx: ExtensionContext, mode: DisplayMode, reload: boolean): void {
314
+ bind(ctx: ExtensionContext, mode: DisplayMode, reload: boolean, resume = false): void {
315
315
  this.context = ctx;
316
- this.activity.bind(ctx);
316
+ this.activity.bind(ctx, resume);
317
317
  // An invisible widget is the extension API's way to reach Pi's TUI, and
318
318
  // through it the chat container whose notices silent mode hides.
319
319
  ctx.ui.setWidget(WIDGET_KEY, (tui) => {
@@ -326,13 +326,16 @@ export class SilentModeController {
326
326
  }
327
327
 
328
328
  dispose(): void {
329
- this.activity.dispose();
330
- // A patched Pi prototype outlives this extension instance. Disable hiding
331
- // while no instance is bound, but keep the choice for a subsequent /reload.
332
- sharedState().active = false;
333
- this.context?.ui.setStatus(STATUS_KEY, undefined);
334
- this.context?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
329
+ const context = this.context;
335
330
  this.context = undefined;
331
+ // Detach state before calling host UI: an exception must not reactivate
332
+ // silent hiding or its animation while the extension is off.
333
+ sharedState().active = false;
334
+ try { this.activity.dispose(); }
335
+ finally {
336
+ try { context?.ui.setStatus(STATUS_KEY, undefined); }
337
+ finally { context?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" }); }
338
+ }
336
339
  }
337
340
 
338
341
  private apply(enabled: boolean): boolean {
@@ -201,15 +201,18 @@ function getInitialThinkingPhase(): ThinkingPhase {
201
201
  }
202
202
 
203
203
  export class ThinkingCycleController {
204
+ private context: ExtensionContext | undefined;
204
205
  private phase: ThinkingPhase = "summary";
205
206
  private unsubscribe: (() => void) | undefined;
206
207
  private theme: Theme | undefined;
207
208
  private hasToggleableThinking = false;
208
209
  private refreshThinking: (() => void) | undefined;
209
210
 
210
- constructor(pi: ExtensionAPI) {
211
+ constructor(pi: ExtensionAPI, enabled: () => boolean = () => true) {
211
212
  pi.registerMarkdownTransformer((markdown, context) => {
212
- if (context.messageType !== "assistant-thinking") return markdown;
213
+ // Off also applies before session_start, during transcript reconstruction
214
+ // and HTML exports. Do not summarize or restyle native thinking there.
215
+ if (!enabled() || context.messageType !== "assistant-thinking") return markdown;
213
216
  const sections = splitThinkingSections(markdown);
214
217
  this.hasToggleableThinking ||= sectionsHaveDetail(sections);
215
218
  const theme = this.theme;
@@ -241,6 +244,7 @@ export class ThinkingCycleController {
241
244
 
242
245
  bind(ctx: ExtensionContext): void {
243
246
  this.unsubscribe?.();
247
+ this.context = ctx;
244
248
  this.theme = ctx.ui.theme;
245
249
  this.hasToggleableThinking = false;
246
250
  // Match Pi's persisted host visibility after startup or /reload. When the
@@ -277,11 +281,15 @@ export class ThinkingCycleController {
277
281
  });
278
282
  }
279
283
 
280
- dispose(): void {
281
- this.unsubscribe?.();
284
+ dispose(restoreNative = false): void {
285
+ const context = this.context;
286
+ const unsubscribe = this.unsubscribe;
287
+ this.context = undefined;
282
288
  this.unsubscribe = undefined;
283
289
  this.refreshThinking = undefined;
284
290
  this.hasToggleableThinking = false;
285
291
  this.theme = undefined;
292
+ try { unsubscribe?.(); }
293
+ finally { if (restoreNative) context?.ui.setHiddenThinkingLabel(); }
286
294
  }
287
295
  }
@@ -0,0 +1,93 @@
1
+ import { ToolExecutionComponent, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import type { TUI } from "@earendil-works/pi-tui";
3
+ import type { RowRenderers, ToolRow } from "./compact-tools-custom.ts";
4
+ import { refreshRowShell, syncNativeToolTimer } from "./compact-tools-rendering.ts";
5
+ import type { ToolRuntime } from "./compact-tools-runtime.ts";
6
+ import { findChat, isKind } from "./compact-tools-silent.ts";
7
+
8
+ type Definition = ToolDefinition<any, any, any> | undefined;
9
+ export interface UIFailure { operation: string; error: unknown }
10
+
11
+ /** Attempt every independent cleanup/row action; the caller reports failures once. */
12
+ export function attemptUI(failures: UIFailure[], operation: string, action: () => void): void {
13
+ try { action(); } catch (error) { failures.push({ operation, error }); }
14
+ }
15
+
16
+ interface TranscriptOptions {
17
+ runtime: ToolRuntime;
18
+ publicRendering: boolean;
19
+ resolve(row: ToolRow): RowRenderers | undefined;
20
+ definition(name: string, base: Definition): Definition;
21
+ unwrap(definition: Definition): Definition;
22
+ }
23
+
24
+ const WIDGET_KEY = "compact-tools-render";
25
+ const emptyWidget = () => ({ render: () => [], invalidate() {} });
26
+
27
+ /** Own the TUI probe and the private host-row reconciliation boundary together. */
28
+ export function createTranscriptRenderer(options: TranscriptOptions) {
29
+ let context: ExtensionContext | undefined;
30
+ let refresh: ((failures: UIFailure[]) => void) | undefined;
31
+
32
+ const reconcile = (tui: TUI, failures: UIFailure[]) => {
33
+ const visit = (parent: { children?: unknown[] }) => {
34
+ for (const child of parent.children ?? []) {
35
+ if (!child || typeof child !== "object") continue;
36
+ attemptUI(failures, "tool row", () => {
37
+ if (!isKind(child, ToolExecutionComponent)) { visit(child); return; }
38
+ const row = child as ToolRow & { toolCallId: string; updateDisplay?(): void; callRendererComponent?: unknown; resultRendererComponent?: unknown };
39
+ const base = options.unwrap(row.toolDefinition);
40
+ syncNativeToolTimer(row, row.toolName, base, !options.resolve({ toolName: row.toolName, toolDefinition: base }), options.runtime.timing(row.toolCallId));
41
+ if (options.publicRendering) {
42
+ row.toolDefinition = options.definition(row.toolName, base);
43
+ // Components must not cross native/compact renderer ownership,
44
+ // even when both definitions request the same shell.
45
+ row.callRendererComponent = undefined;
46
+ row.resultRendererComponent = undefined;
47
+ }
48
+ refreshRowShell(row);
49
+ row.updateDisplay?.();
50
+ });
51
+ }
52
+ };
53
+ attemptUI(failures, "transcript", () => {
54
+ const chat = findChat(tui);
55
+ if (chat && chat.children.length > 0) {
56
+ visit(chat);
57
+ attemptUI(failures, "repaint", () => tui.requestRender());
58
+ }
59
+ });
60
+ };
61
+
62
+ return {
63
+ bind(ctx: ExtensionContext, failures: UIFailure[]): void {
64
+ context = ctx;
65
+ attemptUI(failures, "renderer probe", () => ctx.ui.setWidget(WIDGET_KEY, (tui) => {
66
+ options.runtime.bindRenderer(() => tui.requestRender());
67
+ refresh = (errors) => reconcile(tui, errors);
68
+ return emptyWidget();
69
+ }, { placement: "belowEditor" }));
70
+ refresh?.(failures);
71
+ },
72
+ refresh(failures: UIFailure[]): void { refresh?.(failures); },
73
+ detach(): void {
74
+ const previous = context;
75
+ context = undefined;
76
+ refresh = undefined;
77
+ options.runtime.bindRenderer(undefined);
78
+ previous?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
79
+ },
80
+ /** Reconcile provisional startup rows without binding runtime animation. */
81
+ reconcileOnce(ctx: ExtensionContext, failures: UIFailure[]): void {
82
+ if (refresh) { refresh(failures); return; }
83
+ try {
84
+ attemptUI(failures, "startup renderer probe", () => ctx.ui.setWidget(WIDGET_KEY, (tui) => {
85
+ reconcile(tui, failures);
86
+ return emptyWidget();
87
+ }, { placement: "belowEditor" }));
88
+ } finally {
89
+ attemptUI(failures, "startup renderer cleanup", () => ctx.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" }));
90
+ }
91
+ },
92
+ };
93
+ }
@@ -221,12 +221,14 @@ export class ViewportKeeper {
221
221
  }
222
222
 
223
223
  dispose(): void {
224
- this.unsubscribe?.();
224
+ const context = this.context;
225
+ const unsubscribe = this.unsubscribe;
226
+ this.context = undefined;
225
227
  this.unsubscribe = undefined;
226
228
  const state = sharedState();
227
229
  state.pending = undefined;
228
230
  state.tui = undefined;
229
- this.context?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
230
- this.context = undefined;
231
+ try { unsubscribe?.(); }
232
+ finally { context?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" }); }
231
233
  }
232
234
  }
@@ -7,12 +7,11 @@ import type {
7
7
  ToolDefinition,
8
8
  ToolRenderResultOptions,
9
9
  } from "@earendil-works/pi-coding-agent";
10
- import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
11
10
  import type { Component } from "@earendil-works/pi-tui";
12
11
  import { classifyCallStatus, formatDurationMs, normalizeLineEndings, type RowStatus } from "./compact-tools-core.ts";
13
12
  import { loadConfig } from "./compact-tools-config.ts";
14
13
  import { paletteOf } from "./compact-tools-color.ts";
15
- import { hasAuthorRenderers, refreshRowShell } from "./compact-tools-rendering.ts";
14
+ import { hasAuthorRenderers, loadNativeTimerRenderers, syncNativeToolTimer } from "./compact-tools-rendering.ts";
16
15
  import { registerCompactToolsCommand } from "./compact-tools-command.ts";
17
16
  import { clearRowResolver, installToolRowPatch, setRowResolver, type RowRenderers, type ToolRow } from "./compact-tools-custom.ts";
18
17
  import {
@@ -23,6 +22,7 @@ import {
23
22
  getEditDiff,
24
23
  getEditPatch,
25
24
  getFileOutput,
25
+ readStartLine,
26
26
  getTextResult,
27
27
  isReadTextResult,
28
28
  splitReadFooter,
@@ -56,7 +56,8 @@ import { chromePainter, completionPainter, paintIndicator } from "./compact-tool
56
56
  import { ProgressController } from "./compact-tools-progress.ts";
57
57
  import { showReleaseNotice } from "./compact-tools-release.ts";
58
58
  import { ToolRuntime } from "./compact-tools-runtime.ts";
59
- import { findChat, isKind, SilentModeController } from "./compact-tools-silent.ts";
59
+ import { SilentModeController } from "./compact-tools-silent.ts";
60
+ import { attemptUI, createTranscriptRenderer, type UIFailure } from "./compact-tools-transcript.ts";
60
61
  import { ThinkingCycleController } from "./compact-tools-thinking.ts";
61
62
  import { ViewportKeeper } from "./compact-tools-viewport.ts";
62
63
  import {
@@ -72,7 +73,6 @@ const PREVIEW_DIFF_CONTEXT_LINES = 1;
72
73
 
73
74
  const runtime = new ToolRuntime();
74
75
  const nestedSummaries = new NestedCallSummaries();
75
- const RENDER_WIDGET_KEY = "compact-tools-render";
76
76
  let registeredConfiguration: string | undefined;
77
77
  /**
78
78
  * Whether the last session found its directory trusted. A /reload rebuilds the
@@ -278,7 +278,7 @@ function renderFileBody(
278
278
  }
279
279
  if (name === "read" && isReadTextResult(result)) {
280
280
  const { body, footer } = splitReadFooter(output);
281
- const startLine = typeof args.offset === "number" ? args.offset : 1;
281
+ const startLine = readStartLine(args.offset);
282
282
  return renderCodeView(body, path, theme, { startLine, footer, rail }) ?? fallback();
283
283
  }
284
284
  if (name === "write") return renderCodeView(output, path, theme, { rail }) ?? fallback();
@@ -509,9 +509,15 @@ function createCustomRenderers(name: string, author: ToolDefinition<any, any, an
509
509
  // Compact owns the outer margin and the result rail. An author
510
510
  // renderer is embedded content, not another host-padded shell.
511
511
  // Leave the field absent on legacy hosts.
512
- value.component = authorRenderer(result, { ...options, expanded }, theme, {
513
- ...ctx, lastComponent: value.component, ...((ctx as { outputPad?: number }).outputPad === undefined ? {} : { outputPad: 0 }),
514
- });
512
+ try {
513
+ value.component = authorRenderer(result, { ...options, expanded }, theme, {
514
+ ...ctx, lastComponent: value.component, ...((ctx as { outputPad?: number }).outputPad === undefined ? {} : { outputPad: 0 }),
515
+ });
516
+ } finally {
517
+ // Compact owns animation; a delegated native codemode preview
518
+ // must not leave a second clock when later collapsed.
519
+ syncNativeToolTimer({ rendererState: ctx.state }, name, author, false);
520
+ }
515
521
  return value.component;
516
522
  }
517
523
  : undefined;
@@ -552,6 +558,7 @@ type RendererAPI = ExtensionAPI & {
552
558
  };
553
559
  let publicRendering = false;
554
560
  let sessionReady = false;
561
+ let publicBases = new WeakMap<object, ToolDefinition<any, any, any> | undefined>();
555
562
  let publicOwners = new WeakMap<object, { call?: unknown; result?: unknown }>();
556
563
  function publicRowDefinition(name: string, base: ToolDefinition<any, any, any> | undefined): ToolDefinition<any, any, any> | undefined {
557
564
  // Preserve next()'s result exactly once the session's configuration is known.
@@ -580,46 +587,47 @@ function publicRowDefinition(name: string, base: ToolDefinition<any, any, any> |
580
587
  const render = decide()?.renderResult ?? base?.renderResult;
581
588
  return render!(value, options, theme, context(ctx, "result", render));
582
589
  };
583
- return {
590
+ const definition = {
584
591
  get renderShell() { return decide() ? "self" : base?.renderShell ?? "default"; },
585
592
  get renderCall() { return decide()?.renderCall || base?.renderCall ? call : undefined; },
586
593
  get renderResult() { return decide()?.renderResult || base?.renderResult ? result : undefined; },
587
594
  } as ToolDefinition<any, any, any>;
595
+ publicBases.set(definition, base);
596
+ return definition;
588
597
  }
589
598
  let legacyResolver: ((row: ToolRow) => RowRenderers | undefined) | undefined;
590
- let refreshTranscriptRows: (() => void) | undefined;
591
- function invalidateRowDecisions(): void {
599
+ let refreshTranscriptRows: ((failures: UIFailure[]) => void) | undefined;
600
+ function invalidateRowDecisions(failures: UIFailure[]): void {
592
601
  if (!publicRendering) {
593
602
  legacyResolver = (row) => resolveCustomRow(row);
594
603
  setRowResolver(legacyResolver);
595
604
  }
596
- refreshTranscriptRows?.();
605
+ refreshTranscriptRows?.(failures);
597
606
  }
598
607
 
599
- function configure(_pi: ExtensionAPI, cwd?: string, projectTrusted = false): void {
608
+ function configure(_pi: ExtensionAPI, cwd?: string, projectTrusted = false, failures: UIFailure[] = []): void {
600
609
  const resolvedCwd = cwd ?? process.cwd();
601
610
  const config = loadConfig(resolvedCwd, projectTrusted);
602
611
  const signature = JSON.stringify([resolvedCwd, config]);
603
612
  if (signature === registeredConfiguration) return;
604
613
  runtime.configure(config);
605
614
  registeredConfiguration = signature;
606
- invalidateRowDecisions();
615
+ invalidateRowDecisions(failures);
607
616
  }
608
617
 
609
618
  /**
610
619
  * Apply a style saved by /compact-tools without reloading Pi. The compact, Claude, and
611
620
  * Codex styles share every tool registration and read the style as they draw, so the
612
621
  * configuration is swapped and each tool row on screen is drawn again, a running one
613
- * included. Anything else that changed, or a switch to or from off, needs a reload.
622
+ * included. Only non-style configuration changes require a reload.
614
623
  */
615
- function applyStyle(cwd: string, trusted: boolean): boolean {
624
+ function applyStyle(cwd: string, trusted: boolean, failures: UIFailure[]): boolean {
616
625
  const config = loadConfig(cwd, trusted);
617
626
  const current = runtime.config;
618
- if (config.style === "off" || current.style === "off") return false;
619
627
  if (JSON.stringify({ ...config, style: current.style }) !== JSON.stringify(current)) return false;
620
628
  runtime.configure(config);
621
629
  registeredConfiguration = JSON.stringify([cwd, config]);
622
- invalidateRowDecisions();
630
+ invalidateRowDecisions(failures);
623
631
  return true;
624
632
  }
625
633
 
@@ -643,44 +651,15 @@ function madeByTool(event: object): boolean {
643
651
  return typeof (event as { parentToolCallId?: unknown }).parentToolCallId === "string";
644
652
  }
645
653
 
646
- /** Hand the animation Pi's frame request, reached through an invisible widget as the extension API allows. */
647
- function bindRenderer(ctx: ExtensionContext): void {
648
- ctx.ui.setWidget(RENDER_WIDGET_KEY, (tui) => {
649
- runtime.bindRenderer(() => tui.requestRender());
650
- refreshTranscriptRows = () => {
651
- const visit = (parent: { children?: unknown[] }) => {
652
- for (const child of parent.children ?? []) {
653
- if (!child || typeof child !== "object") continue;
654
- if (isKind(child, ToolExecutionComponent)) {
655
- const row = child as ToolRow & { updateDisplay?(): void };
656
- refreshRowShell(row);
657
- row.updateDisplay?.();
658
- } else visit(child as { children?: unknown[] });
659
- }
660
- };
661
- const chat = findChat(tui);
662
- if (chat && chat.children.length > 0) { visit(chat); tui.requestRender(); }
663
- };
664
- return { render: () => [], invalidate() {} };
665
- }, { placement: "belowEditor" });
666
- refreshTranscriptRows?.();
667
- }
668
-
669
- function unbindRenderer(ctx: ExtensionContext | undefined): void {
670
- refreshTranscriptRows = undefined;
671
- runtime.bindRenderer(undefined);
672
- ctx?.ui.setWidget(RENDER_WIDGET_KEY, undefined, { placement: "belowEditor" });
673
- }
674
-
675
654
  export default function compactTools(pi: ExtensionAPI): void {
676
655
  // The cached factory is invoked again after session replacement and reload.
677
656
  // Reset configuration memoization, but never change the host's tool registry.
678
657
  registeredConfiguration = undefined;
658
+ refreshTranscriptRows = undefined;
679
659
  sessionReady = false;
680
660
  publicOwners = new WeakMap();
681
-
682
- registerCompactToolsCommand(pi, () => runtime.config, applyStyle);
683
- const thinkingCycle = new ThinkingCycleController(pi);
661
+ publicBases = new WeakMap();
662
+ const thinkingCycle = new ThinkingCycleController(pi, () => runtime.config.style !== "off");
684
663
  const progress = new ProgressController(pi);
685
664
  const viewport = new ViewportKeeper();
686
665
  const silent = new SilentModeController(pi, showReleaseNotice);
@@ -696,15 +675,66 @@ export default function compactTools(pi: ExtensionAPI): void {
696
675
  const base = next();
697
676
  return publicRowDefinition(name, base as ToolDefinition<any, any, any> | undefined);
698
677
  });
699
- } else invalidateRowDecisions();
678
+ } else invalidateRowDecisions([]);
679
+ const transcript = createTranscriptRenderer({
680
+ runtime, publicRendering, resolve: resolveCustomRow, definition: publicRowDefinition,
681
+ unwrap: (current) => current && publicBases.has(current) ? publicBases.get(current) : current,
682
+ });
683
+ refreshTranscriptRows = transcript.refresh;
700
684
  let customRowsWarned = false;
701
- let renderContext: ExtensionContext | undefined;
685
+ let sessionContext: ExtensionContext | undefined;
686
+ const reportFailures = (ctx: ExtensionContext | undefined, failures: UIFailure[]) => {
687
+ if (ctx && failures.length > 0) {
688
+ const operations = [...new Set(failures.map(failure => failure.operation))].join(", ");
689
+ const message = `Compact tools: UI refresh/cleanup failed (${operations}). The selected style is saved; affected UI may need /reload.`;
690
+ try { ctx.ui.notify(message, "warning"); }
691
+ catch (error) { console.warn(message, "UI notification also failed:", error); }
692
+ }
693
+ };
694
+ const enableUI = (ctx: ExtensionContext, reload: boolean, resume: boolean, failures: UIFailure[]) => {
695
+ // Observe relayout keys before the thinking controller consumes them.
696
+ attemptUI(failures, "viewport bind", () => viewport.bind(ctx));
697
+ attemptUI(failures, "progress bind", () => progress.bind(ctx, resume));
698
+ attemptUI(failures, "thinking bind", () => thinkingCycle.bind(ctx));
699
+ attemptUI(failures, "silent bind", () => silent.bind(ctx, runtime.config.mode, reload, resume));
700
+ attemptUI(failures, "groups bind", () => groups.bind(ctx));
701
+ transcript.bind(ctx, failures);
702
+ };
703
+ // One ownership/cleanup inventory for live off, session replacement and shutdown.
704
+ // Each action is independent: a host failure must not skip later cleanup steps.
705
+ const detachUI = (options: { preservePhase?: boolean; restoreNative?: boolean; clearTimings?: boolean }, failures: UIFailure[]) => {
706
+ attemptUI(failures, "thinking cleanup", () => thinkingCycle.dispose(options.restoreNative));
707
+ attemptUI(failures, "progress cleanup", () => progress.dispose(options.preservePhase));
708
+ attemptUI(failures, "silent cleanup", () => silent.dispose());
709
+ attemptUI(failures, "groups cleanup", () => groups.dispose());
710
+ attemptUI(failures, "viewport cleanup", () => viewport.dispose());
711
+ attemptUI(failures, "animation cleanup", () => runtime.reset(options.clearTimings === true));
712
+ attemptUI(failures, "renderer cleanup", () => transcript.detach());
713
+ };
714
+ registerCompactToolsCommand(pi, () => runtime.config, (cwd, trusted, ctx) => {
715
+ const failures: UIFailure[] = [];
716
+ const wasOff = runtime.config.style === "off";
717
+ if (!applyStyle(cwd, trusted, failures)) return false;
718
+ const uiContext = sessionContext ?? ctx;
719
+ if (uiContext.mode === "tui") {
720
+ if (runtime.config.style === "off") detachUI({ preservePhase: true, restoreNative: true }, failures);
721
+ else if (wasOff) enableUI(uiContext, true, true, failures);
722
+ // Off->off has no attached thinking context to restore the native label.
723
+ if (wasOff && runtime.config.style === "off") attemptUI(failures, "native thinking", () => uiContext.ui.setHiddenThinkingLabel());
724
+ }
725
+ reportFailures(uiContext, failures);
726
+ return true;
727
+ });
702
728
  // Configure UI routing before Pi rebuilds transcript rows. Tool declarations
703
729
  // and the active loadout must remain exactly as the host selected them.
704
730
  const cwd = process.cwd();
705
731
  const lastTrust = (globalThis as TrustSlot)[TRUST_KEY];
706
732
  configure(pi, cwd, lastTrust?.cwd === realDirectory(cwd) && lastTrust.trusted);
733
+ pi.on("session_start", () => loadNativeTimerRenderers());
707
734
  pi.on("session_start", (event, ctx) => {
735
+ const failures: UIFailure[] = [];
736
+ detachUI({ restoreNative: true }, failures);
737
+ sessionContext = ctx;
708
738
  if (event.reason !== "reload") {
709
739
  runtime.clearTimings();
710
740
  nestedSummaries.clear();
@@ -715,33 +745,21 @@ export default function compactTools(pi: ExtensionAPI): void {
715
745
  if (typeof ctx.isIdle === "function") runtime.setBusy(!ctx.isIdle());
716
746
  const trusted = ctx.isProjectTrusted();
717
747
  (globalThis as TrustSlot)[TRUST_KEY] = { cwd: realDirectory(ctx.cwd), trusted };
718
- configure(pi, ctx.cwd, trusted);
748
+ configure(pi, ctx.cwd, trusted, failures);
719
749
  sessionReady = true;
720
- unbindRenderer(renderContext);
721
- renderContext = undefined;
722
- // Even off must reconcile rows built before trusted config was available.
723
- if (ctx.mode === "tui") bindRenderer(ctx);
724
750
  if (ctx.mode === "tui" && runtime.config.style !== "off") {
725
- renderContext = ctx;
726
- // First, so its input listener sees relayout keys before the thinking controller consumes them.
727
- viewport.bind(ctx);
728
- progress.bind(ctx);
729
- thinkingCycle.bind(ctx);
730
- silent.bind(ctx, runtime.config.mode, event.reason === "reload");
731
- groups.bind(ctx);
751
+ enableUI(ctx, event.reason === "reload", false, failures);
732
752
  if (!silent.isEnabled()) showReleaseNotice(ctx);
733
753
  if ((runtime.config.custom_tools.enabled || runtime.config.tools.length > 0) && !customRowsAvailable && !customRowsWarned) {
734
754
  customRowsWarned = true;
735
755
  ctx.ui.notify("Compact tool rendering is unavailable in this version of Pi", "warning");
736
756
  }
737
- } else {
738
- if (ctx.mode === "tui") unbindRenderer(ctx);
739
- viewport.dispose();
740
- progress.dispose();
741
- thinkingCycle.dispose();
742
- silent.dispose();
743
- groups.dispose();
757
+ } else if (ctx.mode === "tui") {
758
+ // Startup-off also reconciles rows built using provisional/trusted config,
759
+ // but never attaches animation or the compact controllers.
760
+ transcript.reconcileOnce(ctx, failures);
744
761
  }
762
+ reportFailures(ctx, failures);
745
763
  });
746
764
  // /tree changes the branch without session_start. Fill sibling-branch records
747
765
  // before Pi rebuilds its transcript, while retaining richer same-process deltas.
@@ -761,7 +779,6 @@ export default function compactTools(pi: ExtensionAPI): void {
761
779
  });
762
780
  pi.on("tool_execution_start", (event) => {
763
781
  if (madeByTool(event)) {
764
- runtime.noteNestedCall(event.toolCallId, true);
765
782
  nestedSummaries.note(event, false);
766
783
  return;
767
784
  }
@@ -771,7 +788,6 @@ export default function compactTools(pi: ExtensionAPI): void {
771
788
  });
772
789
  pi.on("tool_execution_end", (event) => {
773
790
  if (madeByTool(event)) {
774
- runtime.noteNestedCall(event.toolCallId, false);
775
791
  if (nestedSummaries.note(event, true)) runtime.repaint();
776
792
  return;
777
793
  }
@@ -779,14 +795,10 @@ export default function compactTools(pi: ExtensionAPI): void {
779
795
  runtime.noteExecutionEnd(event.toolCallId, (event as { durationMs?: number }).durationMs);
780
796
  });
781
797
  pi.on("session_shutdown", (event) => {
782
- thinkingCycle.dispose();
783
- progress.dispose();
784
- silent.dispose();
785
- groups.dispose();
786
- viewport.dispose();
787
- unbindRenderer(renderContext);
788
- renderContext = undefined;
789
- runtime.reset(event.reason !== "reload");
798
+ const failures: UIFailure[] = [];
799
+ detachUI({ clearTimings: event.reason !== "reload" }, failures);
800
+ reportFailures(sessionContext, failures);
801
+ sessionContext = undefined;
790
802
  if (event.reason !== "reload") nestedSummaries.clear();
791
803
  // A reload that keeps the extension sets it again before Pi rebuilds the transcript.
792
804
  if (legacyResolver) clearRowResolver(legacyResolver);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-compact-tools",
3
- "version": "0.16.1",
3
+ "version": "0.16.5",
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.2.0",
55
- "@earendil-works/pi-tui": "1.2.0",
54
+ "@earendil-works/pi-coding-agent": "1.3.0",
55
+ "@earendil-works/pi-tui": "1.3.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",