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
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/** 按照原始条目顺序渲染折叠分组与有界 Markdown 预览。 */
|
|
2
|
+
import { Container, Markdown, visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { DefaultTextStyle } from "@earendil-works/pi-tui";
|
|
4
|
+
import type { GroupEntry, MarkdownPreview, ThinkingEntry } from "./types.js";
|
|
5
|
+
import { absorbPreparingTool, rememberToolStart, toolElapsed } from "./streaming-tools.js";
|
|
6
|
+
import { hideGroupedToolRender } from "./guards.js";
|
|
7
|
+
import { PARENT_KEY, SPINNER, spinnerStart, SPINNER_MS, GROUP_PADDING_X, GROUP_PADDING_RIGHT } from "./constants.js";
|
|
8
|
+
import { toolStatus, toolSummary, thinkingTokenLabel, toolResultText } from "./helpers.js";
|
|
9
|
+
import { getCompactMarkdownTheme, normalizeCompactCodeBlockLines } from "./markdown.js";
|
|
10
|
+
import { runtime } from "./state.js";
|
|
11
|
+
import { thinkingSpinnerFrame, scheduleAnimation } from "./animation.js";
|
|
12
|
+
import { config } from "./config.js";
|
|
13
|
+
|
|
14
|
+
export class ToolGroupComponent extends Container {
|
|
15
|
+
readonly toolCallId = `compact-group-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
16
|
+
toolName = "group";
|
|
17
|
+
/** Nested at a visible-text boundary rather than rendered at chat level. */
|
|
18
|
+
anchored = false;
|
|
19
|
+
private _expanded = false;
|
|
20
|
+
get expanded(): boolean {
|
|
21
|
+
return this._expanded;
|
|
22
|
+
}
|
|
23
|
+
/** Sealed: this block was closed by real text output — no further entries. */
|
|
24
|
+
sealed = false;
|
|
25
|
+
/** Thinking runs and tool calls, in the exact order they streamed in. */
|
|
26
|
+
entries: GroupEntry[] = [];
|
|
27
|
+
private markdownPreviewCache = new Map<string, MarkdownPreview>();
|
|
28
|
+
|
|
29
|
+
constructor() {
|
|
30
|
+
super();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
setExpanded(expanded: boolean): void {
|
|
34
|
+
this._expanded = expanded;
|
|
35
|
+
for (const tool of this.children as any[]) tool.setExpanded?.(expanded);
|
|
36
|
+
this.invalidate();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
addTool(tool: any): void {
|
|
40
|
+
absorbPreparingTool(tool);
|
|
41
|
+
hideGroupedToolRender(tool);
|
|
42
|
+
if (!tool?.result && !runtime.toolDurations.has(tool?.toolCallId)) {
|
|
43
|
+
rememberToolStart(String(tool?.toolCallId ?? ""));
|
|
44
|
+
}
|
|
45
|
+
this.children.push(tool);
|
|
46
|
+
if ((tool as any)._groupedAt === undefined) (tool as any)._groupedAt = Date.now();
|
|
47
|
+
(tool as any)[PARENT_KEY] = this;
|
|
48
|
+
this.entries.push({ kind: "tool", tool });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Append a thinking run. Idempotent: a run belongs to exactly one group. */
|
|
52
|
+
addThinking(entry: ThinkingEntry): void {
|
|
53
|
+
if (entry.owner) return;
|
|
54
|
+
entry.owner = this;
|
|
55
|
+
this.entries.push(entry);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
removeTool(tool: any): void {
|
|
59
|
+
const index = this.children.indexOf(tool);
|
|
60
|
+
if (index >= 0) this.children.splice(index, 1);
|
|
61
|
+
const entryIndex = this.entries.findIndex((item) => item.kind === "tool" && item.tool === tool);
|
|
62
|
+
if (entryIndex >= 0) this.entries.splice(entryIndex, 1);
|
|
63
|
+
if ((tool as any)?.[PARENT_KEY] === this) delete (tool as any)[PARENT_KEY];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Return only entries that should be displayed (tools, or thinking with non-empty text). */
|
|
67
|
+
getVisibleEntries(): GroupEntry[] {
|
|
68
|
+
return this.entries.filter((entry) => {
|
|
69
|
+
if (entry.kind === "tool") return true;
|
|
70
|
+
if (entry.kind === "thinking") {
|
|
71
|
+
return entry.text.trim().length > 0;
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
hasVisibleEntries(): boolean {
|
|
78
|
+
return this.getVisibleEntries().length > 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
hasPending(): boolean {
|
|
82
|
+
return this.getVisibleEntries().some((entry) => entry.kind === "tool" && toolStatus(entry.tool) === "pending");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** True while a thinking run in this group is still streaming. */
|
|
86
|
+
hasActiveThinking(): boolean {
|
|
87
|
+
return this.getVisibleEntries().some((entry) => entry.kind === "thinking" && entry.active);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True once any thinking run has been added to this group. */
|
|
91
|
+
hasThinking(): boolean {
|
|
92
|
+
return this.getVisibleEntries().some((entry) => entry.kind === "thinking");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** True while this group should keep its spinner animating. */
|
|
96
|
+
needsAnimation(): boolean {
|
|
97
|
+
if (this.sealed) return false;
|
|
98
|
+
return this.hasVisibleEntries() && (this.hasPending() || this.hasActiveThinking());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
override invalidate(): void {
|
|
102
|
+
// Theme changes and tool/thinking updates must rebuild ANSI markdown.
|
|
103
|
+
this.markdownPreviewCache.clear();
|
|
104
|
+
super.invalidate();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private renderMarkdownPreview(
|
|
108
|
+
cacheKey: string,
|
|
109
|
+
source: string,
|
|
110
|
+
width: number,
|
|
111
|
+
maxLines: number,
|
|
112
|
+
defaultTextStyle?: DefaultTextStyle,
|
|
113
|
+
): MarkdownPreview {
|
|
114
|
+
const renderWidth = Math.max(1, width);
|
|
115
|
+
const lineLimit = Math.max(1, maxLines);
|
|
116
|
+
const cached = this.markdownPreviewCache.get(cacheKey);
|
|
117
|
+
if (cached && cached.source === source && cached.width === renderWidth && cached.maxLines === lineLimit) {
|
|
118
|
+
return cached;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Only a bounded prefix can become visible. This prevents a very large
|
|
122
|
+
// command result from being reparsed in full merely to display a handful
|
|
123
|
+
// of expanded lines. Incomplete closing fences are supported by pi-tui.
|
|
124
|
+
const sourceRows = source.split("\n");
|
|
125
|
+
const sourceLineLimit = Math.max(lineLimit * 4, lineLimit + 20);
|
|
126
|
+
const sourceCharLimit = Math.max(4096, lineLimit * Math.max(40, renderWidth) * 4);
|
|
127
|
+
let markdownSource = sourceRows.slice(0, sourceLineLimit).join("\n");
|
|
128
|
+
let sourceTruncated = sourceRows.length > sourceLineLimit;
|
|
129
|
+
if (markdownSource.length > sourceCharLimit) {
|
|
130
|
+
markdownSource = markdownSource.slice(0, sourceCharLimit);
|
|
131
|
+
sourceTruncated = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const markdown = new Markdown(markdownSource, 0, 0, getCompactMarkdownTheme(), defaultTextStyle);
|
|
135
|
+
const rendered = normalizeCompactCodeBlockLines(markdown.render(renderWidth), renderWidth);
|
|
136
|
+
const preview: MarkdownPreview = {
|
|
137
|
+
source,
|
|
138
|
+
width: renderWidth,
|
|
139
|
+
maxLines: lineLimit,
|
|
140
|
+
lines: rendered.slice(0, lineLimit),
|
|
141
|
+
truncated: sourceTruncated || rendered.length > lineLimit,
|
|
142
|
+
};
|
|
143
|
+
this.markdownPreviewCache.set(cacheKey, preview);
|
|
144
|
+
return preview;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private iconFor(tool: any, frame: string): string {
|
|
148
|
+
const st = toolStatus(tool);
|
|
149
|
+
return st === "pending" ? frame : st === "error" ? "✗" : "✓";
|
|
150
|
+
}
|
|
151
|
+
private colorFor(status: string): string {
|
|
152
|
+
return status === "pending" ? "accent" : status === "error" ? "error" : "success";
|
|
153
|
+
}
|
|
154
|
+
// Tool name in bold accent, tool payload in dim, elapsed right-aligned to width.
|
|
155
|
+
private toolRow(rail: string, tool: any, frame: string, width: number, isDim = false): string {
|
|
156
|
+
const theme = runtime.currentTheme;
|
|
157
|
+
const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
|
|
158
|
+
const bold = theme?.bold ? theme.bold : (t: string) => t;
|
|
159
|
+
const st = toolStatus(tool);
|
|
160
|
+
const s = toolSummary(tool.toolName, tool.args);
|
|
161
|
+
const prefix = rail ? fg("dim", rail) : "";
|
|
162
|
+
const leftHeader = isDim
|
|
163
|
+
? `${prefix}${fg("dim", this.iconFor(tool, frame))} ${fg("dim", s.name)}`
|
|
164
|
+
: `${prefix}${fg(this.colorFor(st), this.iconFor(tool, frame))} ${fg("toolTitle", bold(s.name))}`;
|
|
165
|
+
const right = fg(isDim ? "dim" : "muted", `(${toolElapsed(tool)}s)`);
|
|
166
|
+
const rightLen = visibleWidth(right);
|
|
167
|
+
const headerLen = visibleWidth(leftHeader);
|
|
168
|
+
|
|
169
|
+
if (s.content) {
|
|
170
|
+
const maxContentLen = width - headerLen - 1 - rightLen - 1;
|
|
171
|
+
if (maxContentLen > 3) {
|
|
172
|
+
const truncatedContent = truncateToWidth(s.content, maxContentLen, fg("dim", "..."));
|
|
173
|
+
const left = `${leftHeader} ${fg("dim", truncatedContent)}`;
|
|
174
|
+
const gap = Math.max(1, width - visibleWidth(left) - rightLen);
|
|
175
|
+
return `${left}${" ".repeat(gap)}${right}`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const gap = Math.max(1, width - headerLen - rightLen);
|
|
180
|
+
return `${leftHeader}${" ".repeat(gap)}${right}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// One row per thinking run. The spinner marks the run that is still streaming;
|
|
184
|
+
// the completion mark is written as soon as the run ends. Token label is right-aligned.
|
|
185
|
+
private thinkingRow(
|
|
186
|
+
rail: string,
|
|
187
|
+
entry: ThinkingEntry,
|
|
188
|
+
width: number,
|
|
189
|
+
showPreview = true,
|
|
190
|
+
isDim = false,
|
|
191
|
+
): string {
|
|
192
|
+
const theme = runtime.currentTheme;
|
|
193
|
+
const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
|
|
194
|
+
const spin = entry.active ? thinkingSpinnerFrame() : undefined;
|
|
195
|
+
const icon = spin?.frame ?? "✓";
|
|
196
|
+
const iconColor = isDim ? "dim" : (spin?.color ?? "thinkingText");
|
|
197
|
+
const prefix = rail ? fg("dim", rail) : "";
|
|
198
|
+
const preview = showPreview ? entry.text.trim().replace(/[*_#`>]+/g, "") : "";
|
|
199
|
+
|
|
200
|
+
if (showPreview && !preview) {
|
|
201
|
+
const label = entry.active ? "thinking..." : "thinking";
|
|
202
|
+
const titleColor = isDim ? "dim" : "toolTitle";
|
|
203
|
+
return `${prefix}${fg(iconColor, icon)} ${fg(titleColor, label)}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const titleColor = isDim ? "dim" : "toolTitle";
|
|
207
|
+
const leftHeader = `${prefix}${fg(iconColor, icon)} ${fg(titleColor, "thinking")}`;
|
|
208
|
+
|
|
209
|
+
const tokenLabel = thinkingTokenLabel(entry);
|
|
210
|
+
const right = fg(isDim ? "dim" : "muted", tokenLabel);
|
|
211
|
+
const rightLen = visibleWidth(right);
|
|
212
|
+
const headerLen = visibleWidth(leftHeader);
|
|
213
|
+
|
|
214
|
+
if (preview) {
|
|
215
|
+
const maxPreviewLen = width - headerLen - 1 - rightLen - 1;
|
|
216
|
+
if (maxPreviewLen > 3) {
|
|
217
|
+
const contentColor = isDim ? "dim" : "thinkingText";
|
|
218
|
+
const ellipsis = fg(contentColor, "...");
|
|
219
|
+
const truncated = truncateToWidth(preview.replace(/\s+/g, " "), maxPreviewLen, ellipsis);
|
|
220
|
+
const left = `${leftHeader} ${fg(contentColor, truncated)}`;
|
|
221
|
+
const gap = Math.max(1, width - visibleWidth(left) - rightLen);
|
|
222
|
+
return `${left}${" ".repeat(gap)}${right}`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const gap = Math.max(1, width - headerLen - rightLen);
|
|
227
|
+
return `${leftHeader}${" ".repeat(gap)}${right}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Folded: header + one line per entry, in stream order (oldest first).
|
|
231
|
+
// When there is only 1 visible entry, do not show hierarchy (no header, no tree rail).
|
|
232
|
+
private renderCollapsed(width: number, visible: GroupEntry[]): string[] {
|
|
233
|
+
const theme = runtime.currentTheme;
|
|
234
|
+
const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
|
|
235
|
+
const frame = SPINNER[Math.floor((Date.now() - spinnerStart) / SPINNER_MS) % SPINNER.length]!;
|
|
236
|
+
|
|
237
|
+
if (visible.length === 1) {
|
|
238
|
+
const entry = visible[0]!;
|
|
239
|
+
if (entry.kind === "tool") {
|
|
240
|
+
return [this.toolRow("", entry.tool, frame, width)];
|
|
241
|
+
}
|
|
242
|
+
return [this.thinkingRow("", entry, width)];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const lines: string[] = [];
|
|
246
|
+
|
|
247
|
+
const hasPendingTool = visible.some((e) => e.kind === "tool" && toolStatus(e.tool) === "pending");
|
|
248
|
+
const isThinking = !this.sealed && visible.some((e) => e.kind === "thinking" && e.active);
|
|
249
|
+
const hasTools = visible.some((e) => e.kind === "tool");
|
|
250
|
+
const hasThinking = visible.some((e) => e.kind === "thinking");
|
|
251
|
+
const openNoTools = !this.sealed && !hasTools;
|
|
252
|
+
const working = hasPendingTool || isThinking || openNoTools;
|
|
253
|
+
const state = hasPendingTool
|
|
254
|
+
? "tool calling..."
|
|
255
|
+
: !hasTools
|
|
256
|
+
? working
|
|
257
|
+
? "thinking..."
|
|
258
|
+
: "thinking"
|
|
259
|
+
: isThinking
|
|
260
|
+
? "thinking..."
|
|
261
|
+
: hasThinking
|
|
262
|
+
? "tools and thinking done"
|
|
263
|
+
: "tools done";
|
|
264
|
+
const stateColor = hasPendingTool ? "accent" : hasTools && !isThinking ? "success" : "thinkingText";
|
|
265
|
+
const thinkingSpin = !hasPendingTool && working ? thinkingSpinnerFrame() : undefined;
|
|
266
|
+
// Left icon: spinner while working, completion mark once the group is done.
|
|
267
|
+
const leftIcon = working ? (thinkingSpin?.frame ?? frame) : "✓";
|
|
268
|
+
const leftColor = thinkingSpin?.color ?? stateColor;
|
|
269
|
+
|
|
270
|
+
const maxEntries = config.maxGroupEntries ?? 5;
|
|
271
|
+
const hiddenCount = visible.length > maxEntries ? visible.length - maxEntries : 0;
|
|
272
|
+
const hiddenSuffix = hiddenCount > 0 ? ` ${fg("muted", `(${hiddenCount} hidden)`)}` : "";
|
|
273
|
+
lines.push(`${fg(leftColor, leftIcon)} ${fg(stateColor, state)}${hiddenSuffix}`);
|
|
274
|
+
|
|
275
|
+
// Every entry is shown, one line each, in the order the model produced it.
|
|
276
|
+
// When hiddenCount > 0, hide oldest entries and fade the top visible entry.
|
|
277
|
+
const entriesToRender = hiddenCount > 0 ? visible.slice(hiddenCount) : visible;
|
|
278
|
+
const total = entriesToRender.length;
|
|
279
|
+
for (let index = 0; index < total; index++) {
|
|
280
|
+
const entry = entriesToRender[index]!;
|
|
281
|
+
const rail = index === total - 1 ? "└ " : "│ ";
|
|
282
|
+
const isDim = hiddenCount > 0 && index === 0;
|
|
283
|
+
lines.push(
|
|
284
|
+
entry.kind === "thinking"
|
|
285
|
+
? this.thinkingRow(rail, entry, width, true, isDim)
|
|
286
|
+
: this.toolRow(rail, entry.tool, frame, width, isDim),
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return lines;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Expanded: per-tool detail + every thinking run, in stream order.
|
|
294
|
+
// When there is only 1 visible entry, do not show hierarchy (no header, no tree rail).
|
|
295
|
+
private renderExpanded(width: number, visible: GroupEntry[]): string[] {
|
|
296
|
+
const theme = runtime.currentTheme;
|
|
297
|
+
const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
|
|
298
|
+
const frame = SPINNER[Math.floor((Date.now() - spinnerStart) / SPINNER_MS) % SPINNER.length]!;
|
|
299
|
+
const lines: string[] = [];
|
|
300
|
+
|
|
301
|
+
if (visible.length === 1) {
|
|
302
|
+
const entry = visible[0]!;
|
|
303
|
+
const sub = " ";
|
|
304
|
+
if (entry.kind === "tool") {
|
|
305
|
+
const tool = entry.tool;
|
|
306
|
+
lines.push(this.toolRow("", tool, frame, width));
|
|
307
|
+
const result = toolResultText(tool);
|
|
308
|
+
if (result) {
|
|
309
|
+
const markdownWidth = Math.max(1, width - sub.length);
|
|
310
|
+
const preview = this.renderMarkdownPreview(
|
|
311
|
+
`tool:${tool.toolCallId ?? 0}`,
|
|
312
|
+
result,
|
|
313
|
+
markdownWidth,
|
|
314
|
+
config.expandedToolLines,
|
|
315
|
+
{ color: (text) => runtime.currentTheme?.fg?.("toolOutput", text) ?? text },
|
|
316
|
+
);
|
|
317
|
+
for (const row of preview.lines) {
|
|
318
|
+
lines.push(`${fg("dim", sub)}${row}`);
|
|
319
|
+
}
|
|
320
|
+
if (preview.truncated) {
|
|
321
|
+
lines.push(`${fg("dim", sub)}${fg("muted", "...")}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return lines;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
lines.push(this.thinkingRow("", entry, width, false));
|
|
328
|
+
const tText = entry.text.trim();
|
|
329
|
+
if (tText) {
|
|
330
|
+
const markdownWidth = Math.max(1, width - sub.length);
|
|
331
|
+
const preview = this.renderMarkdownPreview(
|
|
332
|
+
`thinking:${entry.id}`,
|
|
333
|
+
tText,
|
|
334
|
+
markdownWidth,
|
|
335
|
+
config.expandedThinkingLines,
|
|
336
|
+
{ color: (text) => runtime.currentTheme?.fg?.("thinkingText", text) ?? text, italic: true },
|
|
337
|
+
);
|
|
338
|
+
for (const row of preview.lines) {
|
|
339
|
+
lines.push(`${fg("dim", sub)}${row}`);
|
|
340
|
+
}
|
|
341
|
+
if (preview.truncated) {
|
|
342
|
+
lines.push(`${fg("dim", sub)}${fg("muted", "...")}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return lines;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const hasPendingTool = visible.some((e) => e.kind === "tool" && toolStatus(e.tool) === "pending");
|
|
349
|
+
const isThinking = !this.sealed && visible.some((e) => e.kind === "thinking" && e.active);
|
|
350
|
+
// See renderCollapsed: a block without tools keeps the thinking label, and a
|
|
351
|
+
// block with reasoning must not claim "tools done" alone.
|
|
352
|
+
const hasTools = visible.some((e) => e.kind === "tool");
|
|
353
|
+
const hasThinking = visible.some((e) => e.kind === "thinking");
|
|
354
|
+
const openNoTools = !this.sealed && !hasTools;
|
|
355
|
+
const working = hasPendingTool || isThinking || openNoTools;
|
|
356
|
+
const state = hasPendingTool
|
|
357
|
+
? "tool calling..."
|
|
358
|
+
: !hasTools
|
|
359
|
+
? working
|
|
360
|
+
? "thinking..."
|
|
361
|
+
: "thinking"
|
|
362
|
+
: isThinking
|
|
363
|
+
? "thinking..."
|
|
364
|
+
: hasThinking
|
|
365
|
+
? "tools and thinking done"
|
|
366
|
+
: "tools done";
|
|
367
|
+
const stateColor = hasPendingTool ? "accent" : hasTools && !isThinking ? "success" : "thinkingText";
|
|
368
|
+
const thinkingSpin = !hasPendingTool && working ? thinkingSpinnerFrame() : undefined;
|
|
369
|
+
// Left icon: spinner while working, completion mark once the group is done.
|
|
370
|
+
const leftIcon = working ? (thinkingSpin?.frame ?? frame) : "✓";
|
|
371
|
+
const leftColor = thinkingSpin?.color ?? stateColor;
|
|
372
|
+
lines.push(`${fg(leftColor, leftIcon)} ${fg(stateColor, state)}`);
|
|
373
|
+
|
|
374
|
+
const total = visible.length;
|
|
375
|
+
for (let index = 0; index < total; index++) {
|
|
376
|
+
const entry = visible[index]!;
|
|
377
|
+
const isLast = index === total - 1;
|
|
378
|
+
const rail = isLast ? "└─ " : "├─ ";
|
|
379
|
+
const sub = isLast ? " " : "│ ";
|
|
380
|
+
if (entry.kind === "tool") {
|
|
381
|
+
const tool = entry.tool;
|
|
382
|
+
lines.push(this.toolRow(rail, tool, frame, width));
|
|
383
|
+
const result = toolResultText(tool);
|
|
384
|
+
if (result) {
|
|
385
|
+
const markdownWidth = Math.max(1, width - sub.length);
|
|
386
|
+
const preview = this.renderMarkdownPreview(
|
|
387
|
+
`tool:${tool.toolCallId ?? index}`,
|
|
388
|
+
result,
|
|
389
|
+
markdownWidth,
|
|
390
|
+
config.expandedToolLines,
|
|
391
|
+
{ color: (text) => runtime.currentTheme?.fg?.("toolOutput", text) ?? text },
|
|
392
|
+
);
|
|
393
|
+
for (const row of preview.lines) {
|
|
394
|
+
lines.push(`${fg("dim", sub)}${row}`);
|
|
395
|
+
}
|
|
396
|
+
if (preview.truncated) {
|
|
397
|
+
lines.push(`${fg("dim", sub)}${fg("muted", "...")}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
lines.push(this.thinkingRow(rail, entry, width, false));
|
|
404
|
+
const tText = entry.text.trim();
|
|
405
|
+
if (tText) {
|
|
406
|
+
const markdownWidth = Math.max(1, width - sub.length);
|
|
407
|
+
const preview = this.renderMarkdownPreview(
|
|
408
|
+
`thinking:${entry.id}`,
|
|
409
|
+
tText,
|
|
410
|
+
markdownWidth,
|
|
411
|
+
config.expandedThinkingLines,
|
|
412
|
+
{ color: (text) => runtime.currentTheme?.fg?.("thinkingText", text) ?? text, italic: true },
|
|
413
|
+
);
|
|
414
|
+
for (const row of preview.lines) {
|
|
415
|
+
lines.push(`${fg("dim", sub)}${row}`);
|
|
416
|
+
}
|
|
417
|
+
if (preview.truncated) {
|
|
418
|
+
lines.push(`${fg("dim", sub)}${fg("muted", "...")}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return lines;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
override render(width: number): string[] {
|
|
431
|
+
const visible = this.getVisibleEntries();
|
|
432
|
+
if (visible.length === 0) return [];
|
|
433
|
+
|
|
434
|
+
if (this.needsAnimation()) scheduleAnimation();
|
|
435
|
+
|
|
436
|
+
const padding = " ".repeat(Math.min(GROUP_PADDING_X, Math.max(0, width - 1)));
|
|
437
|
+
const contentWidth = Math.max(1, width - padding.length - GROUP_PADDING_RIGHT);
|
|
438
|
+
const lines = this._expanded
|
|
439
|
+
? this.renderExpanded(contentWidth, visible)
|
|
440
|
+
: this.renderCollapsed(contentWidth, visible);
|
|
441
|
+
if (lines.length === 0) return [];
|
|
442
|
+
const rendered = lines.map((line) => padding + truncateToWidth(line, contentWidth, "..."));
|
|
443
|
+
// Native ToolExecutionComponent starts with Spacer(1). Our custom render
|
|
444
|
+
// bypasses that child tree, so restore the same single leading gap while
|
|
445
|
+
// the group is top-level. Anchored groups receive deterministic spacing
|
|
446
|
+
// from placeAnchoredGroupBeforeText() instead.
|
|
447
|
+
return this.anchored ? rendered : ["", ...rendered];
|
|
448
|
+
}
|
|
449
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** 思考、工具、预览与组件补丁的共享类型。 */
|
|
2
|
+
import type { ToolGroupComponent } from "./tool-group.js";
|
|
3
|
+
|
|
4
|
+
export type ThinkingEntry = {
|
|
5
|
+
kind: "thinking";
|
|
6
|
+
id: number;
|
|
7
|
+
text: string;
|
|
8
|
+
tokens: number;
|
|
9
|
+
tokensExact: boolean;
|
|
10
|
+
/** Still streaming — renders the animated spinner instead of the done mark. */
|
|
11
|
+
active: boolean;
|
|
12
|
+
/** Group currently owning this entry, used for targeted invalidation. */
|
|
13
|
+
owner: ToolGroupComponent | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type GroupEntry = { kind: "tool"; tool: any } | ThinkingEntry;
|
|
17
|
+
|
|
18
|
+
export type ToolStatus = "pending" | "success" | "error";
|
|
19
|
+
|
|
20
|
+
export type PreparingTool = {
|
|
21
|
+
toolName: string;
|
|
22
|
+
toolCallId: string;
|
|
23
|
+
args: any;
|
|
24
|
+
isPartial: true;
|
|
25
|
+
_preparing: true;
|
|
26
|
+
_contentIndex: number;
|
|
27
|
+
owner: ToolGroupComponent | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type MarkdownPreview = {
|
|
31
|
+
source: string;
|
|
32
|
+
width: number;
|
|
33
|
+
maxLines: number;
|
|
34
|
+
lines: string[];
|
|
35
|
+
truncated: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type PatchState = {
|
|
39
|
+
active: boolean;
|
|
40
|
+
original: { addChild: Function; removeChild: Function; clear: Function };
|
|
41
|
+
installed: { addChild: Function; removeChild: Function; clear: Function };
|
|
42
|
+
prototype: any;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type AssistantContentState = {
|
|
46
|
+
/** Sealed groups keyed by the visible Markdown block they precede. */
|
|
47
|
+
anchors: Map<number, ToolGroupComponent>;
|
|
48
|
+
/** Visible Markdown ordinal while AssistantMessageComponent rebuilds. */
|
|
49
|
+
nextTextOrdinal: number;
|
|
50
|
+
/** Turn-duration divider bound to the final visible Markdown ordinal. */
|
|
51
|
+
finalDivider?: { ordinal: number; component: any };
|
|
52
|
+
};
|