@quandev104/pi-style 0.2.0 → 0.2.1

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.
@@ -8,10 +8,10 @@
8
8
  // consume no vertical space.
9
9
  //
10
10
  // Design notes:
11
- // - No caching in the batch panel: it reads the module-level registry on every
12
- // render, so member completions (which trigger ui.requestRender via Pi's
13
- // tool_execution_end handler) are picked up without cross-component
14
- // invalidation plumbing.
11
+ // - Live batches render directly from the registry so member completions (which
12
+ // trigger ui.requestRender via Pi's tool_execution_end handler) are picked up
13
+ // without cross-component invalidation plumbing. Once a batch is finalized,
14
+ // width/config-stable renders reuse the cached line array.
15
15
  // - Batch boundaries: a new batch starts when the active batch is closed. The
16
16
  // active batch closes when a non-batchable tool call is dispatched
17
17
  // (boxed/index.ts), when a new message starts (pi/index.ts), and on session
@@ -31,7 +31,7 @@
31
31
 
32
32
  import type { Component } from "@earendil-works/pi-tui";
33
33
  import { stripAnsi } from "../../../shared/ansi.js";
34
- import { type BoxTheme, dimLine, formatToolTitlePrefix } from "../../../shared/box.js";
34
+ import { type BoxTheme, dimLine, formatToolTitlePrefix, themeCacheKey } from "../../../shared/box.js";
35
35
  import { safeTruncateToWidth } from "../../../shared/render-budget.js";
