pi-compact-tools 0.16.2 → 0.16.6

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
@@ -23,8 +23,9 @@ Requires Node.js 22.19+ and Pi 0.85.1+. **Pi 1.0.2+ recommended** for native ren
23
23
  | **Claude** | Grouped reads/searches, `⎿` results, edit previews | `/compact-tools claude` |
24
24
  | **Codex** | `• Explored` groups, `• Ran` output, highlighted diffs | `/compact-tools codex` |
25
25
  | **Compact** (default) | One row per call: status, duration, line count, and diff | `/compact-tools compact` |
26
+ | **Off / Pi native** | Pi's original rows, thinking display, and working indicator | `/compact-tools off` |
26
27
 
27
- Switching redraws existing rows, even while tools run. Only read-only exploration is grouped; commands that write or execute work keep separate rows.
28
+ Every style, including **off**, redraws existing rows immediately, even while tools run. No reload or interruption is needed. Only read-only exploration is grouped; commands that write or execute work keep separate rows.
28
29
 
29
30
  ### Claude
30
31
 
@@ -69,10 +70,10 @@ The two failed edits are intentional. Reload/resume preserves summaries from nes
69
70
  /compact-tools claude project # save for this trusted project
70
71
  /compact-tools codex global # save globally
71
72
  /compact-tools status # effective settings and paths
