@quandev104/pi-style 0.1.6 → 0.2.0

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,413 @@
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 that collapses under the render
6
+ // config); every other collapsible tool item of the turn renders zero lines.
7
+ // Error results stay visible, interrupted turns never collapse, and Pi's
8
+ // global tool-output toggle (Ctrl+O) expands everything again
9
+ // (`options.expanded` is read, never written).
10
+ //
11
+ // Mutating tools (edit/write/quick_edit/substitute_edit/target_edit) are
12
+ // exempt from the summary by default (`tools.collapseMutatingTools: off`):
13
+ // their blocks are the record of what was done to the user's files, so they
14
+ // always stay visible (compact preview) even in an ended turn — the summary
15
+ // covers only read-only tools (read/ls/find/grep/bash). Turning the leaf on
16
+ // restores the full collapse.
17
+ //
18
+ // Design notes:
19
+ // - The registry is populated from **session content**, never from runtime
20
+ // event flags: the live path registers the final assistant message +
21
+ // toolResults at `turn_end`; the restore path rebuilds the registry from
22
+ // `sessionManager.getEntries()` at session start / `session_tree`, so
23
+ // scroll-back and session resume render identically.
24
+ // - A turn is "ended" only when every tool call of its message has a matching
25
+ // tool result AND (live) turn_end fired / (restore) the message is
26
+ // finalized (`stopReason`) or a later user/assistant message exists.
27
+ // - Elapsed per member is frozen from the renderer wall-clock state
28
+ // (STARTED_AT/ENDED_AT) at the first post-turn result pass; the summary
29
+ // totals the members' frozen elapsed. No render-time I/O.
30
+ // - No new Pi-core patch identity: the dispatcher (boxed/index.ts) decides
31
+ // collapse before the per-tool renderers run, so every certified renderer
32
+ // surface stays untouched when the turn is not collapsed.
33
+
34
+ import type { Component } from "@earendil-works/pi-tui";
35
+ import type { BoxTheme } from "../../../shared/box.js";
36
+ import { safeTruncateToWidth } from "../../../shared/render-budget.js";
37
+ import { pluralForm } from "./output-tree.js";
38
+ import { getToolsRenderConfig } from "./session-config.js";
39
+
40
+ export interface TurnMemberInfo {
41
+ readonly toolCallId: string;
42
+ readonly toolName: string;
43
+ /** Whether a tool result was registered for this call (run completeness). */
44
+ readonly hasResult: boolean;
45
+ isError: boolean;
46
+ /** Frozen wall-clock elapsed (ms), recorded from the renderer context state. */
47
+ elapsedMs?: number;
48
+ }
49
+
50
+ export interface TurnState {
51
+ /**
52
+ * First non-error member that collapses under the current render config
53
+ * (mutating members are skipped unless `tools.collapseMutatingTools` is
54
+ * on); renders the summary line. Empty when every member errored or when
55
+ * the turn's members are all mutating with the exemption active (such a
56
+ * turn collapses nothing).
57
+ */
58
+ leaderId: string;
59
+ ended: boolean;
60
+ members: readonly TurnMemberInfo[];
61
+ }
62
+
63
+ /**
64
+ * Tools that change the user's files. Their blocks are the record of what was
65
+ * done — they stay visible after the turn and are excluded from the summary
66
+ * unless `tools.collapseMutatingTools` is on. bash is deliberately NOT here:
67
+ * read-only and mutating commands are indistinguishable without parsing the
68
+ * command text.
69
+ */
70
+ const MUTATING_TOOLS: ReadonlySet<string> = new Set(["edit", "write", "quick_edit", "substitute_edit", "target_edit"]);
71
+
72
+ /** Whether the tool changes the user's files (exempt from turn collapse). */
73
+ export function isMutatingTool(toolName: string): boolean {
74
+ return MUTATING_TOOLS.has(toolName);
75
+ }
76
+
77
+ /** Whether the summary should also cover mutating tools (render config). */
78
+ function mutatingCollapses(): boolean {
79
+ return getToolsRenderConfig().collapseMutatingTools;
80
+ }
81
+
82
+ interface TurnEntry {
83
+ readonly turn: TurnState;
84
+ readonly member: TurnMemberInfo;
85
+ }
86
+
87
+ const memberByCallId = new Map<string, TurnEntry>();
88
+
89
+ /** Per-member component invalidate callbacks captured during render passes. */
90
+ const invalidateByCallId = new Map<string, () => void>();
91
+
92
+ /** Reset all turn state (session start/shutdown). */
93
+ export function resetTurnRegistry(): void {
94
+ memberByCallId.clear();
95
+ invalidateByCallId.clear();
96
+ }
97
+
98
+ interface ToolCallLike {
99
+ readonly type?: unknown;
100
+ readonly id?: unknown;
101
+ readonly name?: unknown;
102
+ }
103
+
104
+ function toolCallsOf(message: unknown): ToolCallLike[] {
105
+ if (!message || typeof message !== "object") return [];
106
+ const content = (message as { readonly content?: unknown }).content;
107
+ if (!Array.isArray(content)) return [];
108
+ const calls: ToolCallLike[] = [];
109
+ for (const item of content) {
110
+ if (!item || typeof item !== "object") continue;
111
+ const candidate = item as ToolCallLike;
112
+ if (candidate.type === "toolCall") calls.push(candidate);
113
+ }
114
+ return calls;
115
+ }
116
+
117
+ function registerTurn(
118
+ calls: readonly ToolCallLike[],
119
+ isErrorById: ReadonlyMap<string, boolean>,
120
+ ended: boolean,
121
+ ): TurnState | undefined {
122
+ if (calls.length === 0) return undefined;
123
+ const complete = calls.every((call) => typeof call.id === "string" && isErrorById.has(call.id));
124
+ const members: TurnMemberInfo[] = calls.map((call) => {
125
+ const toolCallId = String(call.id ?? "");
126
+ return {
127
+ toolCallId,
128
+ toolName: typeof call.name === "string" ? call.name : "tool",
129
+ hasResult: isErrorById.has(toolCallId),
130
+ isError: isErrorById.get(toolCallId) === true,
131
+ };
132
+ });
133
+ const leader = members.find((member) => !member.isError && (!isMutatingTool(member.toolName) || mutatingCollapses()));
134
+ const turn: TurnState = {
135
+ leaderId: leader?.toolCallId ?? "",
136
+ ended: ended && complete,
137
+ members: Object.freeze(members),
138
+ };
139
+ for (const member of members) memberByCallId.set(member.toolCallId, { turn, member });
140
+ return turn;
141
+ }
142
+
143
+ export interface TurnResultLike {
144
+ readonly toolCallId: string;
145
+ readonly isError?: boolean;
146
+ }
147
+
148
+ /**
149
+ * One summary group = one agent run (user request → `agent_end`). Pi emits
150
+ * `turn_end` per assistant message, so tool batches of the same request are
151
+ * appended to the same run and collapse into ONE summary line at `agent_end`.
152
+ */
153
+ let currentRun: TurnState | undefined;
154
+
155
+ /** Live path: start a fresh run group (`agent_start`). */
156
+ export function beginAgentRun(): void {
157
+ currentRun = undefined;
158
+ }
159
+
160
+ /**
161
+ * Live path: append the finalized assistant message's tool calls and results
162
+ * to the current run (`turn_end` event). The run stays expanded until
163
+ * `finishAgentRun`; a batch interrupted mid-tool never collapses.
164
+ */
165
+ export function registerTurnFromMessage(message: unknown, toolResults: readonly TurnResultLike[]): void {
166
+ const calls = toolCallsOf(message);
167
+ if (calls.length === 0) return;
168
+ const isErrorById = new Map<string, boolean>();
169
+ for (const result of toolResults) {
170
+ if (typeof result?.toolCallId !== "string") continue;
171
+ isErrorById.set(result.toolCallId, result.isError === true);
172
+ }
173
+ const newMembers: TurnMemberInfo[] = calls.map((call) => {
174
+ const toolCallId = String(call.id ?? "");
175
+ return {
176
+ toolCallId,
177
+ toolName: typeof call.name === "string" ? call.name : "tool",
178
+ hasResult: isErrorById.has(toolCallId),
179
+ isError: isErrorById.get(toolCallId) === true,
180
+ };
181
+ });
182
+ const leader = newMembers.find(
183
+ (member) => !member.isError && (!isMutatingTool(member.toolName) || mutatingCollapses()),
184
+ );
185
+ if (!currentRun) {
186
+ currentRun = {
187
+ leaderId: leader?.toolCallId ?? "",
188
+ ended: false,
189
+ members: Object.freeze(newMembers),
190
+ };
191
+ } else {
192
+ if (currentRun.leaderId === "" && leader) currentRun.leaderId = leader.toolCallId;
193
+ currentRun.members = Object.freeze([...currentRun.members, ...newMembers]);
194
+ }
195
+ for (const member of newMembers) memberByCallId.set(member.toolCallId, { turn: currentRun, member });
196
+ }
197
+
198
+ /**
199
+ * Live path: finalize the current run (`agent_end`). Returns the run so the
200
+ * caller can invalidate its blocks; undefined when the run had no tool calls
201
+ * or is interrupted (a call without a result stays expanded).
202
+ */
203
+ export function finishAgentRun(): TurnState | undefined {
204
+ const run = currentRun;
205
+ currentRun = undefined;
206
+ if (!run) return undefined;
207
+ // A member without a result means the run was interrupted before every call
208
+ // settled; such a run never collapses.
209
+ if (run.members.every((member) => member.hasResult)) run.ended = true;
210
+ return run.ended ? run : undefined;
211
+ }
212
+
213
+ interface TurnEntryLike {
214
+ readonly type?: unknown;
215
+ readonly message?: {
216
+ readonly role?: unknown;
217
+ readonly content?: unknown;
218
+ readonly stopReason?: unknown;
219
+ readonly toolCallId?: unknown;
220
+ readonly isError?: unknown;
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Restore path: rebuild the registry from session entries (session start /
226
+ * `session_tree`). Consecutive assistant messages between user messages form
227
+ * one run; a run is ended when every tool call has a result AND a later user
228
+ * message exists (historical) or its last assistant message is finalized
229
+ * (`stopReason`). The currently streaming run stays expanded.
230
+ */
231
+ export function rebuildTurnRegistryFromEntries(entries: readonly TurnEntryLike[] | undefined): void {
232
+ memberByCallId.clear();
233
+ if (!Array.isArray(entries)) return;
234
+ const isErrorById = new Map<string, boolean>();
235
+ const resultById = new Set<string>();
236
+ const runs: Array<{
237
+ calls: ToolCallLike[];
238
+ lastStopReason: string | undefined;
239
+ followedByUser: boolean;
240
+ }> = [];
241
+ let current: (typeof runs)[number] | undefined;
242
+ const closeRun = () => {
243
+ if (current && current.calls.length > 0) runs.push(current);
244
+ current = undefined;
245
+ };
246
+ entries.forEach((entry) => {
247
+ if (entry?.type !== "message") return;
248
+ const message = entry.message;
249
+ if (message?.role === "toolResult" && typeof message.toolCallId === "string") {
250
+ resultById.add(message.toolCallId);
251
+ isErrorById.set(message.toolCallId, message.isError === true);
252
+ } else if (message?.role === "assistant") {
253
+ if (!current) current = { calls: [], lastStopReason: undefined, followedByUser: false };
254
+ const calls = toolCallsOf(message);
255
+ current.calls.push(...calls);
256
+ if (typeof message.stopReason === "string" && message.stopReason !== "")
257
+ current.lastStopReason = message.stopReason;
258
+ } else if (message?.role === "user") {
259
+ if (current) {
260
+ current.followedByUser = true;
261
+ closeRun();
262
+ } else {
263
+ // A user message with no preceding open run is a plain boundary.
264
+ closeRun();
265
+ }
266
+ }
267
+ });
268
+ closeRun();
269
+ for (const run of runs) {
270
+ const complete = run.calls.every((call) => typeof call.id === "string" && resultById.has(call.id));
271
+ const ended = complete && (run.followedByUser || run.lastStopReason !== undefined);
272
+ registerTurn(run.calls, isErrorById, ended);
273
+ }
274
+ }
275
+
276
+ /** Registry lookup for the render dispatcher. */
277
+ export function getTurnEntry(toolCallId: string): TurnEntry | undefined {
278
+ return memberByCallId.get(toolCallId);
279
+ }
280
+
281
+ /**
282
+ * Capture a member's component invalidate callback during a render pass. Pi
283
+ * only re-invokes the tool renderer selectors from updateDisplay(); calling
284
+ * the captured callback after turn_end rebuilds the block with the collapsed
285
+ * summary. Idempotent per toolCallId (latest component wins).
286
+ */
287
+ export function noteTurnMemberRender(toolCallId: string, invalidate: () => void): void {
288
+ if (typeof invalidate !== "function") return;
289
+ invalidateByCallId.set(toolCallId, invalidate);
290
+ }
291
+
292
+ /**
293
+ * Force the just-finished turn's tool blocks to re-render (updateDisplay).
294
+ * Components that never rendered (headless/print) have no captured callback.
295
+ */
296
+ export function invalidateTurnMembers(turn: TurnState): void {
297
+ for (const member of turn.members) {
298
+ const invalidate = invalidateByCallId.get(member.toolCallId);
299
+ if (!invalidate) continue;
300
+ try {
301
+ invalidate();
302
+ } catch {
303
+ // A detached component must not break the turn-end path.
304
+ invalidateByCallId.delete(member.toolCallId);
305
+ }
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Freeze a member's wall-clock elapsed into the registry (idempotent; the
311
+ * value is frozen by the renderer state once the terminal result rendered).
312
+ */
313
+ export function noteTurnMemberElapsed(toolCallId: string, elapsedMs: number | undefined): void {
314
+ if (elapsedMs === undefined) return;
315
+ const entry = memberByCallId.get(toolCallId);
316
+ if (!entry || entry.member.elapsedMs !== undefined) return;
317
+ entry.member.elapsedMs = elapsedMs;
318
+ }
319
+
320
+ /** Per-tool summary phrasing: `Read 2 files` / `ran 4 shell commands`. */
321
+ const TURN_SUMMARY_STYLE: Readonly<Record<string, { readonly verb: string; readonly unit: string }>> = Object.freeze({
322
+ read: { verb: "Read", unit: "file" },
323
+ bash: { verb: "ran", unit: "shell command" },
324
+ ls: { verb: "Listed", unit: "path" },
325
+ find: { verb: "Found", unit: "file" },
326
+ grep: { verb: "Grepped", unit: "pattern" },
327
+ edit: { verb: "Edited", unit: "file" },
328
+ write: { verb: "Wrote", unit: "file" },
329
+ quick_edit: { verb: "Edited", unit: "file" },
330
+ substitute_edit: { verb: "Edited", unit: "file" },
331
+ target_edit: { verb: "Edited", unit: "file" },
332
+ });
333
+
334
+ export interface TurnSummaryParts {
335
+ /** `Read 2 files`, `ran 4 shell commands`, ... in first-use order. */
336
+ readonly parts: readonly string[];
337
+ readonly failedCount: number;
338
+ /** Sum of members' frozen elapsed; undefined when nothing was recorded. */
339
+ readonly elapsedMs: number | undefined;
340
+ }
341
+
342
+ /**
343
+ * Aggregate a turn's collapsed members into summary parts (pure). Mutating
344
+ * members are excluded unless `tools.collapseMutatingTools` is on — by default
345
+ * their visible blocks are the record; the summary describes only what it
346
+ * hides.
347
+ */
348
+ export function turnSummaryParts(turn: TurnState): TurnSummaryParts {
349
+ const counts = new Map<string, number>();
350
+ const order: string[] = [];
351
+ let failedCount = 0;
352
+ let elapsedMs: number | undefined;
353
+ const collapseMutating = mutatingCollapses();
354
+ for (const member of turn.members) {
355
+ if (member.isError) {
356
+ failedCount++;
357
+ continue;
358
+ }
359
+ if (!collapseMutating && isMutatingTool(member.toolName)) continue;
360
+ if (member.elapsedMs !== undefined) elapsedMs = (elapsedMs ?? 0) + member.elapsedMs;
361
+ const existing = counts.get(member.toolName);
362
+ if (existing === undefined) {
363
+ counts.set(member.toolName, 1);
364
+ order.push(member.toolName);
365
+ } else counts.set(member.toolName, existing + 1);
366
+ }
367
+ const parts = order.map((toolName) => {
368
+ const count = counts.get(toolName) ?? 0;
369
+ const style = TURN_SUMMARY_STYLE[toolName];
370
+ // Unknown tools (extension tools like TaskCreate/ask_user_question) use a
371
+ // neutral phrasing with the invariant tool name: `used 5 TaskCreate`.
372
+ return style ? `${style.verb} ${count} ${pluralForm(style.unit, count)}` : `used ${count} ${toolName}`;
373
+ });
374
+ return { parts, failedCount, elapsedMs };
375
+ }
376
+
377
+ function formatTurnSummaryLine(theme: BoxTheme, turn: TurnState): string {
378
+ const summary = turnSummaryParts(turn);
379
+ // The summary is deliberately quiet: the whole line renders dim so completed
380
+ // tool work recedes behind the assistant's answer. Only the failed marker
381
+ // stays error-colored (errors must remain visible).
382
+ const parts = summary.parts.join(", ");
383
+ let line = `${theme.fg("dim", `➔ ${parts}`)}`;
384
+ if (summary.failedCount > 0)
385
+ line += theme.fg("error", ` · ${summary.failedCount} ${pluralForm("failure", summary.failedCount)}`);
386
+ if (summary.elapsedMs !== undefined) line += theme.fg("dim", ` · ${(summary.elapsedMs / 1000).toFixed(2)}s`);
387
+ return line;
388
+ }
389
+
390
+ /** Leader call component: renders the live turn summary line on every pass. */
391
+ export function renderTurnSummaryCall(theme: BoxTheme, turn: TurnState): Component {
392
+ return {
393
+ invalidate() {},
394
+ render(width: number): string[] {
395
+ return [safeTruncateToWidth(formatTurnSummaryLine(theme, turn), Math.max(1, width), "…")];
396
+ },
397
+ };
398
+ }
399
+
400
+ /**
401
+ * Empty result component for the turn-summary leader. The summary lives in the
402
+ * call component; the result adds nothing. Deliberately NOT the shared
403
+ * EMPTY_BATCH_COMPONENT singleton, so the decoration's hideBatchMember
404
+ * (identity-compared) never hides the leader.
405
+ */
406
+ export function emptyTurnResult(): Component {
407
+ return {
408
+ invalidate() {},
409
+ render() {
410
+ return [];
411
+ },
412
+ };
413
+ }
@@ -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;
@@ -408,16 +423,31 @@ export function createToolDecorationOwner(snapshot: Partial<ToolDecorationSnapsh
408
423
  if (typeof renderer !== "function") {
409
424
  neutralizeToolContainerBackground(instance);
410
425
  if (subtype === "tool-call-renderer")
411
- return (callArgs: unknown, theme: unknown, context: unknown) =>
412
- renderBoxedToolCall(toolName, callArgs as Record<string, unknown>, theme as never, context as never);
413
- return (result: unknown, options: unknown, theme: unknown, context: unknown) =>
414
- renderBoxedToolResult(
426
+ return (callArgs: unknown, theme: unknown, context: unknown) => {
427
+ const component = renderBoxedToolCall(
428
+ toolName,
429
+ callArgs as Record<string, unknown>,
430
+ theme as never,
431
+ context as never,
432
+ );
433
+ // Same batch-member contract as the native-renderer path: a
434
+ // collapsed turn member (or quiet batch member) returns the
435
+ // singleton and must be hidden, or Pi leaves a stray native
436
+ // placeholder row per block after the collapse.
437
+ if (component === EMPTY_BATCH_COMPONENT) hideBatchMember(instance);
438
+ return component;
439
+ };
440
+ return (result: unknown, options: unknown, theme: unknown, context: unknown) => {
441
+ const component = renderBoxedToolResult(
415
442
  toolName,
416
443
  result as { content?: readonly unknown[]; details?: unknown },
417
444
  options as { expanded: boolean; isPartial: boolean },
418
445
  theme as never,
419
446
  context as never,
420
447
  );
448
+ if (component === EMPTY_BATCH_COMPONENT) hideBatchMember(instance);
449
+ return component;
450
+ };
421
451
  }
422
452
  return function (this: unknown, ...rendererArgs: unknown[]) {
423
453
  const valid = subtype === "tool-call-renderer" ? validCallArgs(rendererArgs) : validResultArgs(rendererArgs);
@@ -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
@@ -567,8 +567,20 @@ function renderBoxedOutputLines(
567
567
  let truncated = false;
568
568
 
569
569
  for (; nextInputIndex < outputLines.length; nextInputIndex++) {
570
- const line = boxedTruncatedLine(theme, outputLines[nextInputIndex] ?? "", width);
571
- if (!pushBoundedLines(head, [line], headLimit)) {
570
+ // An output "line" may carry embedded newlines (raw tool error messages,
571
+ // JSON payloads). Split before boxing so every fragment gets its own
572
+ // border and truncation — otherwise the box frame visually breaks on the
573
+ // embedded rows.
574
+ const fragments = (outputLines[nextInputIndex] ?? "").split("\n");
575
+ let headExceeded = false;
576
+ for (const fragment of fragments) {
577
+ const line = boxedTruncatedLine(theme, fragment, width);
578
+ if (!pushBoundedLines(head, [line], headLimit)) {
579
+ headExceeded = true;
580
+ break;
581
+ }
582
+ }
583
+ if (headExceeded) {
572
584
  truncated = true;
573
585
  nextInputIndex++;
574
586
  break;
@@ -580,9 +592,12 @@ function renderBoxedOutputLines(
580
592
  const tail: string[] = [];
581
593
  const tailStart = Math.max(nextInputIndex, outputLines.length - tailLimit);
582
594
  for (let i = tailStart; i < outputLines.length; i++) {
583
- const line = boxedTruncatedLine(theme, outputLines[i] ?? "", width);
584
- tail.push(line);
585
- if (tail.length > tailLimit) tail.splice(0, tail.length - tailLimit);
595
+ const fragments = (outputLines[i] ?? "").split("\n");
596
+ for (const fragment of fragments) {
597
+ const line = boxedTruncatedLine(theme, fragment, width);
598
+ tail.push(line);
599
+ if (tail.length > tailLimit) tail.splice(0, tail.length - tailLimit);
600
+ }
586
601
  }
587
602
 
588
603
  const skippedInputLines = Math.max(0, tailStart - nextInputIndex);
@@ -764,6 +779,10 @@ export function renderBoxedToolResult(
764
779
  bodyLines.length > 0
765
780
  ? [...errorPrefix, ...bodyLines]
766
781
  : [theme.fg("muted", `∅ ${options.emptyText ?? "(no output)"}`)];
782
+ // Split embedded newlines before budgeting so raw multi-line messages
783
+ // (tool validation errors, JSON payloads) render as proper bordered
784
+ // rows instead of one "line" whose embedded rows break the frame.
785
+ const outputFragments = outputLines.flatMap((line) => line.split("\n"));
767
786
  const footerText = (options.footerLines ?? []).join(" · ");
768
787
  const dividerText =
769
788
  typeof options.dividerLabel === "function"
@@ -783,7 +802,12 @@ export function renderBoxedToolResult(
783
802
  ),
784
803
  ]),
785
804
  boxBlankLine(theme, renderedWidth),
786
- ...renderBoxedOutputLines(theme, outputLines, renderedWidth, options.renderLineBudget ?? outputLines.length),
805
+ ...renderBoxedOutputLines(
806
+ theme,
807
+ outputFragments,
808
+ renderedWidth,
809
+ options.renderLineBudget ?? outputFragments.length,
810
+ ),
787
811
  boxBlankLine(theme, renderedWidth),
788
812
  boxLabeledBorder(
789
813
  theme,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quandev104/pi-style",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "A native-layout, cohesive visual style package for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",