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/README.md +31 -3
- package/index.ts +3 -1744
- package/package.json +5 -3
- package/src/animation.ts +37 -0
- package/src/assistant-patches.ts +431 -0
- package/src/compaction.ts +75 -0
- package/src/config.ts +31 -0
- package/src/constants.ts +36 -0
- package/src/extension.ts +282 -0
- package/src/grouping.ts +327 -0
- package/src/guards.ts +58 -0
- package/src/helpers.ts +130 -0
- package/src/markdown.ts +98 -0
- package/src/modals.ts +579 -0
- package/src/settings.ts +142 -0
- package/src/state.ts +80 -0
- package/src/streaming-tools.ts +232 -0
- package/src/thinking.ts +62 -0
- package/src/tool-group.ts +449 -0
- package/src/types.ts +52 -0
package/index.ts
CHANGED
|
@@ -1,1744 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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";
|