72
- /compact-tools off # restore Pi's native rows (reloads Pi)
73
+ /compact-tools off # restore Pi's native UI live, even while streaming
73
74
  ```
74
75
 
75
- Without a scope, changes are saved where the style is configured: project or global. A trusted project's own style overrides a global save. Turning rows off or back on waits for the current response to finish and reloads Pi.
76
+ Without a scope, changes are saved where the style is configured: project or global. A trusted project's own style overrides a global save. `/compact-tools off` immediately restores native tool rows, unmodified thinking, Pi's working indicator and keyboard behavior, with no grouping or silent hiding—even while a response or tool is running. No reload, abort, or restart occurs. Your selected theme, tool execution, and session history are not changed. Use `/compact-tools compact`, `claude`, or `codex` to enable it again, also live. Your silent-mode choice is retained for when you re-enable a style; `/silent` while off only updates that saved choice, without hiding native UI.
76
77
 
77
78
  - Click a row/group or press `Ctrl+O` to expand/collapse.
78
79
  - `Ctrl+T` changes the thinking display, not the model's reasoning effort.
@@ -251,7 +251,6 @@ export class SilentActivityAnimator {
251
251
 
252
252
  constructor(private readonly pi: ExtensionAPI) {
253
253
  pi.on("agent_start", () => {
254
- if (!this.context) return;
255
254
  this.running = true;
256
255
  this.answering = false;
257
256
  this.refresh();
@@ -265,7 +264,6 @@ export class SilentActivityAnimator {
265
264
  });
266
265
  pi.on("tool_execution_start", () => this.setAnswering(false));
267
266
  pi.on("turn_start", () => {
268
- if (!this.context) return;
269
267
  this.running = true;
270
268
  this.answering = false;
271
269
  this.refresh();
@@ -274,17 +272,16 @@ export class SilentActivityAnimator {
274
272
  // Keep the shared clock alive until Pi declares final settlement, as the
275
273
  // working-label controller already does.
276
274
  pi.on("agent_settled", () => {
277
- if (!this.context) return;
278
275
  this.running = false;
279
276
  this.refresh();
280
277
  });
281
278
  }
282
279
 
283
- bind(ctx: ExtensionContext): void {
280
+ bind(ctx: ExtensionContext, resume = false): void {
284
281
  this.hide();
285
282
  this.context = ctx;
286
283
  this.running = ctx.isIdle?.() === false;
287
- this.answering = false;
284
+ if (!resume) this.answering = false;
288
285
  }
289
286
 
290
287
  setEnabled(enabled: boolean): void {
@@ -293,10 +290,12 @@ export class SilentActivityAnimator {
293
290
  }
294
291
 
295
292
  dispose(): void {
296
- this.hide();
297
- this.context = undefined;
298
- this.running = false;
299
- this.enabled = false;
293
+ try { this.hide(); }
294
+ finally {
295
+ this.context = undefined;
296
+ this.running = false;
297
+ this.enabled = false;
298
+ }
300
299
  }
301
300
 
302
301
  /** Guarded like the progress label: a host with a partial API keeps the default color. */
@@ -305,7 +304,7 @@ export class SilentActivityAnimator {
305
304
  }
306
305
 
307
306
  private setAnswering(answering: boolean): void {
308
- if (!this.context || this.answering === answering) return;
307
+ if (this.answering === answering) return;
309
308
  this.answering = answering;
310
309
  this.refresh();
311
310
  }
@@ -1,29 +1,46 @@
1
1
  /**
2
- * One shared animation clock. Every animation tick asks Pi to lay out and repaint
3
- * the whole screen, so the working label and the silent-mode line tick together:
4
- * both change inside the same callback, and Pi coalesces their requests into one
5
- * frame instead of two unsynchronized timers each forcing frames of their own.
2
+ * One clock for tool indicators, the working label, and silent activity. The
3
+ * fastest subscriber sets its cadence; slower animations share those frames,
4
+ * keeping their average period with a remainder instead of repainting between
5
+ * indicator frames. With no indicators it returns to the original 80ms cadence.
6
6
  */
7
7
  export const TICK_MS = 80;
8
8
 
9
- type Listener = () => void;
10
-
11
- const listeners = new Set<Listener>();
9
+ type Subscription = { listener: () => void; period: number; elapsed: number };
10
+ const listeners = new Set<Subscription>();
12
11
  let timer: ReturnType<typeof setInterval> | undefined;
12
+ let cadence = 0;
13
+
14
+ function reschedule(): void {
15
+ const next = listeners.size ? Math.min(...[...listeners].map(({ period }) => period)) : 0;
16
+ if (next === cadence) return;
17
+ if (timer) clearInterval(timer);
18
+ timer = undefined;
19
+ cadence = next;
20
+ if (!next) return;
21
+ timer = setInterval(() => {
22
+ // Capture the elapsed step: a subscriber may change the cadence or unsubscribe
23
+ // another callback while this dispatch is in progress.
24
+ const step = cadence;
25
+ for (const entry of [...listeners]) {
26
+ if (!listeners.has(entry)) continue;
27
+ entry.elapsed += step;
28
+ if (entry.elapsed < entry.period) continue;
29
+ entry.elapsed %= entry.period;
30
+ entry.listener();
31
+ }
32
+ }, next);
33
+ timer.unref?.();
34
+ }
13
35
 
14
- /** Run `listener` on every tick until the returned function is called. The clock stops when idle. */
15
- export function onTick(listener: Listener): () => void {
16
- listeners.add(listener);
17
- if (!timer) {
18
- timer = setInterval(() => {
19
- for (const tick of [...listeners]) tick();
20
- }, TICK_MS);
21
- timer.unref?.();
22
- }
36
+ /** Subscribe until disposed. No timer survives the last subscriber. */
37
+ export function onTick(listener: () => void, period = TICK_MS): () => void {
38
+ if (!Number.isFinite(period) || period <= 0) throw new RangeError("Animation period must be positive and finite");
39
+ const entry: Subscription = { listener, period, elapsed: 0 };
40
+ listeners.add(entry);
41
+ reschedule();
23
42
  return () => {
24
- listeners.delete(listener);
25
- if (listeners.size > 0 || !timer) return;
26
- clearInterval(timer);
27
- timer = undefined;
43
+ listeners.delete(entry);
44
+ reschedule();
28
45
  };
29
46
  }
@@ -1,19 +1,19 @@
1
1
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { dirname, join } from "node:path";
4
- import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { DISPLAY_STYLES, type CompactToolsConfig, type DisplayStyle } from "./compact-tools-types.ts";
6
6
  import { loadConfig } from "./compact-tools-config.ts";
7
7
 
8
8
  type Scope = "global" | "project";
9
9
  const SCOPES: readonly Scope[] = ["global", "project"];
10
- const HELP = "Usage: /compact-tools [compact|claude|codex|off] [global|project]\nWithout a scope the style is saved globally, or to this project when its settings choose the style.\n/compact-tools status · /compact-tools help\nCtrl+O expands rows; /silent toggles quiet mode. Styles switch at once, even while tools run; off reloads Pi.";
10
+ const HELP = "Usage: /compact-tools [compact|claude|codex|off] [global|project]\nWithout a scope the style is saved globally, or to this project when its settings choose the style.\n/compact-tools status · /compact-tools help\nCtrl+O expands rows; /silent toggles quiet mode. All styles, including off, switch at once, even while tools run.";
11
11
  const BUSY = "Wait for the current response to finish before changing tool styles.";
12
12
  const CHOICES: Record<DisplayStyle, string> = {
13
13
  compact: "Compact — one expandable row per tool call",
14
14
  claude: "Claude — group reads and searches",
15
15
  codex: "Codex — explored groups and output tails, like Codex CLI",
16
- off: "Off — restore Pi's default tool rows",
16
+ off: "Off — restore Pi's native UI",
17
17
  };
18
18
 
19
19
  export function configPath(cwd: string, scope: Scope): string {
@@ -78,15 +78,10 @@ function errorMessage(error: unknown): string {
78
78
 
79
79
  /**
80
80
  * Draw the saved style in place, without a reload, and say whether it could: a switch
81
- * between the compact, Claude, and Codex styles keeps every tool's registration, so
82
- * it applies at once, even while a response runs. Turning rows off or back on cannot.
81
+ * between any style, including native/off, changes only UI routing. It applies
82
+ * at once while a response runs, with no tool registry or transcript changes.
83
83
  */
84
- export type ApplyStyle = (cwd: string, trusted: boolean) => boolean;
85
-
86
- /** Whether a switch needs Pi to reload: only off hands tools to or takes them from Pi's own renderers. */
87
- function needsReload(from: DisplayStyle, to: DisplayStyle): boolean {
88
- return from === "off" || to === "off";
89
- }
84
+ export type ApplyStyle = (cwd: string, trusted: boolean, ctx: ExtensionContext) => boolean;
90
85
 
91
86
  export function registerCompactToolsCommand(pi: ExtensionAPI, getConfig: () => CompactToolsConfig, applyStyle?: ApplyStyle): void {
92
87
  pi.registerCommand("compact-tools", {
@@ -122,9 +117,9 @@ export function registerCompactToolsCommand(pi: ExtensionAPI, getConfig: () => C
122
117
  ctx.ui.notify(HELP, "warning");
123
118
  return;
124
119
  }
125
- // A switch between drawn styles applies in place, so only one that reloads waits for the response.
120
+ // Only a host/integration without live UI routing requires an idle reload.
126
121
  const named = parts[0] as DisplayStyle | undefined;
127
- if (named && !ctx.isIdle() && (needsReload(getConfig().style, named) || !applyStyle)) {
122
+ if (named && !ctx.isIdle() && !applyStyle) {
128
123
  ctx.ui.notify(BUSY, "warning");
129
124
  return;
130
125
  }
@@ -155,7 +150,7 @@ export function registerCompactToolsCommand(pi: ExtensionAPI, getConfig: () => C
155
150
  return;
156
151
  }
157
152
  // The agent may have started while the user was choosing in a dialog.
158
- const reloads = needsReload(getConfig().style, style) || !applyStyle;
153
+ const reloads = !applyStyle;
159
154
  if (reloads && !ctx.isIdle()) {
160
155
  ctx.ui.notify(BUSY, "warning");
161
156
  return;
@@ -175,7 +170,7 @@ export function registerCompactToolsCommand(pi: ExtensionAPI, getConfig: () => C
175
170
  ctx.ui.notify(`Saved ${style} to global settings, but this project keeps its own ${effective} style. Run /compact-tools ${style} to switch it here.`, "warning");
176
171
  return;
177
172
  }
178
- if (!reloads && applyStyle!(ctx.cwd, ctx.isProjectTrusted())) {
173
+ if (!reloads && applyStyle!(ctx.cwd, ctx.isProjectTrusted(), ctx)) {
179
174
  ctx.ui.notify(`Switched to ${style} style (saved to ${where}).`, "info");
180
175
  return;
181
176
  }
@@ -183,7 +178,9 @@ export function registerCompactToolsCommand(pi: ExtensionAPI, getConfig: () => C
183
178
  ctx.ui.notify(`Saved ${style} style to ${where}. It applies once the response finishes; run /reload then.`, "warning");
184
179
  return;
185
180
  }
186
- ctx.ui.notify(`Switching to ${style} style (saved to ${where}). Reloading…`, "info");
181
+ ctx.ui.notify(style === "off"
182
+ ? `Restoring Pi's native UI (saved to ${where}). Reloading…`
183
+ : `Switching to ${style} style (saved to ${where}). Reloading…`, "info");
187
184
  // Reloading makes this ctx stale, so a failure is reported through the UI held from before.
