@quandev104/pi-style 0.1.6 → 0.1.7

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.
@@ -0,0 +1,368 @@
1
+ // Turn tool summary registry (ADR 0007).
2
+ //
3
+ // When a turn completes, its finalized tool blocks collapse into a single
4
+ // summary line (`➔ Read 2 files, ran 4 shell commands · 3.1s`) rendered by the
5
+ // turn's leader (its first non-error tool call); every other non-error tool
6
+ // item of the turn renders zero lines. Error results stay visible, interrupted
7
+ // turns never collapse, and Pi's global tool-output toggle (Ctrl+O) expands
8
+ // everything again (`options.expanded` is read, never written).
9
+ //
10
+ // Design notes:
11
+ // - The registry is populated from **session content**, never from runtime
12
+ // event flags: the live path registers the final assistant message +
13
+ // toolResults at `turn_end`; the restore path rebuilds the registry from
14
+ // `sessionManager.getEntries()` at session start / `session_tree`, so
15
+ // scroll-back and session resume render identically.
16
+ // - A turn is "ended" only when every tool call of its message has a matching
17
+ // tool result AND (live) turn_end fired / (restore) the message is
18
+ // finalized (`stopReason`) or a later user/assistant message exists.
19
+ // - Elapsed per member is frozen from the renderer wall-clock state
20
+ // (STARTED_AT/ENDED_AT) at the first post-turn result pass; the summary
21
+ // totals the members' frozen elapsed. No render-time I/O.
22
+ // - No new Pi-core patch identity: the dispatcher (boxed/index.ts) decides
23
+ // collapse before the per-tool renderers run, so every certified renderer
24
+ // surface stays untouched when the turn is not collapsed.
25
+
26
+ import type { Component } from "@earendil-works/pi-tui";
27
+ import type { BoxTheme } from "../../../shared/box.js";
28
+ import { safeTruncateToWidth } from "../../../shared/render-budget.js";
29
+ import { pluralForm } from "./output-tree.js";
30
+
31
+ export interface TurnMemberInfo {
32
+ readonly toolCallId: string;
33
+ readonly toolName: string;
34
+ /** Whether a tool result was registered for this call (run completeness). */
35
+ readonly hasResult: boolean;
36
+ isError: boolean;
37
+ /** Frozen wall-clock elapsed (ms), recorded from the renderer context state. */
38
+ elapsedMs?: number;
39
+ }
40
+
41
+ export interface TurnState {
42
+ /** First non-error member; renders the summary line. Empty when every member errored. */
43
+ leaderId: string;
44
+ ended: boolean;
45
+ members: readonly TurnMemberInfo[];
46
+ }
47
+
48
+ interface TurnEntry {
49
+ readonly turn: TurnState;
50
+ readonly member: TurnMemberInfo;
51
+ }
52
+
53
+ const memberByCallId = new Map<string, TurnEntry>();
54
+
55
+ /** Per-member component invalidate callbacks captured during render passes. */
56
+ const invalidateByCallId = new Map<string, () => void>();
57
+
58
+ /** Reset all turn state (session start/shutdown). */
59
+ export function resetTurnRegistry(): void {
60
+ memberByCallId.clear();
61
+ invalidateByCallId.clear();
62
+ }
63
+
64
+ interface ToolCallLike {
65
+ readonly type?: unknown;
66
+ readonly id?: unknown;
67
+ readonly name?: unknown;
68
+ }
69
+
70
+ function toolCallsOf(message: unknown): ToolCallLike[] {
71
+ if (!message || typeof message !== "object") return [];
72
+ const content = (message as { readonly content?: unknown }).content;
73
+ if (!Array.isArray(content)) return [];
74
+ const calls: ToolCallLike[] = [];
75
+ for (const item of content) {
76
+ if (!item || typeof item !== "object") continue;
77
+ const candidate = item as ToolCallLike;
78
+ if (candidate.type === "toolCall") calls.push(candidate);
79
+ }
80
+ return calls;
81
+ }
82
+
83
+ function registerTurn(
84
+ calls: readonly ToolCallLike[],
85
+ isErrorById: ReadonlyMap<string, boolean>,
86
+ ended: boolean,
87
+ ): TurnState | undefined {
88
+ if (calls.length === 0) return undefined;
89
+ const complete = calls.every((call) => typeof call.id === "string" && isErrorById.has(call.id));
90
+ const members: TurnMemberInfo[] = calls.map((call) => {
91
+ const toolCallId = String(call.id ?? "");
92
+ return {
93
+ toolCallId,
94
+ toolName: typeof call.name === "string" ? call.name : "tool",
95
+ hasResult: isErrorById.has(toolCallId),
96
+ isError: isErrorById.get(toolCallId) === true,
97
+ };
98
+ });
99
+ const leader = members.find((member) => !member.isError);
100
+ const turn: TurnState = {
101
+ leaderId: leader?.toolCallId ?? "",
102
+ ended: ended && complete,
103
+ members: Object.freeze(members),
104
+ };
105
+ for (const member of members) memberByCallId.set(member.toolCallId, { turn, member });
106
+ return turn;
107
+ }
108
+
109
+ export interface TurnResultLike {
110
+ readonly toolCallId: string;
111
+ readonly isError?: boolean;
112
+ }
113
+
114
+ /**
115
+ * One summary group = one agent run (user request → `agent_end`). Pi emits
116
+ * `turn_end` per assistant message, so tool batches of the same request are
117
+ * appended to the same run and collapse into ONE summary line at `agent_end`.
118
+ */
119
+ let currentRun: TurnState | undefined;
120
+
121
+ /** Live path: start a fresh run group (`agent_start`). */
122
+ export function beginAgentRun(): void {
123
+ currentRun = undefined;
124
+ }
125
+
126
+ /**
127
+ * Live path: append the finalized assistant message's tool calls and results
128
+ * to the current run (`turn_end` event). The run stays expanded until
129
+ * `finishAgentRun`; a batch interrupted mid-tool never collapses.
130
+ */
131
+ export function registerTurnFromMessage(message: unknown, toolResults: readonly TurnResultLike[]): void {
132
+ const calls = toolCallsOf(message);
133
+ if (calls.length === 0) return;
134
+ const isErrorById = new Map<string, boolean>();
135
+ for (const result of toolResults) {
136
+ if (typeof result?.toolCallId !== "string") continue;
137
+ isErrorById.set(result.toolCallId, result.isError === true);
138
+ }
139
+ const newMembers: TurnMemberInfo[] = calls.map((call) => {
140
+ const toolCallId = String(call.id ?? "");
141
+ return {
142
+ toolCallId,
143
+ toolName: typeof call.name === "string" ? call.name : "tool",
144
+ hasResult: isErrorById.has(toolCallId),
145
+ isError: isErrorById.get(toolCallId) === true,
146
+ };
147
+ });
148
+ const leader = newMembers.find((member) => !member.isError);
149
+ if (!currentRun) {
150
+ currentRun = {
151
+ leaderId: leader?.toolCallId ?? "",
152
+ ended: false,
153
+ members: Object.freeze(newMembers),
154
+ };
155
+ } else {
156
+ if (currentRun.leaderId === "" && leader) currentRun.leaderId = leader.toolCallId;
157
+ currentRun.members = Object.freeze([...currentRun.members, ...newMembers]);
158
+ }
159
+ for (const member of newMembers) memberByCallId.set(member.toolCallId, { turn: currentRun, member });
160
+ }
161
+
162
+ /**
163
+ * Live path: finalize the current run (`agent_end`). Returns the run so the
164
+ * caller can invalidate its blocks; undefined when the run had no tool calls
165
+ * or is interrupted (a call without a result stays expanded).
166
+ */
167
+ export function finishAgentRun(): TurnState | undefined {
168
+ const run = currentRun;
169
+ currentRun = undefined;
170
+ if (!run) return undefined;
171
+ // A member without a result means the run was interrupted before every call
172
+ // settled; such a run never collapses.
173
+ if (run.members.every((member) => member.hasResult)) run.ended = true;
174
+ return run.ended ? run : undefined;
175
+ }
176
+
177
+ interface TurnEntryLike {
178
+ readonly type?: unknown;
179
+ readonly message?: {
180
+ readonly role?: unknown;
181
+ readonly content?: unknown;
182
+ readonly stopReason?: unknown;
183
+ readonly toolCallId?: unknown;
184
+ readonly isError?: unknown;
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Restore path: rebuild the registry from session entries (session start /
190
+ * `session_tree`). Consecutive assistant messages between user messages form
191
+ * one run; a run is ended when every tool call has a result AND a later user
192
+ * message exists (historical) or its last assistant message is finalized
193
+ * (`stopReason`). The currently streaming run stays expanded.
194
+ */
195
+ export function rebuildTurnRegistryFromEntries(entries: readonly TurnEntryLike[] | undefined): void {
196
+ memberByCallId.clear();
197
+ if (!Array.isArray(entries)) return;
198
+ const isErrorById = new Map<string, boolean>();
199
+ const resultById = new Set<string>();
200
+ const runs: Array<{
201
+ calls: ToolCallLike[];
202
+ lastStopReason: string | undefined;
203
+ followedByUser: boolean;
204
+ }> = [];
205
+ let current: (typeof runs)[number] | undefined;
206
+ const closeRun = () => {
207
+ if (current && current.calls.length > 0) runs.push(current);
208
+ current = undefined;
209
+ };
210
+ entries.forEach((entry) => {
211
+ if (entry?.type !== "message") return;
212
+ const message = entry.message;
213
+ if (message?.role === "toolResult" && typeof message.toolCallId === "string") {
214
+ resultById.add(message.toolCallId);
215
+ isErrorById.set(message.toolCallId, message.isError === true);
216
+ } else if (message?.role === "assistant") {
217
+ if (!current) current = { calls: [], lastStopReason: undefined, followedByUser: false };
218
+ const calls = toolCallsOf(message);
219
+ current.calls.push(...calls);
220
+ if (typeof message.stopReason === "string" && message.stopReason !== "")
221
+ current.lastStopReason = message.stopReason;
222
+ } else if (message?.role === "user") {
223
+ if (current) {
224
+ current.followedByUser = true;
225
+ closeRun();
226
+ } else {
227
+ // A user message with no preceding open run is a plain boundary.
228
+ closeRun();
229
+ }
230
+ }
231
+ });
232
+ closeRun();
233
+ for (const run of runs) {
234
+ const complete = run.calls.every((call) => typeof call.id === "string" && resultById.has(call.id));
235
+ const ended = complete && (run.followedByUser || run.lastStopReason !== undefined);
236
+ registerTurn(run.calls, isErrorById, ended);
237
+ }
238
+ }
239
+
240
+ /** Registry lookup for the render dispatcher. */
241
+ export function getTurnEntry(toolCallId: string): TurnEntry | undefined {
242
+ return memberByCallId.get(toolCallId);
243
+ }
244
+
245
+ /**
246
+ * Capture a member's component invalidate callback during a render pass. Pi
247
+ * only re-invokes the tool renderer selectors from updateDisplay(); calling
248
+ * the captured callback after turn_end rebuilds the block with the collapsed
249
+ * summary. Idempotent per toolCallId (latest component wins).
250
+ */
251
+ export function noteTurnMemberRender(toolCallId: string, invalidate: () => void): void {
252
+ if (typeof invalidate !== "function") return;
253
+ invalidateByCallId.set(toolCallId, invalidate);
254
+ }
255
+
256
+ /**
257
+ * Force the just-finished turn's tool blocks to re-render (updateDisplay).
258
+ * Components that never rendered (headless/print) have no captured callback.
259
+ */
260
+ export function invalidateTurnMembers(turn: TurnState): void {
261
+ for (const member of turn.members) {
262
+ const invalidate = invalidateByCallId.get(member.toolCallId);
263
+ if (!invalidate) continue;
264
+ try {
265
+ invalidate();
266
+ } catch {
267
+ // A detached component must not break the turn-end path.
268
+ invalidateByCallId.delete(member.toolCallId);
269
+ }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Freeze a member's wall-clock elapsed into the registry (idempotent; the
275
+ * value is frozen by the renderer state once the terminal result rendered).
276
+ */
277
+ export function noteTurnMemberElapsed(toolCallId: string, elapsedMs: number | undefined): void {
278
+ if (elapsedMs === undefined) return;
279
+ const entry = memberByCallId.get(toolCallId);
280
+ if (!entry || entry.member.elapsedMs !== undefined) return;
281
+ entry.member.elapsedMs = elapsedMs;
282
+ }
283
+
284
+ /** Per-tool summary phrasing: `Read 2 files` / `ran 4 shell commands`. */
285
+ const TURN_SUMMARY_STYLE: Readonly<Record<string, { readonly verb: string; readonly unit: string }>> = Object.freeze({
286
+ read: { verb: "Read", unit: "file" },
287
+ bash: { verb: "ran", unit: "shell command" },
288
+ ls: { verb: "Listed", unit: "path" },
289
+ find: { verb: "Found", unit: "file" },
290
+ grep: { verb: "Grepped", unit: "pattern" },
291
+ edit: { verb: "Edited", unit: "file" },
292
+ write: { verb: "Wrote", unit: "file" },
293
+ quick_edit: { verb: "Edited", unit: "file" },
294
+ substitute_edit: { verb: "Edited", unit: "file" },
295
+ target_edit: { verb: "Edited", unit: "file" },
296
+ });
297
+
298
+ export interface TurnSummaryParts {
299
+ /** `Read 2 files`, `ran 4 shell commands`, ... in first-use order. */
300
+ readonly parts: readonly string[];
301
+ readonly failedCount: number;
302
+ /** Sum of members' frozen elapsed; undefined when nothing was recorded. */
303
+ readonly elapsedMs: number | undefined;
304
+ }
305
+
306
+ /** Aggregate a turn's non-error members into summary parts (pure). */
307
+ export function turnSummaryParts(turn: TurnState): TurnSummaryParts {
308
+ const counts = new Map<string, number>();
309
+ const order: string[] = [];
310
+ let failedCount = 0;
311
+ let elapsedMs: number | undefined;
312
+ for (const member of turn.members) {
313
+ if (member.isError) {
314
+ failedCount++;
315
+ continue;
316
+ }
317
+ if (member.elapsedMs !== undefined) elapsedMs = (elapsedMs ?? 0) + member.elapsedMs;
318
+ const existing = counts.get(member.toolName);
319
+ if (existing === undefined) {
320
+ counts.set(member.toolName, 1);
321
+ order.push(member.toolName);
322
+ } else counts.set(member.toolName, existing + 1);
323
+ }
324
+ const parts = order.map((toolName) => {
325
+ const count = counts.get(toolName) ?? 0;
326
+ const style = TURN_SUMMARY_STYLE[toolName] ?? { verb: "ran", unit: `${toolName} call` };
327
+ return `${style.verb} ${count} ${pluralForm(style.unit, count)}`;
328
+ });
329
+ return { parts, failedCount, elapsedMs };
330
+ }
331
+
332
+ function formatTurnSummaryLine(theme: BoxTheme, turn: TurnState): string {
333
+ const summary = turnSummaryParts(turn);
334
+ // The summary is deliberately quiet: the whole line renders dim so completed
335
+ // tool work recedes behind the assistant's answer. Only the failed marker
336
+ // stays error-colored (errors must remain visible).
337
+ const parts = summary.parts.join(", ");
338
+ let line = `${theme.fg("dim", `➔ ${parts}`)}`;
339
+ if (summary.failedCount > 0)
340
+ line += theme.fg("error", ` · ${summary.failedCount} ${pluralForm("failed", summary.failedCount)}`);
341
+ if (summary.elapsedMs !== undefined) line += theme.fg("dim", ` · ${(summary.elapsedMs / 1000).toFixed(2)}s`);
342
+ return line;
343
+ }
344
+
345
+ /** Leader call component: renders the live turn summary line on every pass. */
346
+ export function renderTurnSummaryCall(theme: BoxTheme, turn: TurnState): Component {
347
+ return {
348
+ invalidate() {},
349
+ render(width: number): string[] {
350
+ return [safeTruncateToWidth(formatTurnSummaryLine(theme, turn), Math.max(1, width), "…")];
351
+ },
352
+ };
353
+ }
354
+
355
+ /**
356
+ * Empty result component for the turn-summary leader. The summary lives in the
357
+ * call component; the result adds nothing. Deliberately NOT the shared
358
+ * EMPTY_BATCH_COMPONENT singleton, so the decoration's hideBatchMember
359
+ * (identity-compared) never hides the leader.
360
+ */
361
+ export function emptyTurnResult(): Component {
362
+ return {
363
+ invalidate() {},
364
+ render() {
365
+ return [];
366
+ },
367
+ };
368
+ }
@@ -113,6 +113,19 @@ const DEFAULT_SNAPSHOT: ToolDecorationSnapshot = Object.freeze({
113
113
  });
114
114
  let ownerGeneration = 0;
115
115
  const piStyleWrappers = new WeakSet<RenderFunction>();
116
+
117
+ /**
118
+ * The real Tui instance captured from decorated tool components (`instance.ui`),
119
+ * used to request a repaint after the turn registry flips a turn to collapsed
120
+ * (pi only re-paints after its own events; the renderer selectors re-run on
121
+ * updateDisplay, but the screen refresh still needs a requestRender).
122
+ */
123
+ let capturedToolUi: { requestRender?: (force?: boolean) => void } | undefined;
124
+
125
+ /** Request a screen repaint through the captured tool Tui (no-op headless). */
126
+ export function requestToolPresentationRender(): void {
127
+ capturedToolUi?.requestRender?.();
128
+ }
116
129
  let toolTestHooks: { defineProperty?: typeof Reflect.defineProperty; deleteProperty?: typeof Reflect.deleteProperty } =
117
130
  {};
118
131
  export function __setToolDecorationTestHooks(hooks: typeof toolTestHooks): () => void {
@@ -392,12 +405,14 @@ export function createToolDecorationOwner(snapshot: Partial<ToolDecorationSnapsh
392
405
  } else if (outcome === "later-owner") state.active.delete(record);
393
406
  else state.failed++;
394
407
  }
408
+ capturedToolUi = undefined;
395
409
  const archive = state.active.size === 0 ? finalize(state) : undefined;
396
410
  return { restored: state.restored, failed: state.failed, diagnostics: new Map(state.diagnostics), archive };
397
411
  };
398
412
  return Object.freeze({
399
413
  decorateToolRendererSelection(subtype: RendererSubtype, original: unknown, instance: object, args: unknown[]) {
400
414
  if (typeof original !== "function") return undefined;
415
+ capturedToolUi = (instance as { ui?: { requestRender?: (force?: boolean) => void } }).ui ?? capturedToolUi;
401
416
  const renderer = Reflect.apply(original, instance, args);
402
417
  if (state.snapshot.style === "compact-box") {
403
418
  const toolName = (instance as { toolName?: unknown }).toolName;
@@ -168,6 +168,7 @@ export function createCompatibilityCoordinator(dispose = disposePiCompatibilityP
168
168
  assistantPrefix: authorization.ascii ? "[assistant] " : "│ ",
169
169
  assistantEnabled,
170
170
  collapseHiddenThinking: thinkingCollapseEnabled,
171
+ hideInterimText: config.messages.hideInterimText && messagesEnabled,
171
172
  },
172
173
  toolSnapshot: {
173
174
  callMarker: authorization.ascii ? "[tool] " : "[tool] ",
@@ -369,7 +369,13 @@ function shape(spec: TargetSpec): boolean {
369
369
  export interface CompatibilityProbeOptions {
370
370
  markers?: Set<string>;
371
371
  config?: Readonly<{
372
- messages: { enabled: boolean; assistantPrefix: boolean; specialBlocks: boolean; hideThinkingLabel: boolean };
372
+ messages: {
373
+ enabled: boolean;
374
+ assistantPrefix: boolean;
375
+ specialBlocks: boolean;
376
+ hideThinkingLabel: boolean;
377
+ hideInterimText: boolean;
378
+ };
373
379
  tools: { enabled: boolean; style: string; maxCollapsedLines: number; maxExpandedLines: number; dimOutput: boolean };
374
380
  preset: string;
375
381
  }>;
@@ -417,7 +423,7 @@ function surfaceDisabled(spec: TargetSpec, config: CompatibilityProbeOptions["co
417
423
  if (!config.messages.enabled) return true;
418
424
  if (spec.subtype === "native-assistant-message" && spec.method === "render") return !config.messages.assistantPrefix;
419
425
  if (spec.subtype === "native-assistant-message" && spec.method === "updateContent")
420
- return !config.messages.hideThinkingLabel;
426
+ return !config.messages.hideThinkingLabel && !config.messages.hideInterimText;
421
427
  if (isSpecialBlock(spec)) return !config.messages.specialBlocks;
422
428
  return true;
423
429
  }
@@ -1,6 +1,14 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { StatusSnapshot } from "../domain/status.js";
3
3
  import { closeActiveBatch } from "../features/tools/boxed/batch.js";
4
+ import {
5
+ beginAgentRun,
6
+ finishAgentRun,
7
+ invalidateTurnMembers,
8
+ rebuildTurnRegistryFromEntries,
9
+ registerTurnFromMessage,
10
+ } from "../features/tools/boxed/turn-summary.js";
11
+ import { requestToolPresentationRender } from "../features/tools/index.js";
4
12
  import { registerPiStyleCommand } from "./commands.js";
5
13
  import { type CompatibilityTestHooks, createPiStyleSessionCoordinator } from "./session-coordinator.js";
6
14
  import { usageFromSession } from "./session-usage.js";
@@ -66,7 +74,12 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
66
74
  }
67
75
  await coordinator.start(event, ctx);
68
76
  });
69
- pi.on("agent_start", () => coordinator.app.runtime.current?.dismissStartup());
77
+ pi.on("agent_start", () => {
78
+ coordinator.app.runtime.current?.dismissStartup();
79
+ // Turn summary (ADR 0007): a summary group spans the whole agent run
80
+ // (user request → agent_end), not pi's per-message turn_end.
81
+ beginAgentRun();
82
+ });
70
83
  pi.on("input", (event, _ctx) => {
71
84
  coordinator.app.runtime.current?.dismissStartup();
72
85
  // Bare `!`/`!!` submit guard: Pi treats `!`-prefixed input as a direct bash
@@ -111,9 +124,30 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
111
124
  // message/turn boundaries, mirroring Pi's native footer; per-chunk updates
112
125
  // stay usage-free to keep streaming cheap.
113
126
  pi.on("message_end", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "coalesced"));
114
- pi.on("turn_end", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "deferred"));
127
+ pi.on("turn_end", (event, ctx) => {
128
+ // Append the finalized assistant message's tool batch to the current run.
129
+ registerTurnFromMessage(event.message, event.toolResults);
130
+ coordinator.app.update({ ...usagePatch(ctx) }, "deferred");
131
+ });
132
+ pi.on("agent_end", () => {
133
+ // The run is complete: collapse its tool blocks into one summary line.
134
+ // Pi only re-invokes the tool renderer selectors from updateDisplay(), so
135
+ // the captured per-block invalidate callbacks force the collapse and the
136
+ // captured Tui repaints. Interrupted runs (a call without a result) stay
137
+ // expanded.
138
+ const run = finishAgentRun();
139
+ if (run) {
140
+ invalidateTurnMembers(run);
141
+ requestToolPresentationRender();
142
+ }
143
+ });
115
144
  pi.on("agent_settled", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "coalesced"));
116
- pi.on("session_tree", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "deferred"));
145
+ pi.on("session_tree", (_event, ctx) => {
146
+ // Rebuild the turn registry from session content so restored/branched
147
+ // history renders collapsed consistently (no in-process turn_end events).
148
+ rebuildTurnRegistryFromEntries(ctx.sessionManager.getEntries());
149
+ coordinator.app.update({ ...usagePatch(ctx) }, "deferred");
150
+ });
117
151
  pi.on("session_compact", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "deferred"));
118
152
  pi.on("tool_result", (event, ctx) => {
119
153
  if (["write", "edit", "bash"].includes(event.toolName)) {
@@ -13,6 +13,7 @@ import {
13
13
  stopAllElapsedTickers,
14
14
  type ToolsRenderConfig,
15
15
  } from "../features/tools/boxed/session-config.js";
16
+ import { rebuildTurnRegistryFromEntries, resetTurnRegistry } from "../features/tools/boxed/turn-summary.js";
16
17
  import { createCompatibilityCoordinator } from "./compatibility-coordinator.js";
17
18
  import {
18
19
  type CompatibilityCleanupResult,
@@ -175,6 +176,11 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
175
176
  resetBatchRegistry();
176
177
  resetGrepRegistry();
177
178
  resetBashTreeRegistry();
179
+ // Turn summaries (ADR 0007): rebuild the registry from session content so
180
+ // restored/forked history renders collapsed before the first render pass
181
+ // (deterministic; no in-process turn_end events needed).
182
+ resetTurnRegistry();
183
+ rebuildTurnRegistryFromEntries(ctx.sessionManager.getEntries());
178
184
  // Stop any 1s elapsed re-render ticker left by a tool that was still
179
185
  // running when the session ended.
180
186
  stopAllElapsedTickers();
@@ -247,6 +253,7 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
247
253
  resetBatchRegistry();
248
254
  resetGrepRegistry();
249
255
  resetBashTreeRegistry();
256
+ resetTurnRegistry();
250
257
  stopAllElapsedTickers();
251
258
  app.sessionShutdown();
252
259
  // Tier C prototype patches stay installed across session switches. Pi renders
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quandev104/pi-style",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "A native-layout, cohesive visual style package for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",