36
36
  import {
37
37
  fileIcon,
@@ -42,7 +42,7 @@ import {
42
42
  TREE_CHILD_INDENT,
43
43
  TREE_INDENT,
44
44
  } from "./output-tree.js";
45
- import { getToolsRenderConfig } from "./session-config.js";
45
+ import { getToolsRenderCacheSignature, getToolsRenderConfig } from "./session-config.js";
46
46
  import type { BoxedToolContext } from "./shared.js";
47
47
 
48
48
  /** Quiet tools whose calls group into a single batch panel. */
@@ -78,6 +78,11 @@ export interface BatchMember {
78
78
  outputEntries?: string[];
79
79
  }
80
80
 
81
+ type BatchRenderCache = {
82
+ key: string;
83
+ lines: string[];
84
+ };
85
+
81
86
  export interface BatchState {
82
87
  readonly meta: BatchToolMeta;
83
88
  readonly leaderId: string;
@@ -85,6 +90,8 @@ export interface BatchState {
85
90
  completedAt?: number;
86
91
  closed: boolean;
87
92
  readonly members: BatchMember[];
93
+ revision: number;
94
+ renderCache?: BatchRenderCache;
88
95
  }
89
96
 
90
97
  /** Tree head limit: only the first few members are listed, the rest collapse. */
@@ -130,6 +137,7 @@ function createBatch(
130
137
  leaderId,
131
138
  startedAt: performance.now(),
132
139
  closed: false,
140
+ revision: 0,
133
141
  members: [
134
142
  {
135
143
  toolCallId: leaderId,
@@ -146,6 +154,11 @@ function createBatch(
146
154
  return batch;
147
155
  }
148
156
 
157
+ function bumpBatchRevision(batch: BatchState): void {
158
+ batch.revision++;
159
+ delete batch.renderCache;
160
+ }
161
+
149
162
  /**
150
163
  * Register a call renderer invocation. Idempotent per toolCallId: re-fires
151
164
  * (updateDisplay on the same component) reuse the call's existing batch, even
@@ -161,9 +174,12 @@ export function registerBatchCall(
161
174
  if (existing) {
162
175
  const member = existing.members.find((entry) => entry.toolCallId === context.toolCallId);
163
176
  if (member) {
177
+ const changed =
178
+ member.detail !== detail || member.pattern !== opts.pattern || member.pathLabel !== opts.pathLabel;
164
179
  member.detail = detail;
165
180
  if (opts.pattern !== undefined) member.pattern = opts.pattern;
166
181
  if (opts.pathLabel !== undefined) member.pathLabel = opts.pathLabel;
182
+ if (changed) bumpBatchRevision(existing);
167
183
  }
168
184
  return { batch: existing, isLeader: existing.leaderId === context.toolCallId };
169
185
  }
@@ -182,6 +198,7 @@ export function registerBatchCall(
182
198
  };
183
199
  current.members.push(member);
184
200
  batchByCallId.set(context.toolCallId, current);
201
+ bumpBatchRevision(current);
185
202
  return { batch: current, isLeader: false };
186
203
  }
187
204
 
@@ -208,14 +225,23 @@ export function registerBatchResult(
208
225
  if (!batch || batch.meta.toolName !== meta.toolName) return { batch: undefined, isLeader: false };
209
226
  const member = batch.members.find((entry) => entry.toolCallId === context.toolCallId);
210
227
  if (member) {
211
- member.status = data.isPartial ? "running" : "done";
212
- member.isError = !data.isPartial && data.isError;
228
+ const nextStatus = data.isPartial ? "running" : "done";
229
+ const nextIsError = !data.isPartial && data.isError;
230
+ const changed =
231
+ member.status !== nextStatus ||
232
+ member.isError !== nextIsError ||
233
+ member.errorText !== (nextIsError ? data.errorText : undefined) ||
234
+ member.outputEntries !== data.entries;
235
+ member.status = nextStatus;
236
+ member.isError = nextIsError;
213
237
  if (member.isError && data.errorText !== undefined) member.errorText = data.errorText;
214
238
  else delete member.errorText;
215
239
  if (data.entries !== undefined) member.outputEntries = data.entries;
240
+ if (changed) bumpBatchRevision(batch);
216
241
  }
217
242
  if (batch.completedAt === undefined && batch.members.every((entry) => entry.status === "done")) {
218
243
  batch.completedAt = performance.now();
244
+ bumpBatchRevision(batch);
219
245
  }
220
246
  return { batch, isLeader: batch.leaderId === context.toolCallId };
221
247
  }
@@ -459,9 +485,17 @@ function renderBatchPanelLines(theme: BoxTheme, batch: BatchState, status: Batch
459
485
  */
460
486
  export function renderBatchAwareCall(theme: BoxTheme, batch: BatchState): Component {
461
487
  return {
462
- invalidate() {},
488
+ invalidate() {
489
+ delete batch.renderCache;
490
+ },
463
491
  render(width: number): string[] {
464
- return renderBatchPanelLines(theme, batch, batchStatus(batch), width);
492
+ const status = batchStatus(batch);
493
+ if (!status.allDone) return renderBatchPanelLines(theme, batch, status, width);
494
+ const cacheKey = [themeCacheKey(theme), getToolsRenderCacheSignature(), width, batch.revision].join("|");
495
+ if (batch.renderCache?.key === cacheKey) return batch.renderCache.lines;
496
+ const lines = renderBatchPanelLines(theme, batch, status, width);
497
+ batch.renderCache = { key: cacheKey, lines };
498
+ return lines;
465
499
  },
466
500
  };
467
501
  }
@@ -24,6 +24,8 @@ import {
24
24
  type BoxedToolContext,
25
25
  type BoxedToolDefinition,
26
26
  displayPath,
27
+ getRenderCacheKey,
28
+ memoizedStateComponent,
27
29
  noteBoxedCallState,
28
30
  noteBoxedResultPhase,
29
31
  noteExecutionStart,
@@ -133,21 +135,34 @@ export const editTool: BoxedToolDefinition = {
133
135
  const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
134
136
  const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
135
137
 
136
- return renderBoxedToolResult(
137
- theme,
138
- {
139
- render(width: number): string[] {
140
- return diffView.render(width);
141
- },
142
- invalidate(): void {
143
- diffView.invalidate();
144
- },
145
- },
146
- {
147
- dividerLabel: diffDividerLabel(theme, stats),
148
- ...(expandHint ? { dividerRightLabel: expandHint } : {}),
149
- footerLines: [editDiffFooter(theme, result, context, stats)],
150
- },
138
+ return memoizedStateComponent(
139
+ context.state,
140
+ "__piStyleEditDiffResult",
141
+ getRenderCacheKey(
142
+ "edit-diff-result",
143
+ theme,
144
+ Boolean(expanded),
145
+ diff,
146
+ sourcePath ?? "",
147
+ editDiffFooter(theme, result, context, stats),
148
+ ),
149
+ () =>
150
+ renderBoxedToolResult(
151
+ theme,
152
+ {
153
+ render(width: number): string[] {
154
+ return diffView.render(width);
155
+ },
156
+ invalidate(): void {
157
+ diffView.invalidate();
158
+ },
159
+ },
160
+ {
161
+ dividerLabel: diffDividerLabel(theme, stats),
162
+ ...(expandHint ? { dividerRightLabel: expandHint } : {}),
163
+ footerLines: [editDiffFooter(theme, result, context, stats)],
164
+ },
165
+ ),
151
166
  );
152
167
  },
153
168
  };
@@ -16,6 +16,8 @@ import {
16
16
  type BoxedToolContext,
17
17
  type BoxedToolDefinition,
18
18
  displayPath,
19
+ getRenderCacheKey,
20
+ memoizedStateComponent,
19
21
  noteBoxedCallState,
20
22
  noteBoxedResultPhase,
21
23
  noteExecutionStart,
@@ -184,21 +186,35 @@ function renderQuickEditResult(
184
186
  const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
185
187
  const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
186
188
 
187
- return renderBoxedToolResult(
188
- theme,
189
- {
190
- render(width: number): string[] {
191
- return diffView.render(width);
192
- },
193
- invalidate(): void {
194
- diffView.invalidate();
195
- },
196
- },
197
- {
198
- dividerLabel: quickEditDividerLabel(theme, stats),
199
- ...(expandHint ? { dividerRightLabel: expandHint } : {}),
200
- footerLines: [quickEditDiffFooter(theme, result, context, stats)],
201
- },
189
+ return memoizedStateComponent(
190
+ context.state,
191
+ "__piStyleQuickEditDiffResult",
192
+ getRenderCacheKey(
193
+ "quick-edit-diff-result",
194
+ theme,
195
+ config.toolLabel,
196
+ Boolean(expanded),
197
+ diff,
198
+ argPath,
199
+ quickEditDiffFooter(theme, result, context, stats),
200
+ ),
201
+ () =>
202
+ renderBoxedToolResult(
203
+ theme,
204
+ {
205
+ render(width: number): string[] {
206
+ return diffView.render(width);
207
+ },
208
+ invalidate(): void {
209
+ diffView.invalidate();
210
+ },
211
+ },
212
+ {
213
+ dividerLabel: quickEditDividerLabel(theme, stats),
214
+ ...(expandHint ? { dividerRightLabel: expandHint } : {}),
215
+ footerLines: [quickEditDiffFooter(theme, result, context, stats)],
216
+ },
217
+ ),
202
218
  );
203
219
  }
204
220
 
@@ -37,6 +37,19 @@ export function getToolsRenderConfig(): ToolsRenderConfig {
37
37
  return sessionToolsConfig;
38
38
  }
39
39
 
40
+ export function getToolsRenderCacheSignature(): string {
41
+ return [
42
+ sessionToolsConfig.maxCollapsedLines,
43
+ sessionToolsConfig.maxExpandedLines,
44
+ sessionToolsConfig.dimOutput ? 1 : 0,
45
+ sessionToolsConfig.showElapsed ? 1 : 0,
46
+ sessionToolsConfig.batchOpenGlyph,
47
+ sessionToolsConfig.nerdFonts ? 1 : 0,
48
+ sessionToolsConfig.collapseAfterTurn ? 1 : 0,
49
+ sessionToolsConfig.collapseMutatingTools ? 1 : 0,
50
+ ].join("|");
51
+ }
52
+
40
53
  // Wall-clock elapsed tracking through the renderer context state (no tool
41
54
  // re-registration, so result.details has no execution timing).
42
55
  //
@@ -83,8 +96,26 @@ export function markResultSeen(state: Record<string, unknown> | undefined): void
83
96
 
84
97
  type TickerHandle = ReturnType<typeof setInterval>;
85
98
 
86
- /** States that currently own a 1s elapsed-render interval, for session cleanup. */
87
- const tickerStates = new Set<Record<string, unknown>>();
99
+ type ElapsedTickerEntry = {
100
+ invalidate: () => void;
101
+ };
102
+
103
+ /** States currently subscribed to the shared elapsed-render ticker. */
104
+ const tickerEntries = new Map<Record<string, unknown>, ElapsedTickerEntry>();
105
+ let sharedTickerHandle: TickerHandle | undefined;
106
+
107
+ function ensureSharedTicker(): void {
108
+ if (sharedTickerHandle !== undefined || tickerEntries.size === 0) return;
109
+ sharedTickerHandle = setInterval(() => {
110
+ for (const { invalidate } of tickerEntries.values()) invalidate();
111
+ }, 1000) as unknown as TickerHandle;
112
+ }
113
+
114
+ function stopSharedTickerIfIdle(): void {
115
+ if (sharedTickerHandle === undefined || tickerEntries.size > 0) return;
116
+ clearInterval(sharedTickerHandle);
117
+ sharedTickerHandle = undefined;
118
+ }
88
119
 
89
120
  /**
90
121
  * While a tool is running, re-render once per second so live elapsed labels
@@ -92,26 +123,26 @@ const tickerStates = new Set<Record<string, unknown>>();
92
123
  */
93
124
  export function startElapsedTicker(state: Record<string, unknown> | undefined, invalidate: () => void): void {
94
125
  if (!state || typeof state !== "object") return;
95
- if (state[TICKER_KEY] !== undefined) return;
96
- state[TICKER_KEY] = setInterval(() => invalidate(), 1000) as unknown as TickerHandle;
97
- tickerStates.add(state);
126
+ state[TICKER_KEY] = true;
127
+ tickerEntries.set(state, { invalidate });
128
+ ensureSharedTicker();
98
129
  }
99
130
 
100
131
  /** Stop a running tool's elapsed ticker (terminal result, error, session end). */
101
132
  export function stopElapsedTicker(state: Record<string, unknown> | undefined): void {
102
133
  if (!state || typeof state !== "object") return;
103
- const handle = state[TICKER_KEY] as TickerHandle | undefined;
104
- if (handle !== undefined) clearInterval(handle);
105
134
  delete state[TICKER_KEY];
106
- tickerStates.delete(state);
135
+ tickerEntries.delete(state);
136
+ stopSharedTickerIfIdle();
107
137
  }
108
138
 
109
139
  /** Stop every elapsed ticker (session start/shutdown). */
110
140
  export function stopAllElapsedTickers(): void {
111
- for (const state of tickerStates) {
112
- const handle = state[TICKER_KEY] as TickerHandle | undefined;
113
- if (handle !== undefined) clearInterval(handle);
114
- delete state[TICKER_KEY];
115
- }
116
- tickerStates.clear();
141
+ for (const state of tickerEntries.keys()) delete state[TICKER_KEY];
142
+ tickerEntries.clear();
143
+ stopSharedTickerIfIdle();
144
+ }
145
+
146
+ export function __getElapsedTickerDebugState(): { trackedStates: number; hasSharedTicker: boolean } {
147
+ return { trackedStates: tickerEntries.size, hasSharedTicker: sharedTickerHandle !== undefined };
117
148
  }
@@ -12,9 +12,11 @@ import {
12
12
  renderCompactBoxedToolCall,
13
13
  resolveRelativePath,
14
14
  shortenPath,
15
+ themeCacheKey,
15
16
  } from "../../../shared/box.js";
16
17
  import {
17
18
  getStateElapsedMs,
19
+ getToolsRenderCacheSignature,
18
20
  isResultSeen,
19
21
  markResultSeen,
20
22
  recordExecutionEnded,
@@ -174,6 +176,29 @@ export function resultFooterLines(
174
176
  return [formatBoxedFooter(theme, result, extraParts, stateElapsedMs(context))];
175
177
  }
176
178
 
179
+ type StateComponentCacheEntry = {
180
+ key: string;
181
+ component: Component;
182
+ };
183
+
184
+ export function getRenderCacheKey(prefix: string, theme: BoxTheme, ...parts: Array<string | number | boolean>): string {
185
+ return [prefix, themeCacheKey(theme), getToolsRenderCacheSignature(), ...parts].join("|");
186
+ }
187
+
188
+ export function memoizedStateComponent(
189
+ state: Record<string, unknown> | undefined,
190
+ slot: string,
191
+ key: string,
192
+ build: () => Component,
193
+ ): Component {
194
+ if (!state || typeof state !== "object") return build();
195
+ const cached = state[slot] as StateComponentCacheEntry | undefined;
196
+ if (cached && cached.key === key) return cached.component;
197
+ const component = build();
198
+ state[slot] = { key, component } satisfies StateComponentCacheEntry;
199
+ return component;
200
+ }
201
+
177
202
  export function clearFooterState(context: BoxedToolContext): void {
178
203
  clearCompactBoxedFooter(context.state);
179
204
  }
@@ -145,7 +145,7 @@ export const KNOWN_NATIVE_IDENTITIES: Readonly<Record<string, readonly KnownNati
145
145
  /** Primary (first-recorded) fingerprint per surface, for diagnostics and back-compat. */
146
146
  export const TRUSTED_NATIVE_FINGERPRINTS: Readonly<Record<string, string>> = Object.freeze(
147
147
  Object.fromEntries(
148
- Object.entries(KNOWN_NATIVE_IDENTITIES).map(([key, identities]) => [key, identities[0]!.fingerprint]),
148
+ Object.entries(KNOWN_NATIVE_IDENTITIES).map(([key, identities]) => [key, identities[0]?.fingerprint ?? ""]),
149
149
  ),
150
150
  );
151
151
 
@@ -11,12 +11,11 @@ import {
11
11
  import { requestToolPresentationRender } from "../features/tools/index.js";
12
12
  import { registerPiStyleCommand } from "./commands.js";
13
13
  import { type CompatibilityTestHooks, createPiStyleSessionCoordinator } from "./session-coordinator.js";
14
- import { usageFromSession } from "./session-usage.js";
14
+ import { resetUsageFromSessionCache, usageFromSession } from "./session-usage.js";
15
15
 
16
- /** Usage patch that omits the key when no session usage exists (exact optional types). */
16
+ /** Usage patch clears stale usage when the active branch/session has none. */
17
17
  function usagePatch(ctx: ExtensionContext): StatusSnapshot {
18
- const usage = usageFromSession(ctx.sessionManager);
19
- return usage ? { usage } : {};
18
+ return { usage: usageFromSession(ctx.sessionManager) } as StatusSnapshot;
20
19
  }
21
20
 
22
21
  /**
@@ -65,6 +64,7 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
65
64
  const coordinator = createPiStyleSessionCoordinator(pi, compatibilityTestHooks);
66
65
  registerPiStyleCommand(pi, coordinator.app);
67
66
  pi.on("session_start", async (event, ctx) => {
67
+ resetUsageFromSessionCache(ctx.sessionManager);
68
68
  // Pi only activates read/bash/edit/write by default; grep/find/ls are
69
69
  // registered but inactive (kept out of the model's tool list to keep the
70
70
  // core small). Activate them so the TUI shows them and the model can call
@@ -110,7 +110,9 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
110
110
  ),
111
111
  );
112
112
  pi.on("thinking_level_select", (event) => coordinator.app.update({ thinkingLevel: event.level }, "immediate"));
113
- pi.on("session_info_changed", (event) => coordinator.app.update({ sessionName: event.name }, "coalesced"));
113
+ pi.on("session_info_changed", (event) =>
114
+ coordinator.app.update({ sessionName: event.name }, "coalesced", { refreshExtensionStatuses: true }),
115
+ );
114
116
  pi.on("message_start", () => {
115
117
  // A new message is a batch boundary: quiet-tool (read/ls/find) calls of the
116
118
  // new message start a fresh batch instead of joining the previous one.
@@ -123,11 +125,13 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
123
125
  // Usage (tokens + cost) is aggregated from finalized session entries at
124
126
  // message/turn boundaries, mirroring Pi's native footer; per-chunk updates
125
127
  // stay usage-free to keep streaming cheap.
126
- pi.on("message_end", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "coalesced"));
128
+ pi.on("message_end", (_event, ctx) =>
129
+ coordinator.app.update({ ...usagePatch(ctx) }, "coalesced", { refreshContextUsage: true }),
130
+ );
127
131
  pi.on("turn_end", (event, ctx) => {
128
132
  // Append the finalized assistant message's tool batch to the current run.
129
133
  registerTurnFromMessage(event.message, event.toolResults);
130
- coordinator.app.update({ ...usagePatch(ctx) }, "deferred");
134
+ coordinator.app.update({ ...usagePatch(ctx) }, "deferred", { refreshContextUsage: true });
131
135
  });
132
136
  pi.on("agent_end", () => {
133
137
  // The run is complete: collapse its tool blocks into one summary line.
@@ -141,23 +145,32 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
141
145
  requestToolPresentationRender();
142
146
  }
143
147
  });
144
- pi.on("agent_settled", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "coalesced"));
148
+ pi.on("agent_settled", (_event, ctx) =>
149
+ coordinator.app.update({ ...usagePatch(ctx) }, "coalesced", { refreshContextUsage: true }),
150
+ );
145
151
  pi.on("session_tree", (_event, ctx) => {
152
+ resetUsageFromSessionCache(ctx.sessionManager);
146
153
  // Rebuild the turn registry from session content so restored/branched
147
154
  // history renders collapsed consistently (no in-process turn_end events).
148
155
  rebuildTurnRegistryFromEntries(ctx.sessionManager.getEntries());
149
- coordinator.app.update({ ...usagePatch(ctx) }, "deferred");
156
+ coordinator.app.update({ ...usagePatch(ctx) }, "deferred", { refreshContextUsage: true });
157
+ });
158
+ pi.on("session_compact", (_event, ctx) => {
159
+ resetUsageFromSessionCache(ctx.sessionManager);
160
+ coordinator.app.update({ ...usagePatch(ctx) }, "deferred", { refreshContextUsage: true });
150
161
  });
151
- pi.on("session_compact", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "deferred"));
152
162
  pi.on("tool_result", (event, ctx) => {
153
163
  if (["write", "edit", "bash"].includes(event.toolName)) {
154
164
  coordinator.app.runtime.current?.invalidateGit();
155
- coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry");
165
+ coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry", { refreshContextUsage: true });
156
166
  }
157
167
  });
158
168
  pi.on("user_bash", (_event, ctx) => {
159
169
  coordinator.app.runtime.current?.invalidateGit();
160
- coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry");
170
+ coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry", { refreshContextUsage: true });
171
+ });
172
+ pi.on("session_shutdown", (_event, ctx) => {
173
+ resetUsageFromSessionCache(ctx.sessionManager);
174
+ coordinator.shutdown();
161
175
  });
162
- pi.on("session_shutdown", () => coordinator.shutdown());
163
176
  }