188
185
  const ui = ctx.ui;
189
186
  try {
@@ -324,8 +324,9 @@ export class ToolGroupController {
324
324
  }
325
325
 
326
326
  dispose(): void {
327
- this.context?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
327
+ const context = this.context;
328
328
  this.context = undefined;
329
+ context?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
329
330
  }
330
331
 
331
332
  private makeGroup(rows: ToolRowLike[], members: unknown[], tui: { requestRender(): void }): Component {
@@ -3,6 +3,12 @@ import { stripTerminalSequences } from "@earendil-works/pi-tui";
3
3
  import { normalizeLineEndings } from "./compact-tools-core.ts";
4
4
  import type { ToolArgs } from "./compact-tools-types.ts";
5
5
 
6
+ /** Historical/transcribed read arguments may contain numeric strings or null. Display only. */
7
+ export function readStartLine(value: unknown): number {
8
+ if (typeof value === "string" && value.trim() !== "") value = Number(value);
9
+ return typeof value === "number" && Number.isFinite(value) ? value : 1;
10
+ }
11
+
6
12
  function pathFrom(args: ToolArgs): string {
7
13
  const value = args.path ?? args.file_path;
8
14
  return typeof value === "string" ? normalizeLineEndings(value) : "";
@@ -96,28 +96,24 @@ export class ProgressController {
96
96
  private level: ThinkingLevel = "medium";
97
97
 
98
98
  constructor(private readonly pi: ExtensionAPI) {
99
- // Every handler leaves early while unbound. Pi keeps dispatching to this
100
- // extension in non-TUI modes, and guarding at the top keeps that work — and
101
- // any future handler added here — out of the detached path.
99
+ // Keep only the current phase/active calls while detached. No UI work or
100
+ // clock runs, but re-enabling mid-stream can resume the correct label.
102
101
  pi.on("agent_start", () => {
103
- if (!this.context) return;
104
102
  this.setMessage("Thinking…");
105
103
  });
106
104
  pi.on("message_update", (event) => {
107
- if (!this.context) return;
108
- const type = event.assistantMessageEvent.type;
105
+ const type = event.assistantMessageEvent?.type;
109
106
  if (type === "thinking_start" || type === "thinking_delta") this.setMessage("Thinking…");
110
107
  else if (type === "text_start" || type === "text_delta") this.setMessage("Responding…");
111
108
  else if (type === "toolcall_start" || type === "toolcall_delta") this.setMessage("Preparing tools…");
112
109
  });
113
110
  pi.on("tool_execution_start", (event) => {
114
- if (!this.context) return;
111
+ if (typeof event.toolName !== "string") return;
115
112
  const message = formatToolProgress(event.toolName, event.args ?? {});
116
113
  this.activeTools.set(event.toolCallId, message);
117
114
  this.setMessage(message);
118
115
  });
119
116
  pi.on("tool_execution_end", (event) => {
120
- if (!this.context) return;
121
117
  this.activeTools.delete(event.toolCallId);
122
118
  const remaining = [...this.activeTools.values()].at(-1);
123
119
  this.setMessage(remaining ?? "Processing results…");
@@ -128,7 +124,6 @@ export class ProgressController {
128
124
  // so releasing the label at agent_end makes a retried turn reappear under Pi's
129
125
  // default label. Release it at the final settlement instead.
130
126
  pi.on("agent_settled", () => {
131
- if (!this.context) return;
132
127
  this.activeTools.clear();
133
128
  this.clearMessage();
134
129
  });
@@ -146,30 +141,34 @@ export class ProgressController {
146
141
  if (typeof this.pi.getThinkingLevel === "function") this.level = this.pi.getThinkingLevel();
147
142
  }
148
143
 
149
- bind(ctx: ExtensionContext): void {
144
+ bind(ctx: ExtensionContext, resume = false): void {
150
145
  this.stopAnimation();
151
146
  this.context = ctx;
152
147
  this.readLevel();
153
- this.activeTools.clear();
148
+ const message = resume && ctx.isIdle?.() === false ? this.message ?? "Thinking…" : undefined;
149
+ if (!message) this.activeTools.clear();
154
150
  this.message = undefined;
155
151
  this.frames = [];
156
152
  this.frame = 0;
157
153
  ctx.ui.setWorkingVisible(true);
158
154
  ctx.ui.setWorkingIndicator({ frames: [] });
155
+ if (message) this.setMessage(message);
159
156
  }
160
157
 
161
- dispose(): void {
158
+ dispose(preservePhase = false): void {
159
+ const context = this.context;
160
+ this.context = undefined;
162
161
  this.stopAnimation();
163
- this.activeTools.clear();
164
- this.context?.ui.setWorkingMessage();
165
- this.context?.ui.setWorkingIndicator();
166
- this.message = undefined;
162
+ if (!preservePhase) this.activeTools.clear();
163
+ if (!preservePhase) this.message = undefined;
167
164
  this.frames = [];
168
- this.context = undefined;
165
+ try { context?.ui.setWorkingMessage(); }
166
+ finally { context?.ui.setWorkingIndicator(); }
169
167
  }
170
168
 
171
169
  private setMessage(message: string): void {
172
- if (!this.context || (this.message === message && this.stopTicking)) return;
170
+ if (!this.context) { this.message = message; return; }
171
+ if (this.message === message && this.stopTicking) return;
173
172
  this.message = message;
174
173
  this.frames = [];
175
174
  this.frame = 0;
@@ -1,5 +1,7 @@
1
+ import { join } from "node:path";
2
+ import { pathToFileURL } from "node:url";
1
3
  import {
2
- createBashToolDefinition, createEditToolDefinition, createFindToolDefinition,
4
+ getPackageDir, createBashToolDefinition, createEditToolDefinition, createFindToolDefinition,
3
5
  createGrepToolDefinition, createLsToolDefinition, createPowerShellToolDefinition,
4
6
  createReadToolDefinition, createWriteToolDefinition,
5
7
  type ToolDefinition,
@@ -22,6 +24,20 @@ for (const [name, factory] of Object.entries(factories)) {
22
24
  }
23
25
  }
24
26
 
27
+ let nativeTimerLoad: Promise<void> | undefined;
28
+ /** Optional bundled renderer: absent on old hosts, and never executes a tool. */
29
+ export function loadNativeTimerRenderers(): Promise<void> {
30
+ return nativeTimerLoad ??= (async () => {
31
+ try {
32
+ const url = pathToFileURL(join(getPackageDir(), "dist/extensions/codemode/renderer.js"));
33
+ const { codemodeRenderers } = await import(url.href);
34
+ if (typeof codemodeRenderers?.renderCall === "function" && typeof codemodeRenderers?.renderResult === "function") {
35
+ builtinRenderers.set("codemode", { call: String(codemodeRenderers.renderCall), result: String(codemodeRenderers.renderResult) });
36
+ }
37
+ } catch { /* Optional host internals must not prevent UI loading. */ }
38
+ })();
39
+ }
40
+
25
41
  /** Conservative fallback on legacy hosts without source metadata. */
26
42
  export function hasAuthorRenderers(name: string, definition: ToolDefinition<any, any, any> | undefined): boolean {
27
43
  if (!definition) return false;
@@ -30,6 +46,25 @@ export function hasAuthorRenderers(name: string, definition: ToolDefinition<any,
30
46
  || (definition.renderResult && String(definition.renderResult) !== builtin?.result));
31
47
  }
32
48
 
49
+ /**
50
+ * Native shell rows own a one-second elapsed timer in their renderer state. Pause
51
+ * it when leaving native rendering (without fabricating a final result), and seed
52
+ * its start from the observed execution time when entering native rendering late.
53
+ * Guard by the exact host renderer fingerprint: third-party state is not ours.
54
+ */
55
+ export function syncNativeToolTimer(row: object, name: string, base: ToolDefinition<any, any, any> | undefined, native: boolean, timing?: { startedAt: number; endedAt?: number }): void {
56
+ if (!base || !builtinRenderers.has(name) || !["bash", "powershell", "codemode"].includes(name) || hasAuthorRenderers(name, base)) return;
57
+ const state = (row as { rendererState?: { startedAt?: number; endedAt?: number; interval?: ReturnType<typeof setInterval> } }).rendererState;
58
+ if (!state) return;
59
+ if (native) {
60
+ if (state.startedAt === undefined && timing) state.startedAt = timing.startedAt;
61
+ if (state.endedAt === undefined && timing?.endedAt !== undefined) state.endedAt = timing.endedAt;
62
+ } else if (state.interval !== undefined) {
63
+ clearInterval(state.interval);
64
+ state.interval = undefined;
65
+ }
66
+ }
67
+
33
68
  /** Change only a transcript row's renderer shell, never a tool registry entry. */
34
69
  export function refreshRowShell(row: object): void {
35
70
  const target = row as {
@@ -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.2",
3
+ "version": "0.16.6",
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",
@@ -1,4 +1,46 @@
1
1
  {
2
+ "0.16.6": {
3
+ "New": [],
4
+ "Fix": [
5
+ "Correct the SDK smoke test on Pi 0.99–1.1 by checking native codemode timer behavior only on hosts that provide it while retaining live off/on coverage on every supported version.",
6
+ "Require the complete supported Pi and Node compatibility matrix to pass before npm publication."
7
+ ],
8
+ "Deprecated": []
9
+ },
10
+ "0.16.5": {
11
+ "New": [],
12
+ "Fix": [
13
+ "Remove duplicate transcript updates so each tool row refreshes once during session UI binding.",
14
+ "Reduce median UI rebind time from 8.89 ms to 4.87 ms in a 200-row component benchmark, without implying full reload or general rendering speedups.",
15
+ "Isolate row refresh failures so other rows and controller cleanup continue, with fallback diagnostics if warning delivery fails.",
16
+ "Detach controller state despite host cleanup errors to prevent compact animation from restarting while off.",
17
+ "Centralize UI cleanup and separate transcript reconciliation from extension setup.",
18
+ "Remove unused execution wrappers and state while preserving timing, codemode summaries, and live off/on switching."
19
+ ],
20
+ "Deprecated": []
21
+ },
22
+ "0.16.4": {
23
+ "New": [
24
+ "The off command restores native Pi UI immediately, including during streaming or tool execution, without reload, abort, or restart",
25
+ "Re-enable Compact, Claude, or Codex live from off; existing rows and calls created while off follow the selected style"
26
+ ],
27
+ "Fix": [
28
+ "Native and custom renderer shells and component caches reconcile across repeated off/on transitions without replacing author state",
29
+ "Working labels and silent activity resume the current streaming phase; native shell elapsed timers pause when returning to compact rendering"
30
+ ],
31
+ "Deprecated": []
32
+ },
33
+ "0.16.3": {
34
+ "New": [
35
+ "Verified on Pi 1.3.0 with its version added to CI; the Pi 0.85.1 minimum is unchanged",
36
+ "Tool indicators, the working label, and silent activity share one animation timer, reducing redundant paint boundaries without changing their average animation rates"
37
+ ],
38
+ "Fix": [
39
+ "Replayed read calls with numeric-string offsets retain their correct code line numbers",
40
+ "The off command restores Pi's native thinking display as well as tool rows, working indicator, keyboard behavior, and visibility; active themes and session history are unchanged"
41
+ ],
42
+ "Deprecated": []
43
+ },
2
44
  "0.16.2": {
3
45
  "New": [
4
46
  "A concise README and a separate development guide",