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