pi-my-compact-ui 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/index.ts +1641 -0
  4. package/package.json +50 -0
package/index.ts ADDED
@@ -0,0 +1,1641 @@
1
+ /**
2
+ * compact-ui: collect thinking runs + tool calls into a single tree-shaped block.
3
+ *
4
+ * Tool calls are intercepted at the container-prototype level (like
5
+ * pi-cc-extensions) and collected into a ToolGroupComponent rendered in place
6
+ * in the transcript. Thinking is captured from message_update events; every run
7
+ * is appended to the same block as its own entry.
8
+ *
9
+ * Entries are listed in stream order, oldest first, one line each. The entry
10
+ * row spins while it is still streaming:
11
+ * ⠋ tool calling...
12
+ * │ ⠋ thinking: Planning the fix...
13
+ * │ ✓ bash: ls /tmp && cat fi... (3s)
14
+ * └ ✓ thinking: The cache is stale · 1.2K tok
15
+ *
16
+ * Ctrl+O toggles collapse/expand (via setExpanded, same as built-in tools).
17
+ * Expand line counts are configurable via /compact-ui-config (interactive
18
+ * settings menu, arrows to select, Enter to adjust, Esc to close) and are
19
+ * persisted to ~/.pi/agent/compact-ui.json:
20
+ * { "expandedToolLines": 5, "expandedThinkingLines": 10 }
21
+ */
22
+
23
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
24
+ import {
25
+ AssistantMessageComponent,
26
+ CompactionSummaryMessageComponent,
27
+ ToolExecutionComponent,
28
+ createBashTool,
29
+ createEditTool,
30
+ createFindTool,
31
+ createGrepTool,
32
+ createLsTool,
33
+ createReadTool,
34
+ createWriteTool,
35
+ getMarkdownTheme,
36
+ getSettingsListTheme,
37
+ } from "@earendil-works/pi-coding-agent";
38
+ import {
39
+ Container,
40
+ Key,
41
+ Markdown,
42
+ SettingsList,
43
+ Spacer,
44
+ Text,
45
+ matchesKey,
46
+ stripTerminalSequences,
47
+ truncateToWidth,
48
+ visibleWidth,
49
+ wrapTextWithAnsi,
50
+ } from "@earendil-works/pi-tui";
51
+ import type { Component, DefaultTextStyle, MarkdownTheme, SettingItem } from "@earendil-works/pi-tui";
52
+ import { readFileSync, writeFileSync } from "fs";
53
+ import { homedir } from "os";
54
+ import { join } from "path";
55
+
56
+ // =============================================================================
57
+ // Config
58
+ // =============================================================================
59
+ const CONFIG_PATH = join(homedir(), ".pi", "agent", "compact-ui.json");
60
+ const DEFAULT_CONFIG = { expandedToolLines: 5, expandedThinkingLines: 10 };
61
+ let config = { ...DEFAULT_CONFIG };
62
+ try {
63
+ config = { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) };
64
+ } catch {
65
+ // first run — use defaults
66
+ }
67
+
68
+ function saveConfig(): void {
69
+ try {
70
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
71
+ } catch {
72
+ // ignore
73
+ }
74
+ }
75
+
76
+ // Interactive editor metadata for each numeric option.
77
+ const CONFIG_KEYS = [
78
+ {
79
+ id: "expandedToolLines",
80
+ label: "Expanded tool lines",
81
+ description: "Result lines shown per tool when expanded",
82
+ min: 1,
83
+ max: 50,
84
+ step: 1,
85
+ },
86
+ {
87
+ id: "expandedThinkingLines",
88
+ label: "Expanded thinking lines",
89
+ description: "Thinking lines shown when expanded",
90
+ min: 1,
91
+ max: 100,
92
+ step: 1,
93
+ },
94
+ ] as const;
95
+
96
+ // Numeric stepper submenu: ◀/▶ (or −/+) adjust the value, Enter saves, Esc
97
+ // cancels. `done(undefined)` means "no change".
98
+ function makeStepper(
99
+ title: string,
100
+ initial: number,
101
+ meta: { min: number; max: number; step: number },
102
+ theme: any,
103
+ done: (value?: string) => void,
104
+ ): Component {
105
+ let value = initial;
106
+ let cachedWidth: number | undefined;
107
+ let cachedLines: string[] | undefined;
108
+ const fg = (color: string, t: string) => theme?.fg?.(color, t) ?? t;
109
+
110
+ return {
111
+ render(width: number): string[] {
112
+ if (cachedLines && cachedWidth === width) return cachedLines;
113
+ const barLen = Math.max(1, Math.min(width - 10, 40));
114
+ const ratio = (value - meta.min) / Math.max(1, meta.max - meta.min);
115
+ const filled = Math.round(ratio * barLen);
116
+ const bar = "█".repeat(filled) + "░".repeat(Math.max(0, barLen - filled));
117
+ const titleText = theme?.bold ? theme.bold(title) : title;
118
+ cachedLines = [
119
+ fg("accent", titleText),
120
+ "",
121
+ ` ${fg("accent", String(value))}`,
122
+ ` ${fg("muted", bar)}`,
123
+ "",
124
+ fg("dim", " ◀ ▶ / − + adjust Enter save Esc cancel"),
125
+ ].map((line) => truncateToWidth(line, Math.max(1, width)));
126
+ cachedWidth = width;
127
+ return cachedLines;
128
+ },
129
+ handleInput(data: string): void {
130
+ if (matchesKey(data, Key.left) || matchesKey(data, Key.down) || data === "-" || data === "_") {
131
+ value = Math.max(meta.min, value - meta.step);
132
+ } else if (matchesKey(data, Key.right) || matchesKey(data, Key.up) || data === "+" || data === "=") {
133
+ value = Math.min(meta.max, value + meta.step);
134
+ } else if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) {
135
+ done(String(value));
136
+ return;
137
+ } else if (matchesKey(data, Key.escape)) {
138
+ done(undefined);
139
+ return;
140
+ }
141
+ cachedWidth = undefined;
142
+ },
143
+ invalidate(): void {
144
+ cachedWidth = undefined;
145
+ },
146
+ };
147
+ }
148
+
149
+ // =============================================================================
150
+ // Shared state
151
+ // =============================================================================
152
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
153
+ // 100ms/frame (10fps) matches pi's default spinner cadence; 300ms felt laggy.
154
+ const SPINNER_MS = 100;
155
+ const GROUP_PADDING_X = 1;
156
+ const spinnerStart = Date.now();
157
+ const PARENT_KEY = Symbol.for("compact-ui.group-parent");
158
+ const PATCH_KEY = Symbol.for("compact-ui.group-patch");
159
+ const MARKDOWN_RENDER_PATCH_KEY = Symbol.for("compact-ui.markdown-render-patch");
160
+ const COMPACTION_STYLE_PATCH_KEY = Symbol.for("compact-ui.compaction-style-patch");
161
+ const ASSISTANT_THINKING_PATCH_KEY = Symbol.for("compact-ui.assistant-thinking-patch");
162
+
163
+ // -----------------------------------------------------------------------------
164
+ // Compact block contents. A block is an ordered list of the exact items the
165
+ // model produced, interleaving thinking runs with tool calls. Thinking runs are
166
+ // never merged: each run keeps its own row (spinner while streaming, completion
167
+ // mark afterwards).
168
+ // -----------------------------------------------------------------------------
169
+ type ThinkingEntry = {
170
+ kind: "thinking";
171
+ id: number;
172
+ text: string;
173
+ tokens: number;
174
+ tokensExact: boolean;
175
+ /** Still streaming — renders the animated spinner instead of the done mark. */
176
+ active: boolean;
177
+ /** Group currently owning this entry, used for targeted invalidation. */
178
+ owner: ToolGroupComponent | null;
179
+ };
180
+
181
+ type GroupEntry = { kind: "tool"; tool: any } | ThinkingEntry;
182
+
183
+ let currentTheme: any = null;
184
+ // The thinking run currently streaming, if any. thinking_end (or the visible
185
+ // text boundary that seals the block) finalizes it and clears this slot.
186
+ let activeThinking: ThinkingEntry | null = null;
187
+ // contentIndex of the streaming thinking block within the current assistant
188
+ // message, recorded so a late thinking_end for an already-sealed run is ignored.
189
+ let activeThinkingIndex: number | null = null;
190
+ // Cumulative text of the streaming run, keyed by content index.
191
+ const activeThinkingBlocks = new Map<number, string>();
192
+ // Runs created before their group exists (the assistant message component has
193
+ // not been added to the chat container yet). Drained by ensureThinkingGroup().
194
+ const unattachedThinking: ThinkingEntry[] = [];
195
+ // Stable per-run id, used as the Markdown preview cache key.
196
+ let thinkingEntrySeq = 0;
197
+ // message_update contains a cumulative AssistantMessage snapshot. Track stream
198
+ // content indexes so growing text deltas seal a block only once.
199
+ const handledTextIndexes = new Set<number>();
200
+ // Thinking content indexes already sealed in the current assistant message.
201
+ // Providers that finalize content blocks at the end of the stream
202
+ // (openai-completions pushes thinking_end after its delta loop) re-emit the
203
+ // block after the text; those repeats must not spawn a duplicate entry.
204
+ const sealedThinkingIndexes = new Set<number>();
205
+ let pendingTextSeal = false;
206
+ let pendingTextOrdinal: number | null = null;
207
+ let lastActiveGroup: ToolGroupComponent | null = null;
208
+ // Last finalized thinking run. Its token count is usually frozen before the
209
+ // provider reports reasoning usage, so the reported value is re-applied later.
210
+ let lastSealedThinkingEntry: ThinkingEntry | null = null;
211
+ let lastReportedReasoningTokens = 0;
212
+ // Track the current assistant message's component + the container it lives in,
213
+ // so a thinking-only group can be inserted right after it (before any tool).
214
+ let lastStreamingComp: any = null;
215
+ let lastChatContainer: any = null;
216
+ const toolStarts = new Map<string, number>();
217
+ // Wall-clock start of the current turn (user message), used to render the
218
+ // "worked for Xm Ys" divider before the final visible text.
219
+ let turnStartMs = 0;
220
+
221
+ // =============================================================================
222
+ // Tool summary helpers
223
+ // =============================================================================
224
+ function shortenPath(path: string): string {
225
+ const home = homedir();
226
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
227
+ }
228
+
229
+ function oneLine(value: unknown, max = 60): string {
230
+ const text = String(value ?? "").replace(/\s+/g, " ").trim();
231
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
232
+ }
233
+
234
+ function estimateTextTokens(text: string): number {
235
+ return Math.ceil(text.length / 4);
236
+ }
237
+
238
+ function formatTokenK(tokens: number): string {
239
+ if (tokens <= 0) return "0.0K";
240
+ if (tokens < 100) return "<0.1K";
241
+ const value = tokens / 1000;
242
+ return value < 100 ? `${value.toFixed(1)}K` : `${Math.round(value)}K`;
243
+ }
244
+
245
+ function updateActiveThinkingTokens(message: any): void {
246
+ const entry = activeThinking;
247
+ if (!entry) return;
248
+ entry.text = [...activeThinkingBlocks.values()].filter((text) => text.trim()).join("\n\n");
249
+ // Most providers report reasoning usage only when the response finishes. While
250
+ // streaming, fall back to pi's own chars/4 token heuristic and mark it with ≈.
251
+ const reported = Number(message?.usage?.reasoning);
252
+ if (Number.isFinite(reported) && reported > 0) {
253
+ entry.tokens = reported;
254
+ entry.tokensExact = true;
255
+ lastReportedReasoningTokens = reported;
256
+ return;
257
+ }
258
+ entry.tokens = estimateTextTokens(entry.text);
259
+ entry.tokensExact = false;
260
+ }
261
+
262
+ function thinkingTokenLabel(entry: ThinkingEntry): string {
263
+ return `${entry.tokensExact ? "" : "≈"}${formatTokenK(entry.tokens)} tok`;
264
+ }
265
+
266
+ function createThinkingEntry(): ThinkingEntry {
267
+ const entry: ThinkingEntry = {
268
+ kind: "thinking",
269
+ id: thinkingEntrySeq++,
270
+ text: "",
271
+ tokens: 0,
272
+ tokensExact: false,
273
+ active: true,
274
+ owner: null,
275
+ };
276
+ activeThinking = entry;
277
+ activeThinkingBlocks.clear();
278
+ unattachedThinking.push(entry);
279
+ return entry;
280
+ }
281
+
282
+ // Close the streaming run. The entry keeps its text, so sealing a group never
283
+ // needs a separate snapshot; only the spinner has to stop.
284
+ function finalizeActiveThinking(): void {
285
+ const entry = activeThinking;
286
+ if (!entry) return;
287
+ entry.active = false;
288
+ entry.owner?.invalidate();
289
+ if (activeThinkingIndex !== null) sealedThinkingIndexes.add(activeThinkingIndex);
290
+ lastSealedThinkingEntry = entry;
291
+ activeThinking = null;
292
+ activeThinkingIndex = null;
293
+ activeThinkingBlocks.clear();
294
+ }
295
+
296
+ // The entry is finalized at the text boundary, which usually happens before the
297
+ // provider reports exact reasoning usage (and before pi reads it off the
298
+ // finished message). Re-apply the reported value so the row stops showing a
299
+ // stale ≈ estimate for the rest of the turn.
300
+ function refreshSealedThinkingTokens(reportedReasoning?: unknown): void {
301
+ const reported = Number(reportedReasoning);
302
+ if (Number.isFinite(reported) && reported > 0) lastReportedReasoningTokens = reported;
303
+ const entry = lastSealedThinkingEntry;
304
+ if (!entry || entry.tokensExact) return;
305
+ if (!entry.text.trim()) return; // no thinking row to correct
306
+ if (!(lastReportedReasoningTokens > 0)) return;
307
+ entry.tokens = lastReportedReasoningTokens;
308
+ entry.tokensExact = true;
309
+ entry.owner?.invalidate();
310
+ capturedTui?.requestRender?.();
311
+ }
312
+
313
+ function toolSummary(name: string, args: any): { name: string; content: string } {
314
+ switch (name) {
315
+ case "bash":
316
+ return { name: "bash", content: oneLine(args?.command || "…") };
317
+ case "read":
318
+ return { name: "read", content: shortenPath(args?.path || "…") };
319
+ case "write":
320
+ case "edit":
321
+ return { name, content: shortenPath(args?.path || "…") };
322
+ case "find":
323
+ return { name: "find", content: `${oneLine(args?.pattern || "")} in ${shortenPath(args?.path || ".")}` };
324
+ case "grep":
325
+ return { name: "grep", content: `${oneLine(args?.pattern || "")} in ${shortenPath(args?.path || ".")}` };
326
+ case "ls":
327
+ return { name: "ls", content: shortenPath(args?.path || ".") };
328
+ case "web_search":
329
+ return { name: "web_search", content: oneLine(args?.query || "…") };
330
+ case "subagent":
331
+ return { name: "subagent", content: oneLine(args?.agent || args?.task || "…") };
332
+ default: {
333
+ const preferred = args?.path ?? args?.query ?? args?.name ?? args?.description ?? args?.url;
334
+ return { name, content: oneLine(preferred ?? "…") };
335
+ }
336
+ }
337
+ }
338
+
339
+ type ToolStatus = "pending" | "success" | "error";
340
+ function toolStatus(tool: any): ToolStatus {
341
+ if (tool?.result?.isError) return "error";
342
+ if (tool?.isPartial === true || (tool?.executionStarted && !tool?.result)) return "pending";
343
+ return tool?.result ? "success" : "pending";
344
+ }
345
+
346
+ function toolElapsed(tool: any): string {
347
+ const start = toolStarts.get(tool.toolCallId) ?? Date.now();
348
+ const end = tool?.result ? tool._groupEndAt ?? Date.now() : Date.now();
349
+ return ((end - start) / 1000).toFixed(1);
350
+ }
351
+
352
+ function toolResultText(tool: any): string {
353
+ return (tool?.result?.content ?? [])
354
+ .filter((c: any) => c.type === "text")
355
+ .map((c: any) => String(c.text))
356
+ .join("\n")
357
+ .trim();
358
+ }
359
+
360
+ type MarkdownPreview = {
361
+ source: string;
362
+ width: number;
363
+ maxLines: number;
364
+ lines: string[];
365
+ truncated: boolean;
366
+ };
367
+
368
+ // Compact code fence markers are exactly "┌─" or "┌─ <language>", and the
369
+ // close row is exactly "└─". Markdown tables reuse the same box-drawing
370
+ // prefix ("┌─────┬──────┐") and must not enter code-block mode.
371
+ function isCompactCodeBlockOpen(visible: string): boolean {
372
+ return visible === "┌─" || visible.startsWith("┌─ ");
373
+ }
374
+
375
+ function isCompactCodeBlockClose(visible: string): boolean {
376
+ return visible === "└─";
377
+ }
378
+
379
+ // Pi's Markdown component normally renders fenced code blocks with literal
380
+ // ``` delimiters. Emit lightweight internal markers here; the normalization
381
+ // pass replaces them with a padded, theme-aware background block while
382
+ // retaining syntax highlighting.
383
+ export function getCompactMarkdownTheme(): MarkdownTheme {
384
+ const base = getMarkdownTheme();
385
+ let insideCodeBlock = false;
386
+ const theme: MarkdownTheme = {
387
+ ...base,
388
+ codeBlockIndent: "",
389
+ codeBlockBorder(text: string): string {
390
+ const opening = !insideCodeBlock;
391
+ insideCodeBlock = !insideCodeBlock;
392
+ const language = opening ? text.replace(/^```/, "").trim() : "";
393
+ if (opening) {
394
+ theme.codeBlockIndent = "";
395
+ }
396
+ const border = opening ? `┌─${language ? ` ${language}` : ""}` : "└─";
397
+ return base.codeBlockBorder(border);
398
+ },
399
+ };
400
+ return theme;
401
+ }
402
+
403
+ const CODE_BLOCK_PADDING_X = 1;
404
+
405
+ function renderCodeBlockBackgroundRow(content: string, width: number): string {
406
+ const safeWidth = Math.max(1, width);
407
+ const horizontalPadding = Math.min(CODE_BLOCK_PADDING_X, Math.floor((safeWidth - 1) / 2));
408
+ const innerWidth = Math.max(1, safeWidth - horizontalPadding * 2);
409
+ const clipped = truncateToWidth(content, innerWidth, "…");
410
+ const rightFill = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
411
+ const row = `${" ".repeat(horizontalPadding)}${clipped}${rightFill}${" ".repeat(horizontalPadding)}`;
412
+ return currentTheme?.bg?.("toolPendingBg", row) ?? row;
413
+ }
414
+
415
+ export function normalizeCompactCodeBlockLines(lines: string[], width: number, paddingX = 0): string[] {
416
+ const safeWidth = Math.max(1, width);
417
+ const horizontalPadding = Math.max(0, Math.floor(paddingX));
418
+ const leftPadding = " ".repeat(horizontalPadding);
419
+ const contentWidth = Math.max(1, safeWidth - horizontalPadding * 2);
420
+ const continuationWidth = contentWidth;
421
+ const normalized: string[] = [];
422
+ let codeBlockMode: "none" | "background" = "none";
423
+
424
+ for (const originalLine of lines) {
425
+ const withoutLeftPadding =
426
+ leftPadding.length > 0 && originalLine.startsWith(leftPadding)
427
+ ? originalLine.slice(leftPadding.length)
428
+ : originalLine;
429
+ const content = withoutLeftPadding.trimEnd();
430
+ const visible = stripTerminalSequences(content).trimStart();
431
+ if (isCompactCodeBlockOpen(visible)) {
432
+ codeBlockMode = "background";
433
+ const language = visible.replace(/^┌─/, "").trim();
434
+ if (language) {
435
+ const label = currentTheme?.fg?.("muted", language) ?? language;
436
+ normalized.push(`${leftPadding}${renderCodeBlockBackgroundRow(label, contentWidth)}`);
437
+ }
438
+ continue;
439
+ }
440
+ if (isCompactCodeBlockClose(visible)) {
441
+ codeBlockMode = "none";
442
+ continue;
443
+ }
444
+ if (codeBlockMode === "background") {
445
+ const codeWidth = Math.max(1, continuationWidth - CODE_BLOCK_PADDING_X * 2);
446
+ const wrappedRows = wrapTextWithAnsi(content, codeWidth);
447
+ for (const wrapped of wrappedRows.length > 0 ? wrappedRows : [""]) {
448
+ normalized.push(`${leftPadding}${renderCodeBlockBackgroundRow(wrapped, contentWidth)}`);
449
+ }
450
+ continue;
451
+ }
452
+ normalized.push(truncateToWidth(originalLine, safeWidth, "…"));
453
+ }
454
+
455
+ return normalized;
456
+ }
457
+
458
+ // AssistantMessageComponent already uses Markdown for visible final text, but
459
+ // pi's stock Markdown theme intentionally displays literal ``` fence rows.
460
+ // Replace only visible assistant Markdown instances with compact code borders;
461
+ // user/custom messages and hidden thinking Markdown retain their native theme.
462
+ function installVisibleAssistantMarkdownRendering(component: Markdown): void {
463
+ const markdown = component as any;
464
+ if (markdown[MARKDOWN_RENDER_PATCH_KEY]) return;
465
+ markdown.theme = getCompactMarkdownTheme();
466
+ const originalRender = markdown.render.bind(markdown);
467
+ markdown.render = (width: number): string[] =>
468
+ normalizeCompactCodeBlockLines(originalRender(width), width, Number(markdown.paddingX) || 0);
469
+ markdown[MARKDOWN_RENDER_PATCH_KEY] = { originalRender };
470
+ markdown.invalidate();
471
+ }
472
+
473
+ class CompactionHeaderComponent implements Component {
474
+ constructor(private readonly tokensBefore: number) {}
475
+
476
+ render(width: number): string[] {
477
+ const theme = currentTheme;
478
+ const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
479
+ const exactTokens = Math.max(0, this.tokensBefore).toLocaleString();
480
+ const icon = fg("success", "›‹");
481
+ const title = fg("success", "Context compacted");
482
+ const detail = fg("muted", ` • ${exactTokens} tokens → summary`);
483
+ const full = `${icon} ${title}${detail}`;
484
+ if (visibleWidth(full) <= width) return [full];
485
+
486
+ const compactTitle = fg("success", "Compacted");
487
+ const compactDetail = fg("muted", ` • ${formatTokenK(this.tokensBefore)} tok`);
488
+ return [truncateToWidth(`${icon} ${compactTitle}${compactDetail}`, Math.max(1, width), "…")];
489
+ }
490
+
491
+ invalidate(): void {}
492
+ }
493
+
494
+ function installCompactionSummaryRendering(): void {
495
+ const prototype = CompactionSummaryMessageComponent.prototype as any;
496
+ const previous = prototype[COMPACTION_STYLE_PATCH_KEY] as
497
+ | {
498
+ original?: (this: any) => void;
499
+ installed?: (this: any) => void;
500
+ originalUpdateDisplay?: (this: any) => void;
501
+ originalSetExpanded?: (this: any, expanded: boolean) => void;
502
+ installedUpdateDisplay?: (this: any) => void;
503
+ installedSetExpanded?: (this: any, expanded: boolean) => void;
504
+ }
505
+ | undefined;
506
+ // Replace the previous compact-ui closure on hot reload while retaining
507
+ // pi's original renderer for a future replacement.
508
+ const previousInstalledUpdate = previous?.installedUpdateDisplay ?? previous?.installed;
509
+ const originalUpdateDisplay =
510
+ previous && previousInstalledUpdate && prototype.updateDisplay === previousInstalledUpdate
511
+ ? (previous.originalUpdateDisplay ?? previous.original)!
512
+ : (prototype.updateDisplay as (this: any) => void);
513
+ const originalSetExpanded =
514
+ previous?.installedSetExpanded && prototype.setExpanded === previous.installedSetExpanded
515
+ ? previous.originalSetExpanded!
516
+ : (prototype.setExpanded as (this: any, expanded: boolean) => void);
517
+ const installedUpdateDisplay = function (this: any): void {
518
+ this.paddingX = GROUP_PADDING_X;
519
+ this.paddingY = 0;
520
+ this.setBgFn(undefined);
521
+ this.clear();
522
+
523
+ const tokensBefore = Number(this.message?.tokensBefore);
524
+ const safeTokens = Number.isFinite(tokensBefore) && tokensBefore > 0 ? tokensBefore : 0;
525
+ this.addChild(new CompactionHeaderComponent(safeTokens));
526
+ };
527
+ const installedSetExpanded = function (this: any, _expanded: boolean): void {
528
+ // Context compaction is a static transcript event. Global Ctrl+O remains
529
+ // available for compact thinking/tool groups but does not alter this row.
530
+ };
531
+ prototype.updateDisplay = installedUpdateDisplay;
532
+ prototype.setExpanded = installedSetExpanded;
533
+ prototype[COMPACTION_STYLE_PATCH_KEY] = {
534
+ originalUpdateDisplay,
535
+ originalSetExpanded,
536
+ installedUpdateDisplay,
537
+ installedSetExpanded,
538
+ };
539
+ }
540
+
541
+ /**
542
+ * Pi's hidden-thinking mode still renders one Text component per thinking run.
543
+ * Setting its label to "" hides the glyphs, but the Text itself still occupies
544
+ * a terminal row and Pi may add adjacent Spacer components around it.
545
+ *
546
+ * compact-ui already renders thinking inside ToolGroupComponent, so remove
547
+ * thinking blocks from the presentation-only message passed to Pi's native
548
+ * AssistantMessageComponent. The original session/model message is untouched,
549
+ * and visible text/tool-call ordering is preserved.
550
+ */
551
+ function installNativeThinkingSuppression(): void {
552
+ const prototype = AssistantMessageComponent.prototype as any;
553
+ const previous = prototype[ASSISTANT_THINKING_PATCH_KEY] as
554
+ | {
555
+ originalUpdateContent: (this: any, message: any, isStreaming?: boolean) => void;
556
+ installedUpdateContent: (this: any, message: any, isStreaming?: boolean) => void;
557
+ }
558
+ | undefined;
559
+ const originalUpdateContent =
560
+ previous && prototype.updateContent === previous.installedUpdateContent
561
+ ? previous.originalUpdateContent
562
+ : (prototype.updateContent as (this: any, message: any, isStreaming?: boolean) => void);
563
+ const installedUpdateContent = function (this: any, message: any, isStreaming?: boolean): void {
564
+ const content = Array.isArray(message?.content) ? message.content : undefined;
565
+ if (!content?.some((item: any) => item?.type === "thinking")) {
566
+ originalUpdateContent.call(this, message, isStreaming);
567
+ return;
568
+ }
569
+ originalUpdateContent.call(
570
+ this,
571
+ {
572
+ ...message,
573
+ content: content.filter((item: any) => item?.type !== "thinking"),
574
+ },
575
+ isStreaming,
576
+ );
577
+ };
578
+ prototype.updateContent = installedUpdateContent;
579
+ prototype[ASSISTANT_THINKING_PATCH_KEY] = {
580
+ originalUpdateContent,
581
+ installedUpdateContent,
582
+ };
583
+ }
584
+
585
+ // =============================================================================
586
+ // ToolGroupComponent
587
+ // =============================================================================
588
+ class ToolGroupComponent extends Container {
589
+ readonly toolCallId = `compact-group-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
590
+ toolName = "group";
591
+ /** Nested at a visible-text boundary rather than rendered at chat level. */
592
+ anchored = false;
593
+ private _expanded = false;
594
+ get expanded(): boolean {
595
+ return this._expanded;
596
+ }
597
+ /** Sealed: this block was closed by real text output — no further entries. */
598
+ sealed = false;
599
+ /** Thinking runs and tool calls, in the exact order they streamed in. */
600
+ entries: GroupEntry[] = [];
601
+ private markdownPreviewCache = new Map<string, MarkdownPreview>();
602
+
603
+ constructor() {
604
+ super();
605
+ }
606
+
607
+ setExpanded(expanded: boolean): void {
608
+ this._expanded = expanded;
609
+ for (const tool of this.children as any[]) tool.setExpanded?.(expanded);
610
+ this.invalidate();
611
+ }
612
+
613
+ addTool(tool: any): void {
614
+ this.children.push(tool);
615
+ if ((tool as any)._groupedAt === undefined) (tool as any)._groupedAt = Date.now();
616
+ (tool as any)[PARENT_KEY] = this;
617
+ this.entries.push({ kind: "tool", tool });
618
+ }
619
+
620
+ /** Append a thinking run. Idempotent: a run belongs to exactly one group. */
621
+ addThinking(entry: ThinkingEntry): void {
622
+ if (entry.owner) return;
623
+ entry.owner = this;
624
+ this.entries.push(entry);
625
+ }
626
+
627
+ removeTool(tool: any): void {
628
+ const index = this.children.indexOf(tool);
629
+ if (index >= 0) this.children.splice(index, 1);
630
+ const entryIndex = this.entries.findIndex((item) => item.kind === "tool" && item.tool === tool);
631
+ if (entryIndex >= 0) this.entries.splice(entryIndex, 1);
632
+ if ((tool as any)?.[PARENT_KEY] === this) delete (tool as any)[PARENT_KEY];
633
+ }
634
+
635
+ hasPending(): boolean {
636
+ return this.children.some((tool) => toolStatus(tool) === "pending");
637
+ }
638
+
639
+ /** True while a thinking run in this group is still streaming. */
640
+ hasActiveThinking(): boolean {
641
+ return this.entries.some((entry) => entry.kind === "thinking" && entry.active);
642
+ }
643
+
644
+ /** True once any thinking run has been added to this group. */
645
+ hasThinking(): boolean {
646
+ return this.entries.some((entry) => entry.kind === "thinking");
647
+ }
648
+
649
+ /** True while this group should keep its spinner animating. */
650
+ needsAnimation(): boolean {
651
+ return this.hasPending() || this.hasActiveThinking();
652
+ }
653
+
654
+ override invalidate(): void {
655
+ // Theme changes and tool/thinking updates must rebuild ANSI markdown.
656
+ this.markdownPreviewCache.clear();
657
+ super.invalidate();
658
+ }
659
+
660
+ private renderMarkdownPreview(
661
+ cacheKey: string,
662
+ source: string,
663
+ width: number,
664
+ maxLines: number,
665
+ defaultTextStyle?: DefaultTextStyle,
666
+ ): MarkdownPreview {
667
+ const renderWidth = Math.max(1, width);
668
+ const lineLimit = Math.max(1, maxLines);
669
+ const cached = this.markdownPreviewCache.get(cacheKey);
670
+ if (cached && cached.source === source && cached.width === renderWidth && cached.maxLines === lineLimit) {
671
+ return cached;
672
+ }
673
+
674
+ // Only a bounded prefix can become visible. This prevents a very large
675
+ // command result from being reparsed in full merely to display a handful
676
+ // of expanded lines. Incomplete closing fences are supported by pi-tui.
677
+ const sourceRows = source.split("\n");
678
+ const sourceLineLimit = Math.max(lineLimit * 4, lineLimit + 20);
679
+ const sourceCharLimit = Math.max(4096, lineLimit * Math.max(40, renderWidth) * 4);
680
+ let markdownSource = sourceRows.slice(0, sourceLineLimit).join("\n");
681
+ let sourceTruncated = sourceRows.length > sourceLineLimit;
682
+ if (markdownSource.length > sourceCharLimit) {
683
+ markdownSource = markdownSource.slice(0, sourceCharLimit);
684
+ sourceTruncated = true;
685
+ }
686
+
687
+ const markdown = new Markdown(markdownSource, 0, 0, getCompactMarkdownTheme(), defaultTextStyle);
688
+ const rendered = normalizeCompactCodeBlockLines(markdown.render(renderWidth), renderWidth);
689
+ const preview: MarkdownPreview = {
690
+ source,
691
+ width: renderWidth,
692
+ maxLines: lineLimit,
693
+ lines: rendered.slice(0, lineLimit),
694
+ truncated: sourceTruncated || rendered.length > lineLimit,
695
+ };
696
+ this.markdownPreviewCache.set(cacheKey, preview);
697
+ return preview;
698
+ }
699
+
700
+ private iconFor(tool: any, frame: string): string {
701
+ const st = toolStatus(tool);
702
+ return st === "pending" ? frame : st === "error" ? "✗" : "✓";
703
+ }
704
+ private colorFor(status: string): string {
705
+ return status === "pending" ? "accent" : status === "error" ? "error" : "success";
706
+ }
707
+ // Tool name in bold accent, tool payload in dim.
708
+ private toolRow(rail: string, tool: any, frame: string): string {
709
+ const theme = currentTheme;
710
+ const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
711
+ const bold = theme?.bold ? theme.bold : (t: string) => t;
712
+ const st = toolStatus(tool);
713
+ const s = toolSummary(tool.toolName, tool.args);
714
+ return `${fg("dim", rail)}${fg(this.colorFor(st), this.iconFor(tool, frame))} ${fg("toolTitle", bold(s.name))} ${fg("dim", s.content)} ${fg("muted", `(${toolElapsed(tool)}s)`)}`;
715
+ }
716
+ private thinkingActiveInGroup(): boolean {
717
+ return !this.sealed && this.hasActiveThinking();
718
+ }
719
+
720
+ // One row per thinking run. The spinner marks the run that is still streaming;
721
+ // the completion mark is written as soon as the run ends.
722
+ private thinkingRow(rail: string, entry: ThinkingEntry, frame: string, width: number): string {
723
+ const theme = currentTheme;
724
+ const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
725
+ const icon = entry.active ? frame : "✓";
726
+ const preview = entry.text.trim().replace(/[*_#`>]+/g, "");
727
+ if (!preview) {
728
+ return `${fg("dim", rail)}${fg("thinkingText", icon)} ${fg("thinkingText", "thinking...")}`;
729
+ }
730
+ const tokenLabel = thinkingTokenLabel(entry);
731
+ const previewLimit = Math.max(1, Math.min(60, width - GROUP_PADDING_X - 18 - tokenLabel.length));
732
+ return `${fg("dim", rail)}${fg("thinkingText", icon)} ${fg("thinkingText", `thinking: ${oneLine(preview, previewLimit)}`)} ${fg("muted", `· ${tokenLabel}`)}`;
733
+ }
734
+
735
+ // Folded: header + one line per entry, in stream order (oldest first).
736
+ private renderCollapsed(width: number): string[] {
737
+ const theme = currentTheme;
738
+ const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
739
+ const frame = SPINNER[Math.floor((Date.now() - spinnerStart) / SPINNER_MS) % SPINNER.length]!;
740
+ const lines: string[] = [];
741
+
742
+ const hasPendingTool = this.hasPending();
743
+ const isThinking = this.thinkingActiveInGroup();
744
+ // An open block with no tools yet is still "thinking" (waiting for tools or
745
+ // a text seal); a sealed block without tools is a finished thinking-only
746
+ // block and must not claim "tools done".
747
+ const hasTools = this.children.length > 0;
748
+ const hasThinking = this.hasThinking();
749
+ const openNoTools = !this.sealed && !hasTools;
750
+ const working = hasPendingTool || isThinking || openNoTools;
751
+ const state = hasPendingTool
752
+ ? "tool calling..."
753
+ : !hasTools
754
+ ? working
755
+ ? "thinking..."
756
+ : "thinking"
757
+ : isThinking
758
+ ? "thinking..."
759
+ : hasThinking
760
+ ? "tools and thinking done"
761
+ : "tools done";
762
+ const stateColor = hasPendingTool ? "accent" : hasTools && !isThinking ? "success" : "thinkingText";
763
+ // Left icon: spinner while working, completion mark once the group is done.
764
+ const leftIcon = working ? frame : "✓";
765
+ lines.push(`${fg(stateColor, leftIcon)} ${fg(stateColor, state)}`);
766
+
767
+ // Every entry is shown, one line each, in the order the model produced it.
768
+ const total = this.entries.length;
769
+ for (let index = 0; index < total; index++) {
770
+ const entry = this.entries[index]!;
771
+ const rail = index === total - 1 ? "└ " : "│ ";
772
+ lines.push(
773
+ entry.kind === "thinking"
774
+ ? this.thinkingRow(rail, entry, frame, width)
775
+ : this.toolRow(rail, entry.tool, frame),
776
+ );
777
+ }
778
+
779
+ if (this.needsAnimation()) scheduleAnimation();
780
+ return lines;
781
+ }
782
+
783
+ // Expanded: per-tool detail + every thinking run, in stream order.
784
+ private renderExpanded(width: number): string[] {
785
+ const theme = currentTheme;
786
+ const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
787
+ const frame = SPINNER[Math.floor((Date.now() - spinnerStart) / SPINNER_MS) % SPINNER.length]!;
788
+ const lines: string[] = [];
789
+
790
+ const hasPendingTool = this.hasPending();
791
+ const isThinking = this.thinkingActiveInGroup();
792
+ // See renderCollapsed: a block without tools keeps the thinking label, and a
793
+ // block with reasoning must not claim "tools done" alone.
794
+ const hasTools = this.children.length > 0;
795
+ const hasThinking = this.hasThinking();
796
+ const openNoTools = !this.sealed && !hasTools;
797
+ const working = hasPendingTool || isThinking || openNoTools;
798
+ const state = hasPendingTool
799
+ ? "tool calling..."
800
+ : !hasTools
801
+ ? working
802
+ ? "thinking..."
803
+ : "thinking"
804
+ : isThinking
805
+ ? "thinking..."
806
+ : hasThinking
807
+ ? "tools and thinking done"
808
+ : "tools done";
809
+ const stateColor = hasPendingTool ? "accent" : hasTools && !isThinking ? "success" : "thinkingText";
810
+ // Left icon: spinner while working, completion mark once the group is done.
811
+ const leftIcon = working ? frame : "✓";
812
+ lines.push(`${fg(stateColor, leftIcon)} ${fg(stateColor, state)}`);
813
+
814
+ const total = this.entries.length;
815
+ for (let index = 0; index < total; index++) {
816
+ const entry = this.entries[index]!;
817
+ const isLast = index === total - 1;
818
+ const rail = isLast ? "└─ " : "├─ ";
819
+ const sub = isLast ? " " : "│ ";
820
+ if (entry.kind === "tool") {
821
+ const tool = entry.tool;
822
+ lines.push(this.toolRow(rail, tool, frame));
823
+ const result = toolResultText(tool);
824
+ if (result) {
825
+ const markdownWidth = Math.max(1, width - GROUP_PADDING_X - sub.length);
826
+ const preview = this.renderMarkdownPreview(
827
+ `tool:${tool.toolCallId ?? index}`,
828
+ result,
829
+ markdownWidth,
830
+ config.expandedToolLines,
831
+ { color: (text) => currentTheme?.fg?.("toolOutput", text) ?? text },
832
+ );
833
+ for (const row of preview.lines) {
834
+ lines.push(`${fg("dim", sub)}${row}`);
835
+ }
836
+ if (preview.truncated) {
837
+ lines.push(`${fg("dim", sub)}${fg("muted", "…")}`);
838
+ }
839
+ }
840
+ continue;
841
+ }
842
+
843
+ const icon = entry.active ? frame : "✓";
844
+ lines.push(
845
+ `${fg("dim", rail)}${fg("thinkingText", icon)} ${fg("thinkingText", "thinking")} ${fg("muted", `· ${thinkingTokenLabel(entry)}`)}`,
846
+ );
847
+ const tText = entry.text.trim();
848
+ if (!tText) continue;
849
+ const markdownWidth = Math.max(1, width - GROUP_PADDING_X - sub.length);
850
+ const preview = this.renderMarkdownPreview(
851
+ `thinking:${entry.id}`,
852
+ tText,
853
+ markdownWidth,
854
+ config.expandedThinkingLines,
855
+ { color: (text) => currentTheme?.fg?.("thinkingText", text) ?? text, italic: true },
856
+ );
857
+ for (const row of preview.lines) {
858
+ lines.push(`${fg("dim", sub)}${row}`);
859
+ }
860
+ if (preview.truncated) {
861
+ lines.push(`${fg("dim", sub)}${fg("muted", "…")}`);
862
+ }
863
+ }
864
+
865
+ if (this.needsAnimation()) scheduleAnimation();
866
+ return lines;
867
+ }
868
+
869
+ override render(width: number): string[] {
870
+ const lines = this._expanded ? this.renderExpanded(width) : this.renderCollapsed(width);
871
+ // Indent compact blocks from the transcript edge while keeping every line
872
+ // within the terminal width (including mobile / narrow terminals).
873
+ const padding = " ".repeat(Math.min(GROUP_PADDING_X, Math.max(0, width - 1)));
874
+ const contentWidth = Math.max(1, width - padding.length);
875
+ const rendered = lines.map((line) => padding + truncateToWidth(line, contentWidth, "…"));
876
+ // Native ToolExecutionComponent starts with Spacer(1). Our custom render
877
+ // bypasses that child tree, so restore the same single leading gap while
878
+ // the group is top-level. Anchored groups receive deterministic spacing
879
+ // from placeAnchoredGroupBeforeText() instead.
880
+ return this.anchored ? rendered : ["", ...rendered];
881
+ }
882
+ }
883
+
884
+ // =============================================================================
885
+ // Animation scheduling. The TUI instance is captured via setWidget's factory
886
+ // (extensions can't requestRender directly). We tick at 300ms and call the
887
+ // throttled requestRender(), so the diff renderer updates only the changed
888
+ // spinner/elapsed cells — no full-screen repaint, no scroll fight.
889
+ // =============================================================================
890
+ let animTimer: ReturnType<typeof setTimeout> | null = null;
891
+ let capturedTui: any = null;
892
+
893
+ function scheduleAnimation(): void {
894
+ if (animTimer) return;
895
+ animTimer = setTimeout(() => {
896
+ animTimer = null;
897
+ let any = false;
898
+ for (const g of groups) {
899
+ if (g.needsAnimation()) {
900
+ any = true;
901
+ break;
902
+ }
903
+ }
904
+ if (any && capturedTui) {
905
+ capturedTui.requestRender();
906
+ }
907
+ }, SPINNER_MS);
908
+ }
909
+
910
+ // =============================================================================
911
+ // Prototype patch
912
+ // =============================================================================
913
+ const groups = new Set<ToolGroupComponent>();
914
+
915
+ function isGroupable(value: any): boolean {
916
+ return value instanceof ToolExecutionComponent;
917
+ }
918
+
919
+ function previousGroupable(children: any[], start: number): { child: any; index: number } | undefined {
920
+ for (let i = start; i >= 0; i--) {
921
+ const child = children[i];
922
+ if (child instanceof Spacer) continue;
923
+ if (child instanceof AssistantMessageComponent) continue;
924
+ return { child, index: i };
925
+ }
926
+ return undefined;
927
+ }
928
+
929
+ // A thinking run appears in the transcript as soon as it starts, even before its
930
+ // first delta, so its row can spin while the model is still reasoning. The block
931
+ // is inserted right after the current assistant message component so the
932
+ // message's (and later messages') tool calls join it via maybeGroup — as long as
933
+ // no real (non-thinking) text sealed it in between.
934
+ function ensureThinkingGroup(): void {
935
+ if (unattachedThinking.length === 0) return;
936
+ let group = lastActiveGroup && !lastActiveGroup.sealed ? lastActiveGroup : null;
937
+ if (!group) {
938
+ if (!lastChatContainer || !lastStreamingComp) return; // retry on the next event
939
+ const parent = lastChatContainer;
940
+ const children = parent.children;
941
+ if (!Array.isArray(children)) return;
942
+ const idx = children.indexOf(lastStreamingComp);
943
+ group = new ToolGroupComponent();
944
+ children.splice(idx >= 0 ? idx + 1 : children.length, 0, group);
945
+ groups.add(group);
946
+ lastActiveGroup = group;
947
+ parent.invalidate?.();
948
+ }
949
+ while (unattachedThinking.length > 0) group.addThinking(unattachedThinking.shift()!);
950
+ group.invalidate();
951
+ capturedTui?.requestRender?.();
952
+ }
953
+
954
+ // A text stream is a boundary between compact blocks. The message_update event
955
+ // arrives before or after the matching AssistantMessageComponent depending on
956
+ // the renderer's event ordering, so defer the seal until that component exists.
957
+ function flushPendingTextSeal(): void {
958
+ if (!pendingTextSeal) return;
959
+
960
+ // A run still streaming when the answer starts belongs to the block being
961
+ // sealed, and may not have a group yet.
962
+ if (activeThinking || unattachedThinking.length > 0) ensureThinkingGroup();
963
+
964
+ if (lastActiveGroup && !lastActiveGroup.sealed) {
965
+ // The active group contains every thinking/tool event since the previous
966
+ // visible text. Anchor it immediately before this text block so the visual
967
+ // component order matches the stream order:
968
+ // group -> visible text -> next group -> next visible text
969
+ if (pendingTextOrdinal !== null) {
970
+ anchorGroupBeforeCurrentText(lastActiveGroup, pendingTextOrdinal);
971
+ }
972
+ lastActiveGroup.sealed = true;
973
+ finalizeActiveThinking();
974
+ lastActiveGroup.invalidate();
975
+ pendingTextSeal = false;
976
+ pendingTextOrdinal = null;
977
+ return;
978
+ }
979
+
980
+ // No thinking or tools preceded this text, so there is no compact block to
981
+ // seal. Do not let the boundary leak forward and close a later tool group.
982
+ pendingTextSeal = false;
983
+ pendingTextOrdinal = null;
984
+ }
985
+
986
+ function maybeGroup(parent: any, component: any): void {
987
+ if (!isGroupable(component) || parent instanceof ToolGroupComponent) return;
988
+ const children = parent?.children;
989
+ if (!Array.isArray(children)) return;
990
+ const index = children.indexOf(component);
991
+ if (index < 0) return;
992
+ const prior = previousGroupable(children, index - 1);
993
+
994
+ // Previous sibling is an open (not-yet-sealed) group → join it.
995
+ if (prior?.child instanceof ToolGroupComponent && !prior.child.sealed) {
996
+ children.splice(index, 1);
997
+ prior.child.addTool(component);
998
+ lastActiveGroup = prior.child;
999
+ return;
1000
+ }
1001
+ // Previous sibling is a bare tool → merge both into a new group.
1002
+ if (prior && isGroupable(prior.child)) {
1003
+ const group = new ToolGroupComponent();
1004
+ group.addTool(prior.child);
1005
+ group.addTool(component);
1006
+ (parent as any).children[prior.index] = group;
1007
+ children.splice(index, 1);
1008
+ groups.add(group);
1009
+ lastActiveGroup = group;
1010
+ return;
1011
+ }
1012
+ // Otherwise (sealed group before, or nothing groupable) → wrap the tool in a
1013
+ // fresh open group so it stays visible.
1014
+ const group = new ToolGroupComponent();
1015
+ group.addTool(component);
1016
+ (parent as any).children[index] = group;
1017
+ groups.add(group);
1018
+ lastActiveGroup = group;
1019
+ }
1020
+
1021
+ type PatchState = {
1022
+ active: boolean;
1023
+ original: { addChild: Function; removeChild: Function; clear: Function };
1024
+ installed: { addChild: Function; removeChild: Function; clear: Function };
1025
+ prototype: any;
1026
+ };
1027
+
1028
+ // AssistantMessageComponent content containers that may carry a "phantom" blank
1029
+ // line. With hiddenThinkingLabel set to "", pi still adds
1030
+ // Text(italic(fg("thinkingText",""))) to the message; because the empty string
1031
+ // is wrapped in ANSI escapes, Text does not treat it as empty and renders a full
1032
+ // blank line. That phantom line (plus the thinking-only Spacer pi adds after
1033
+ // it) makes the gap between a folded tool group and the following text look too
1034
+ // large. We strip the empty Text so only pi's normal single Spacer remains.
1035
+ const assistantContentContainers = new WeakSet<Container>();
1036
+ type AssistantContentState = {
1037
+ /** Sealed groups keyed by the visible Markdown block they precede. */
1038
+ anchors: Map<number, ToolGroupComponent>;
1039
+ /** Visible Markdown ordinal while AssistantMessageComponent rebuilds. */
1040
+ nextTextOrdinal: number;
1041
+ /** Turn-duration divider bound to the final visible Markdown ordinal. */
1042
+ finalDivider?: { ordinal: number; component: any };
1043
+ };
1044
+ const assistantContentStates = new WeakMap<Container, AssistantContentState>();
1045
+ const groupAnchors = new WeakMap<ToolGroupComponent, { container: Container; ordinal: number }>();
1046
+
1047
+ function getAssistantContentState(container: Container): AssistantContentState {
1048
+ let state = assistantContentStates.get(container);
1049
+ if (!state) {
1050
+ state = { anchors: new Map(), nextTextOrdinal: 0 };
1051
+ assistantContentStates.set(container, state);
1052
+ }
1053
+ return state;
1054
+ }
1055
+
1056
+ function removeGroupFromContainer(container: any, group: ToolGroupComponent): void {
1057
+ const children = container?.children;
1058
+ if (!Array.isArray(children)) return;
1059
+ const index = children.indexOf(group);
1060
+ if (index >= 0) children.splice(index, 1);
1061
+ }
1062
+
1063
+ function removeComponentFromContainer(container: any, component: any): void {
1064
+ const children = container?.children;
1065
+ if (!Array.isArray(children)) return;
1066
+ const index = children.indexOf(component);
1067
+ if (index >= 0) children.splice(index, 1);
1068
+ }
1069
+
1070
+ function formatWorkedTime(elapsedMs: number): string {
1071
+ const totalSec = Math.max(1, Math.round(elapsedMs / 1000));
1072
+ const hours = Math.floor(totalSec / 3600);
1073
+ const minutes = Math.floor((totalSec % 3600) / 60);
1074
+ const seconds = totalSec % 60;
1075
+ if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
1076
+ if (minutes > 0) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
1077
+ return `${seconds}s`;
1078
+ }
1079
+
1080
+ // Static horizontal rule with the turn's elapsed time in the middle:
1081
+ // ──── worked for 0m 42s ────
1082
+ class TurnDividerComponent {
1083
+ private readonly timeLabel: string;
1084
+
1085
+ constructor(timeLabel: string) {
1086
+ this.timeLabel = timeLabel;
1087
+ }
1088
+
1089
+ render(width: number): string[] {
1090
+ const theme = currentTheme;
1091
+ const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
1092
+ const middle = `worked for ${this.timeLabel}`;
1093
+ const avail = Math.max(6, width - middle.length - 2);
1094
+ const left = Math.floor(avail / 2);
1095
+ const right = avail - left;
1096
+ const dash = (n: number) => "─".repeat(Math.max(0, n));
1097
+ const line = `${fg("dim", dash(left))} ${fg("muted", middle)} ${fg("dim", dash(right))}`;
1098
+ return [truncateToWidth(line, Math.max(1, width))];
1099
+ }
1100
+
1101
+ invalidate(): void {}
1102
+ }
1103
+
1104
+ // Insert the turn divider directly before the final visible Markdown, keeping
1105
+ // the anchored group's trailing Spacer as the gap: ... group, Spacer, divider,
1106
+ // final text. Re-inserting is idempotent (the old instance is removed first).
1107
+ function placeTurnDividerBeforeText(container: Container, target: Markdown, divider: any): void {
1108
+ const targetIndex = container.children.indexOf(target);
1109
+ if (targetIndex < 0) return;
1110
+ removeComponentFromContainer(container, divider);
1111
+ container.children.splice(targetIndex, 0, divider);
1112
+ }
1113
+
1114
+ // Called at agent_end: bind a divider to the final visible text of the last
1115
+ // assistant message so it survives cumulative rebuilds (like anchored groups).
1116
+ function insertTurnDivider(elapsedMs: number): void {
1117
+ if (elapsedMs < 1000) return;
1118
+ let comp: any = lastStreamingComp;
1119
+ if (!comp || !(comp instanceof AssistantMessageComponent)) {
1120
+ if (!lastChatContainer) return;
1121
+ const children = (lastChatContainer as any)?.children;
1122
+ if (!Array.isArray(children)) return;
1123
+ for (let i = children.length - 1; i >= 0; i--) {
1124
+ if (children[i] instanceof AssistantMessageComponent) {
1125
+ comp = children[i];
1126
+ break;
1127
+ }
1128
+ }
1129
+ }
1130
+ if (!comp) return;
1131
+ const contentContainer = (comp as any).contentContainer;
1132
+ if (!(contentContainer instanceof Container)) return;
1133
+
1134
+ const markdowns = contentContainer.children.filter(isVisibleTextMarkdown);
1135
+ if (markdowns.length === 0) return;
1136
+ const final = markdowns[markdowns.length - 1];
1137
+ const finalIndex = contentContainer.children.indexOf(final);
1138
+ // Only separate the final text from preceding work (an anchored tool/thinking
1139
+ // group). A plain text-only answer gets no divider.
1140
+ const hasPriorContent = contentContainer.children
1141
+ .slice(0, finalIndex)
1142
+ .some((child) => child instanceof ToolGroupComponent);
1143
+ if (!hasPriorContent) return;
1144
+
1145
+ const state = getAssistantContentState(contentContainer);
1146
+ if (state.finalDivider && contentContainer.children.includes(state.finalDivider.component)) return;
1147
+ const ordinal = markdowns.length - 1;
1148
+ const divider = new TurnDividerComponent(formatWorkedTime(elapsedMs));
1149
+ state.finalDivider = { ordinal, component: divider };
1150
+ placeTurnDividerBeforeText(contentContainer, final, divider);
1151
+ contentContainer.invalidate?.();
1152
+ capturedTui?.requestRender?.();
1153
+ }
1154
+
1155
+ function isVisibleTextMarkdown(component: any): component is Markdown {
1156
+ // Thinking Markdown receives a defaultTextStyle ({ color, italic }) from
1157
+ // AssistantMessageComponent; normal assistant text does not. Count only
1158
+ // normal text blocks so anchors remain correct if thinking visibility is
1159
+ // toggled on.
1160
+ return component instanceof Markdown && !(component as any).defaultTextStyle;
1161
+ }
1162
+
1163
+ function placeAnchoredGroupBeforeText(container: Container, target: Markdown, group: ToolGroupComponent): void {
1164
+ const targetIndex = container.children.indexOf(target);
1165
+ if (targetIndex < 0) return;
1166
+
1167
+ // Pi may accumulate one Spacer for the message itself plus one Spacer for
1168
+ // every hidden thinking run before this text. Tool loops can therefore leave
1169
+ // an arbitrarily large run here. Replace the entire run with a deterministic
1170
+ // boundary:
1171
+ //
1172
+ // previous text/content -> one blank -> compact group -> one blank -> text
1173
+ //
1174
+ // This also makes repeated cumulative AssistantMessageComponent rebuilds
1175
+ // idempotent instead of accumulating more spacing around restored anchors.
1176
+ let spacerStart = targetIndex;
1177
+ while (spacerStart > 0 && container.children[spacerStart - 1] instanceof Spacer) spacerStart--;
1178
+ if (targetIndex > spacerStart) {
1179
+ container.children.splice(spacerStart, targetIndex - spacerStart);
1180
+ }
1181
+ group.anchored = true;
1182
+ container.children.splice(spacerStart, 0, new Spacer(1), group, new Spacer(1));
1183
+ }
1184
+
1185
+ function insertAnchoredGroup(container: Container, ordinal: number, group: ToolGroupComponent): void {
1186
+ const markdowns = container.children.filter(isVisibleTextMarkdown);
1187
+ const target = markdowns[ordinal];
1188
+ if (!target) return;
1189
+ removeGroupFromContainer(container, group);
1190
+ placeAnchoredGroupBeforeText(container, target, group);
1191
+ }
1192
+
1193
+ function installAssistantExpansion(component: AssistantMessageComponent, contentContainer: Container): void {
1194
+ // Ctrl+O only visits top-level chat children. Once compact groups are
1195
+ // anchored inside an AssistantMessageComponent, make that top-level
1196
+ // component expandable and delegate the state to its nested groups.
1197
+ (component as any).setExpanded = (expanded: boolean) => {
1198
+ const state = assistantContentStates.get(contentContainer);
1199
+ if (!state) return;
1200
+ for (const group of state.anchors.values()) group.setExpanded(expanded);
1201
+ contentContainer.invalidate();
1202
+ };
1203
+ }
1204
+
1205
+ function anchorGroupBeforeCurrentText(group: ToolGroupComponent, ordinal: number): void {
1206
+ if (!lastStreamingComp || !lastChatContainer) return;
1207
+ const contentContainer = (lastStreamingComp as any).contentContainer;
1208
+ if (!(contentContainer instanceof Container)) return;
1209
+
1210
+ // An open group normally lives directly in the chat container. Remove it
1211
+ // there before nesting it at the exact text boundary.
1212
+ removeGroupFromContainer(lastChatContainer, group);
1213
+
1214
+ const previousAnchor = groupAnchors.get(group);
1215
+ if (previousAnchor) {
1216
+ const previousState = assistantContentStates.get(previousAnchor.container);
1217
+ if (previousState?.anchors.get(previousAnchor.ordinal) === group) {
1218
+ previousState.anchors.delete(previousAnchor.ordinal);
1219
+ }
1220
+ removeGroupFromContainer(previousAnchor.container, group);
1221
+ }
1222
+
1223
+ const state = getAssistantContentState(contentContainer);
1224
+ const replaced = state.anchors.get(ordinal);
1225
+ if (replaced && replaced !== group) {
1226
+ removeGroupFromContainer(contentContainer, replaced);
1227
+ replaced.anchored = false;
1228
+ groupAnchors.delete(replaced);
1229
+ }
1230
+ state.anchors.set(ordinal, group);
1231
+ groupAnchors.set(group, { container: contentContainer, ordinal });
1232
+ insertAnchoredGroup(contentContainer, ordinal, group);
1233
+ lastChatContainer.invalidate?.();
1234
+ capturedTui?.requestRender?.();
1235
+ }
1236
+
1237
+ function restoreAssistantAnchor(parent: any, component: any): void {
1238
+ if (!assistantContentContainers.has(parent) || !isVisibleTextMarkdown(component)) return;
1239
+ installVisibleAssistantMarkdownRendering(component);
1240
+ const state = getAssistantContentState(parent);
1241
+ const ordinal = state.nextTextOrdinal++;
1242
+ const group = state.anchors.get(ordinal);
1243
+ if (group) {
1244
+ removeGroupFromContainer(parent, group);
1245
+ placeAnchoredGroupBeforeText(parent, component, group);
1246
+ }
1247
+ const divider = state.finalDivider;
1248
+ if (divider && divider.ordinal === ordinal) {
1249
+ placeTurnDividerBeforeText(parent, component, divider.component);
1250
+ }
1251
+ }
1252
+
1253
+ function releaseAssistantAnchors(component: any): void {
1254
+ if (!(component instanceof AssistantMessageComponent)) return;
1255
+ const contentContainer = (component as any).contentContainer;
1256
+ if (!(contentContainer instanceof Container)) return;
1257
+ const state = assistantContentStates.get(contentContainer);
1258
+ if (!state) return;
1259
+ for (const group of state.anchors.values()) {
1260
+ for (const tool of [...group.children]) delete (tool as any)[PARENT_KEY];
1261
+ groupAnchors.delete(group);
1262
+ groups.delete(group);
1263
+ }
1264
+ state.anchors.clear();
1265
+ state.finalDivider = undefined;
1266
+ }
1267
+
1268
+ function stripAssistantPhantomPadding(parent: any, component: any): void {
1269
+ // Mark the plain Container that an AssistantMessageComponent owns as its
1270
+ // content container so we can trim its children later.
1271
+ if (parent instanceof AssistantMessageComponent && component instanceof Container && !(component instanceof AssistantMessageComponent)) {
1272
+ assistantContentContainers.add(component);
1273
+ getAssistantContentState(component);
1274
+ installAssistantExpansion(parent, component);
1275
+ return;
1276
+ }
1277
+ if (!assistantContentContainers.has(parent)) return;
1278
+ // Drop any Text child whose visible content is empty (only ANSI styling).
1279
+ // This is the hidden-thinking label pi renders even when the label is "".
1280
+ // Also remove the trailing Spacer run that preceded the label. Otherwise
1281
+ // every thinking-only assistant message in a multi-tool loop remains as a
1282
+ // one-line blank component; moving the final group into a later text message
1283
+ // then exposes all of those accumulated blank lines as a huge gap.
1284
+ if (component instanceof Text) {
1285
+ const visible = String((component as any).text ?? "").replace(/\x1b\[[0-9;]*m/g, "").trim();
1286
+ if (visible === "") {
1287
+ const index = parent.children.indexOf(component);
1288
+ if (index >= 0) parent.children.splice(index, 1);
1289
+ while (parent.children.at(-1) instanceof Spacer) parent.children.pop();
1290
+ }
1291
+ }
1292
+ }
1293
+
1294
+ function installGrouping(): void {
1295
+ const host = globalThis as any;
1296
+ const prototype = Container.prototype as any;
1297
+ const previous = host[PATCH_KEY] as PatchState | undefined;
1298
+ // Always (re-)install. On hot-reload (/reload) the old instance's prototype
1299
+ // patch stays on Container.prototype but its closures reference the OLD
1300
+ // module state (groups/lastActiveGroup). Skipping here would leave the new
1301
+ // instance's event handlers reading a different lastActiveGroup than the one
1302
+ // the patch writes, so message-boundary sealing would never fire. Re-install
1303
+ // with the preserved original so future addChild calls use THIS instance's
1304
+ // closures.
1305
+
1306
+ const original = {
1307
+ addChild: previous && prototype.addChild === previous.installed.addChild ? previous.original.addChild : prototype.addChild,
1308
+ removeChild: previous && prototype.removeChild === previous.installed.removeChild ? previous.original.removeChild : prototype.removeChild,
1309
+ clear: previous && prototype.clear === previous.installed.clear ? previous.original.clear : prototype.clear,
1310
+ };
1311
+ const state: PatchState = {
1312
+ active: true,
1313
+ prototype,
1314
+ original,
1315
+ installed: undefined as any,
1316
+ };
1317
+ state.installed = {
1318
+ addChild: function (this: any, component: any) {
1319
+ const result = state.original.addChild.call(this, component);
1320
+ if (component && typeof component === "object") {
1321
+ // Remember where the current assistant message component lives so a
1322
+ // thinking-only group can be inserted right after it later.
1323
+ if (component instanceof AssistantMessageComponent) {
1324
+ lastChatContainer = this;
1325
+ lastStreamingComp = component;
1326
+ // A run that started before its message component existed can now
1327
+ // be placed in the transcript, in front of this message's text.
1328
+ if (unattachedThinking.length > 0) ensureThinkingGroup();
1329
+ flushPendingTextSeal();
1330
+ }
1331
+ maybeGroup(this, component);
1332
+ stripAssistantPhantomPadding(this, component);
1333
+ restoreAssistantAnchor(this, component);
1334
+ }
1335
+ return result;
1336
+ },
1337
+ removeChild: function (this: any, component: any) {
1338
+ const group = component?.[PARENT_KEY];
1339
+ if (group instanceof ToolGroupComponent && (group as any)[PARENT_KEY] === this) {
1340
+ group.removeTool(component);
1341
+ if (group.children.length === 0 && group.entries.length === 0) groups.delete(group);
1342
+ return;
1343
+ }
1344
+ releaseAssistantAnchors(component);
1345
+ return state.original.removeChild.call(this, component);
1346
+ },
1347
+ clear: function (this: any) {
1348
+ if (assistantContentContainers.has(this)) {
1349
+ // AssistantMessageComponent rebuilds this container for every
1350
+ // cumulative stream update. Keep sealed compact groups in the
1351
+ // anchor map; restoreAssistantAnchor() reinserts each one before
1352
+ // its matching Markdown child as the rebuild proceeds.
1353
+ getAssistantContentState(this).nextTextOrdinal = 0;
1354
+ return state.original.clear.call(this);
1355
+ }
1356
+ for (const child of [...(this.children ?? [])]) {
1357
+ if (child instanceof ToolGroupComponent) {
1358
+ for (const tool of [...child.children] as any[]) delete tool[PARENT_KEY];
1359
+ groups.delete(child);
1360
+ }
1361
+ releaseAssistantAnchors(child);
1362
+ }
1363
+ return state.original.clear.call(this);
1364
+ },
1365
+ };
1366
+ prototype.addChild = state.installed.addChild;
1367
+ prototype.removeChild = state.installed.removeChild;
1368
+ prototype.clear = state.installed.clear;
1369
+ host[PATCH_KEY] = state;
1370
+ }
1371
+
1372
+ // =============================================================================
1373
+ // Built-in tool delegation (render nothing natively)
1374
+ // =============================================================================
1375
+ // pi's built-in tools carry schema-inferred parameter and result types. compact-ui
1376
+ // only forwards the calls (rendering is replaced), so they stay opaque here.
1377
+ type AnyTool = {
1378
+ parameters: any;
1379
+ execute: (...args: any[]) => Promise<any>;
1380
+ };
1381
+
1382
+ const toolCache = new Map<string, Record<string, AnyTool>>();
1383
+ function getTools(cwd: string): Record<string, AnyTool> {
1384
+ let tools = toolCache.get(cwd);
1385
+ if (!tools) {
1386
+ tools = {
1387
+ read: createReadTool(cwd),
1388
+ bash: createBashTool(cwd),
1389
+ edit: createEditTool(cwd),
1390
+ write: createWriteTool(cwd),
1391
+ find: createFindTool(cwd),
1392
+ grep: createGrepTool(cwd),
1393
+ ls: createLsTool(cwd),
1394
+ };
1395
+ toolCache.set(cwd, tools);
1396
+ }
1397
+ return tools;
1398
+ }
1399
+
1400
+ export default function (pi: ExtensionAPI) {
1401
+ installGrouping();
1402
+ installNativeThinkingSuppression();
1403
+ installCompactionSummaryRendering();
1404
+
1405
+ const delegate = (name: keyof ReturnType<typeof getTools>) =>
1406
+ async (
1407
+ toolCallId: string,
1408
+ params: unknown,
1409
+ signal?: AbortSignal,
1410
+ onUpdate?: unknown,
1411
+ ctx?: unknown,
1412
+ ): Promise<any> => {
1413
+ return getTools((ctx as { cwd: string }).cwd)[name].execute(toolCallId, params, signal, onUpdate);
1414
+ };
1415
+
1416
+ for (const name of ["read", "bash", "edit", "write", "find", "grep", "ls"] as const) {
1417
+ pi.registerTool({
1418
+ name,
1419
+ label: name,
1420
+ description: `Built-in ${name} (rendering handled by compact-ui group).`,
1421
+ parameters: getTools(process.cwd())[name].parameters,
1422
+ execute: delegate(name),
1423
+ renderCall: () => new Text("", 0, 0),
1424
+ renderResult: () => new Text("", 0, 0),
1425
+ });
1426
+ }
1427
+
1428
+ pi.on("session_start", async (_event, ctx) => {
1429
+ currentTheme = ctx.ui.theme;
1430
+ ctx.ui.setHiddenThinkingLabel("");
1431
+ // Capture the TUI instance via setWidget's factory so the animation can
1432
+ // call its throttled requestRender() to repaint just the changed cells.
1433
+ ctx.ui.setWidget("compact-anim", (tui: any) => {
1434
+ capturedTui = tui;
1435
+ return { render: () => [] as string[], invalidate() {} };
1436
+ });
1437
+ installGrouping();
1438
+ installNativeThinkingSuppression();
1439
+ installCompactionSummaryRendering();
1440
+ });
1441
+
1442
+ pi.on("tool_execution_start", async (event) => {
1443
+ toolStarts.set(event.toolCallId, Date.now());
1444
+ lastActiveGroup?.invalidate();
1445
+ });
1446
+
1447
+ pi.on("tool_execution_end", async (event) => {
1448
+ for (const g of groups) {
1449
+ for (const t of g.children as any[]) {
1450
+ if (t.toolCallId === event.toolCallId) t._groupEndAt = Date.now();
1451
+ }
1452
+ }
1453
+ lastActiveGroup?.invalidate();
1454
+ });
1455
+
1456
+ pi.on("message_start", async (event) => {
1457
+ const role = (event.message as any)?.role;
1458
+ // A new user message is a hard turn boundary: seal whatever block is still
1459
+ // open. Assistant/toolResult message boundaries do NOT seal — thinking and
1460
+ // tool calls stay in one block until real (non-thinking) text appears.
1461
+ if (role === "user" && lastActiveGroup && !lastActiveGroup.sealed) {
1462
+ lastActiveGroup.sealed = true;
1463
+ finalizeActiveThinking();
1464
+ lastActiveGroup.invalidate();
1465
+ }
1466
+ if (role === "user") {
1467
+ turnStartMs = Date.now();
1468
+ activeThinking = null;
1469
+ activeThinkingIndex = null;
1470
+ activeThinkingBlocks.clear();
1471
+ unattachedThinking.length = 0;
1472
+ lastSealedThinkingEntry = null;
1473
+ lastReportedReasoningTokens = 0;
1474
+ handledTextIndexes.clear();
1475
+ sealedThinkingIndexes.clear();
1476
+ pendingTextSeal = false;
1477
+ pendingTextOrdinal = null;
1478
+ lastStreamingComp = null;
1479
+ } else if (role === "assistant") {
1480
+ // contentIndex values are local to one streamed assistant message. Close
1481
+ // any run left open by the previous message; a late thinking_end for it is
1482
+ // then rejected through sealedThinkingIndexes.
1483
+ finalizeActiveThinking();
1484
+ handledTextIndexes.clear();
1485
+ sealedThinkingIndexes.clear();
1486
+ // Reported reasoning usage belongs to one assistant message; a run sealed
1487
+ // later must not inherit the previous message's value.
1488
+ lastSealedThinkingEntry = null;
1489
+ lastReportedReasoningTokens = 0;
1490
+ pendingTextSeal = false;
1491
+ pendingTextOrdinal = null;
1492
+ // Do not insert early thinking beside the previous assistant message.
1493
+ // The addChild patch fills this with the current streaming component.
1494
+ lastStreamingComp = null;
1495
+ }
1496
+ });
1497
+
1498
+ pi.on("message_update", async (event) => {
1499
+ const msg = event.message as any;
1500
+ if (!msg || msg.role !== "assistant") return;
1501
+ const content = Array.isArray(msg.content) ? msg.content : [];
1502
+ const streamEvent = event.assistantMessageEvent as any;
1503
+ const streamType = String(streamEvent?.type ?? "");
1504
+
1505
+ if (streamType.startsWith("thinking_")) {
1506
+ // Only read the block targeted by this stream event. The surrounding
1507
+ // message is cumulative and may still contain thinking from before a
1508
+ // text boundary; scanning all content would resurrect that old block.
1509
+ const contentIndex = Number(streamEvent.contentIndex);
1510
+ const hasIndex = Number.isInteger(contentIndex);
1511
+ // A thinking_end re-emitted after a visible text boundary already sealed
1512
+ // this run must not create a second row for the same reasoning.
1513
+ if (hasIndex && sealedThinkingIndexes.has(contentIndex)) return;
1514
+ const startsRun =
1515
+ streamType === "thinking_start" || !activeThinking || (hasIndex && activeThinkingIndex !== contentIndex);
1516
+ if (startsRun) {
1517
+ // Each run is its own entry: close the previous one instead of merging.
1518
+ finalizeActiveThinking();
1519
+ activeThinkingIndex = hasIndex ? contentIndex : null;
1520
+ createThinkingEntry();
1521
+ }
1522
+ const block = hasIndex ? content[contentIndex] : undefined;
1523
+ const blockText =
1524
+ block?.type === "thinking"
1525
+ ? String(block.thinking ?? "")
1526
+ : streamType === "thinking_end"
1527
+ ? String(streamEvent.content ?? "")
1528
+ : "";
1529
+ if (hasIndex) activeThinkingBlocks.set(contentIndex, blockText);
1530
+ updateActiveThinkingTokens(msg);
1531
+ // Drop the row in as soon as the run starts — even before its first delta —
1532
+ // so the spinner reports the in-progress state.
1533
+ ensureThinkingGroup();
1534
+ if (streamType === "thinking_end") finalizeActiveThinking();
1535
+ } else if (streamType.startsWith("text_")) {
1536
+ const contentIndex = Number(streamEvent.contentIndex);
1537
+ const block = Number.isInteger(contentIndex) ? content[contentIndex] : undefined;
1538
+ const text = block?.type === "text" ? String(block.text ?? "").trim() : "";
1539
+ // The first non-whitespace text is a boundary. Deduplicate by content
1540
+ // index so every later cumulative delta extends the same text block.
1541
+ if (text.length > 0 && !handledTextIndexes.has(contentIndex)) {
1542
+ if (activeThinking) updateActiveThinkingTokens(msg);
1543
+ handledTextIndexes.add(contentIndex);
1544
+ pendingTextSeal = true;
1545
+ pendingTextOrdinal =
1546
+ content
1547
+ .slice(0, Number.isInteger(contentIndex) ? contentIndex + 1 : content.length)
1548
+ .filter((item: any) => item?.type === "text" && String(item.text ?? "").trim()).length - 1;
1549
+ flushPendingTextSeal();
1550
+ }
1551
+ } else if (streamType === "done" || streamType === "error") {
1552
+ if (activeThinking) updateActiveThinkingTokens(msg);
1553
+ finalizeActiveThinking();
1554
+ refreshSealedThinkingTokens(msg?.usage?.reasoning);
1555
+ }
1556
+
1557
+ // Refresh the active block when thinking starts/stops (event-driven only;
1558
+ // no timer, so the transcript scroll position is never yanked around).
1559
+ lastActiveGroup?.invalidate();
1560
+ });
1561
+
1562
+ pi.on("agent_end", async () => {
1563
+ // Turn finished: seal the final block so it stops spinning and shows a
1564
+ // stable summary until the user starts the next turn.
1565
+ if (lastActiveGroup && !lastActiveGroup.sealed) {
1566
+ lastActiveGroup.sealed = true;
1567
+ finalizeActiveThinking();
1568
+ lastActiveGroup.invalidate();
1569
+ }
1570
+ refreshSealedThinkingTokens();
1571
+ // Separate the final visible text from the preceding work with a divider
1572
+ // that reports how long this turn ran.
1573
+ const elapsedMs = Date.now() - turnStartMs;
1574
+ insertTurnDivider(elapsedMs);
1575
+ activeThinking = null;
1576
+ activeThinkingIndex = null;
1577
+ activeThinkingBlocks.clear();
1578
+ unattachedThinking.length = 0;
1579
+ lastSealedThinkingEntry = null;
1580
+ lastReportedReasoningTokens = 0;
1581
+ handledTextIndexes.clear();
1582
+ sealedThinkingIndexes.clear();
1583
+ pendingTextSeal = false;
1584
+ pendingTextOrdinal = null;
1585
+ });
1586
+
1587
+ pi.registerCommand("compact-ui-config", {
1588
+ description: "Interactive compact-ui settings (arrows to select, Enter to adjust, Esc to close)",
1589
+ handler: async (_args, ctx) => {
1590
+ // Non-TUI modes (print/json) can't show the interactive menu.
1591
+ if (!ctx.hasUI) {
1592
+ ctx.ui.notify(
1593
+ `compact: expandedToolLines=${config.expandedToolLines}, expandedThinkingLines=${config.expandedThinkingLines}`,
1594
+ "info",
1595
+ );
1596
+ return;
1597
+ }
1598
+
1599
+ const changed = await ctx.ui.custom<boolean>((tui, theme, _keybindings, done) => {
1600
+ let anyChanged = false;
1601
+ const items: SettingItem[] = CONFIG_KEYS.map((meta) => ({
1602
+ id: meta.id,
1603
+ label: meta.label,
1604
+ currentValue: String((config as any)[meta.id]),
1605
+ description: meta.description,
1606
+ submenu: (currentValue: string, subDone: (value?: string) => void) =>
1607
+ makeStepper(meta.label, Number(currentValue), meta, theme, subDone),
1608
+ }));
1609
+ const settingsList = new SettingsList(
1610
+ items,
1611
+ Math.min(items.length, 15),
1612
+ getSettingsListTheme(),
1613
+ (id, newValue) => {
1614
+ // Persist and refresh the live groups when SettingsList commits a change.
1615
+ (config as any)[id] = Number(newValue);
1616
+ saveConfig();
1617
+ anyChanged = true;
1618
+ for (const g of groups) g.invalidate();
1619
+ },
1620
+ () => done(anyChanged),
1621
+ );
1622
+ return {
1623
+ render(width: number) {
1624
+ return settingsList.render(width);
1625
+ },
1626
+ invalidate() {
1627
+ settingsList.invalidate();
1628
+ },
1629
+ handleInput(data: string) {
1630
+ settingsList.handleInput?.(data);
1631
+ tui.requestRender();
1632
+ },
1633
+ };
1634
+ });
1635
+
1636
+ if (changed) {
1637
+ ctx.ui.notify("compact-ui settings saved", "info");
1638
+ }
1639
+ },
1640
+ });
1641
+ }