@alisio/alisio-code 0.1.0-alpha.15 → 0.1.0-alpha.17
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/dist/tui/app.js +84 -13
- package/dist/tui/components.d.ts +71 -11
- package/dist/tui/components.js +465 -57
- package/dist/tui/state.d.ts +166 -1
- package/dist/tui/state.js +429 -12
- package/dist/tui/theme.d.ts +42 -1
- package/dist/tui/theme.js +516 -1
- package/package.json +4 -4
package/dist/tui/components.js
CHANGED
|
@@ -1,15 +1,193 @@
|
|
|
1
|
-
import { Container, getCapabilities, Image, Key, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
1
|
+
import { Container, getCapabilities, getImageDimensions, Image, Key, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
2
2
|
import { terminalCapabilities } from "../banner.js";
|
|
3
3
|
import { attachmentCaption, MAX_ATTACHMENTS_PER_MESSAGE, } from "./attachments.js";
|
|
4
4
|
import { branchDisplay } from "./git-branch.js";
|
|
5
5
|
import { initialQuestionState, reduceQuestions, } from "./questions.js";
|
|
6
|
-
import { contextLevel, contextPercent, editSummary, fitIdentityParts, fitSegments, formatContext, formatDuration, formatTokens, } from "./state.js";
|
|
7
|
-
import { imageTheme, levelColor, markdownTheme, style } from "./theme.js";
|
|
6
|
+
import { contextLevel, contextPercent, editSummary, entryKeyOf, fitIdentityParts, fitSegments, foldCandidateOf, formatContext, formatDuration, formatTokens, groupIdentity, groupToolEntries, humanizeToolName, previewLinesFor, previewRows, REASONING_EXPAND_MAX_LINES, reasoningDurationMs, } from "./state.js";
|
|
7
|
+
import { highlightCode, imageTheme, levelColor, markdownDefaultTextStyle, markdownTheme, markdownTransform, style, } from "./theme.js";
|
|
8
8
|
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
9
9
|
/** Shared animation clock advanced by the app while work is running. */
|
|
10
10
|
export const clock = { frame: 0, now: Date.now() };
|
|
11
11
|
const fit = (lines, width) => lines.map((l) => truncateToWidth(l, width));
|
|
12
12
|
const wrap = (text, width) => text.split("\n").flatMap((line) => (line ? wrapTextWithAnsi(line, Math.max(1, width)) : [""]));
|
|
13
|
+
/** ANSI-aware right padding to a visible width. */
|
|
14
|
+
const padTo = (text, width) => `${text}${" ".repeat(Math.max(0, width - visibleWidth(text)))}`;
|
|
15
|
+
const wrapCell = (text, width) => text.split("\n").flatMap((line) => (line ? wrapTextWithAnsi(line, Math.max(1, width)) : [""]));
|
|
16
|
+
/**
|
|
17
|
+
* Rendered body of a tool call's rich parts (inline image + native ui block), reusing the exact
|
|
18
|
+
* singleton rendering so grouped detail rows and plain rows agree. Returns [] when there is none.
|
|
19
|
+
*/
|
|
20
|
+
function richBodyLines(view, width, markdownCache) {
|
|
21
|
+
const lines = [];
|
|
22
|
+
if (view.image) {
|
|
23
|
+
const inline = getCapabilities().images !== null && process.env.NO_COLOR === undefined;
|
|
24
|
+
if (inline) {
|
|
25
|
+
const image = new Image(view.image.data, view.image.mimeType, imageTheme, {
|
|
26
|
+
maxWidthCells: Math.max(10, Math.min(60, width - 4)),
|
|
27
|
+
});
|
|
28
|
+
lines.push(...image.render(Math.max(1, width - 2)));
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
const dims = getImageDimensions(view.image.data, view.image.mimeType);
|
|
32
|
+
lines.push(style.dim(`[image: ${view.image.mimeType}${dims ? ` ${dims.widthPx}x${dims.heightPx}` : ""}]`));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (view.ui) {
|
|
36
|
+
const inner = Math.max(1, width - 2);
|
|
37
|
+
if (view.ui.kind === "markdown") {
|
|
38
|
+
// Cache the Markdown instance so streaming/resizes never re-parse every frame.
|
|
39
|
+
if (markdownCache?.text !== view.ui.text)
|
|
40
|
+
markdownCache = {
|
|
41
|
+
text: view.ui.text,
|
|
42
|
+
renderer: new Markdown(view.ui.text, 0, 0, markdownTheme, markdownDefaultTextStyle, {
|
|
43
|
+
transform: markdownTransform,
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
lines.push(...markdownCache.renderer.render(inner));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
lines.push(...renderUiBlock(view.ui, width, unicodeAt(width)));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { lines, markdownCache: markdownCache ?? undefined };
|
|
53
|
+
}
|
|
54
|
+
/** Terminal Unicode support, mirroring the copy hint detection in AssistantBlock. */
|
|
55
|
+
const unicodeAt = (width) => terminalCapabilities({ env: process.env, columns: width, tty: true }).unicode;
|
|
56
|
+
/** Capped preview rows of one member call inside an expanded group (`… N more lines` footer). */
|
|
57
|
+
function memberPreviewLines(member, indent) {
|
|
58
|
+
const rows = previewRows(member.preview ?? "");
|
|
59
|
+
if (!rows.length)
|
|
60
|
+
return [];
|
|
61
|
+
const cap = previewLinesFor(member.status);
|
|
62
|
+
const shown = rows.slice(0, cap);
|
|
63
|
+
const paint = member.status === "error" ? style.red : style.gray;
|
|
64
|
+
const lines = shown.map((line, n) => `${indent} ${style.gray(n === 0 ? "⎿" : " ")} ${paint(line.replace(/\t/g, " "))}`);
|
|
65
|
+
if (rows.length > shown.length)
|
|
66
|
+
lines.push(style.gray(`${indent} … ${rows.length - shown.length} more lines`));
|
|
67
|
+
return lines;
|
|
68
|
+
}
|
|
69
|
+
/** `Command exited with code 0.` (green) / `Command exited with code N.` (red) when known. */
|
|
70
|
+
export const exitCodeLine = (code, indent = " ") => code === undefined
|
|
71
|
+
? undefined
|
|
72
|
+
: `${indent}${code === 0 ? style.green("Command exited with code 0.") : style.red(`Command exited with code ${code}.`)}`;
|
|
73
|
+
/**
|
|
74
|
+
* Aligned-column table rendering: cells wrap to their column width, the header is bold, a dim
|
|
75
|
+
* separator row sits under it, and the whole block respects the given inner width. Long values
|
|
76
|
+
* wrap instead of being cut; the final `truncateToWidth` pass only bites on very narrow
|
|
77
|
+
* terminals.
|
|
78
|
+
*/
|
|
79
|
+
function renderTableBlock(block, width) {
|
|
80
|
+
const caption = block.caption ? [style.dim(block.caption)] : [];
|
|
81
|
+
if (!block.columns.length)
|
|
82
|
+
return [...caption, style.gray("(empty table)")];
|
|
83
|
+
const inner = Math.max(1, width - 2);
|
|
84
|
+
const n = block.columns.length;
|
|
85
|
+
const natural = block.columns.map((header, i) => Math.max(1, visibleWidth(header), ...block.rows.map((row) => visibleWidth(row[i] ?? ""))));
|
|
86
|
+
const separatorCost = (n - 1) * 3;
|
|
87
|
+
const totalNatural = natural.reduce((a, b) => a + b, 0);
|
|
88
|
+
let widths;
|
|
89
|
+
if (totalNatural + separatorCost <= inner) {
|
|
90
|
+
widths = natural;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const budget = Math.max(n, inner - separatorCost);
|
|
94
|
+
const scale = Math.min(1, budget / totalNatural);
|
|
95
|
+
widths = natural.map((w) => Math.max(1, Math.floor(w * scale)));
|
|
96
|
+
let left = budget - widths.reduce((a, b) => a + b, 0);
|
|
97
|
+
// Hand the leftover to the widest columns that still want space (round-robin, deterministic).
|
|
98
|
+
for (let round = 0; round < n && left > 0; round++) {
|
|
99
|
+
const widest = natural.findIndex((w, i) => (widths[i] ?? 0) < w);
|
|
100
|
+
if (widest < 0)
|
|
101
|
+
break;
|
|
102
|
+
widths[widest] = (widths[widest] ?? 0) + 1;
|
|
103
|
+
left--;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const cols = block.columns.map((header, i) => ({
|
|
107
|
+
header: wrapCell(header, widths[i] ?? 1),
|
|
108
|
+
body: block.rows.map((row) => wrapCell(row[i] ?? "", widths[i] ?? 1)),
|
|
109
|
+
}));
|
|
110
|
+
const headerHeight = Math.max(...cols.map((c) => c.header.length));
|
|
111
|
+
const lines = [];
|
|
112
|
+
for (let li = 0; li < headerHeight; li++)
|
|
113
|
+
lines.push(cols.map((c, i) => style.bold(padTo(c.header[li] ?? "", widths[i] ?? 1))).join(" | "));
|
|
114
|
+
lines.push(style.gray(cols.map((c, i) => "─".repeat(widths[i] ?? 1)).join(" │ ")));
|
|
115
|
+
for (const [ri] of block.rows.entries()) {
|
|
116
|
+
const wrapped = cols.map((c, i) => c.body[ri] ?? [""]);
|
|
117
|
+
const height = Math.max(...wrapped.map((w) => w.length));
|
|
118
|
+
for (let li = 0; li < height; li++)
|
|
119
|
+
lines.push(cols.map((c, i) => padTo(wrapped[i]?.[li] ?? "", widths[i] ?? 1)).join(" | "));
|
|
120
|
+
}
|
|
121
|
+
return [...caption, ...lines];
|
|
122
|
+
}
|
|
123
|
+
/** Two-column key-value rendering: bright-cyan keys padded left, values wrapped on the right. */
|
|
124
|
+
function renderKeyValueBlock(block, width) {
|
|
125
|
+
const caption = block.caption ? [style.dim(block.caption)] : [];
|
|
126
|
+
if (!block.entries.length)
|
|
127
|
+
return [...caption, style.gray("(empty)")];
|
|
128
|
+
const inner = Math.max(1, width - 2);
|
|
129
|
+
const keyWidth = Math.min(24, Math.max(...block.entries.map(([k]) => visibleWidth(k)), 1));
|
|
130
|
+
const valueWidth = Math.max(1, inner - keyWidth - 2);
|
|
131
|
+
const lines = [];
|
|
132
|
+
for (const [key, value] of block.entries) {
|
|
133
|
+
wrapCell(value, valueWidth).forEach((line, i) => {
|
|
134
|
+
const keyPart = i === 0 ? style.brightCyan(padTo(key, keyWidth)) : " ".repeat(keyWidth);
|
|
135
|
+
lines.push(`${keyPart} ${line}`);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return [...caption, ...lines];
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Tree rendering with branch glyphs: `├─`/`└─`/`│` when the terminal supports Unicode, ASCII
|
|
142
|
+
* `|-`/`` `- ``/`|` otherwise.
|
|
143
|
+
*/
|
|
144
|
+
export function renderTreeLines(nodes, unicode) {
|
|
145
|
+
const branch = unicode ? "├─ " : "|- ";
|
|
146
|
+
const last = unicode ? "└─ " : "`- ";
|
|
147
|
+
const vertical = unicode ? "│ " : "| ";
|
|
148
|
+
const gap = " ";
|
|
149
|
+
const lines = [];
|
|
150
|
+
const walk = (items, prefix) => {
|
|
151
|
+
items.forEach((node, index) => {
|
|
152
|
+
const isLast = index === items.length - 1;
|
|
153
|
+
const meta = node.meta ? ` ${style.dim(`(${node.meta})`)}` : "";
|
|
154
|
+
lines.push(`${prefix}${isLast ? last : branch}${node.label}${meta}`);
|
|
155
|
+
if (node.children?.length)
|
|
156
|
+
walk(node.children, `${prefix}${isLast ? gap : vertical}`);
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
walk(nodes, "");
|
|
160
|
+
return lines;
|
|
161
|
+
}
|
|
162
|
+
/** Mini code block: dim fenced header, highlighted body (the same highlighter as markdown), dim close. */
|
|
163
|
+
function renderCodeBlock(block, _width) {
|
|
164
|
+
const caption = block.caption ? [style.dim(block.caption)] : [];
|
|
165
|
+
return [
|
|
166
|
+
...caption,
|
|
167
|
+
style.dim(block.lang ? `\`\`\`${block.lang}` : "```"),
|
|
168
|
+
...highlightCode(block.code, block.lang),
|
|
169
|
+
style.dim("```"),
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
/** Renders a `{type:"ui"}` block to styled lines for the given inner width. */
|
|
173
|
+
export function renderUiBlock(block, width, unicode) {
|
|
174
|
+
switch (block.kind) {
|
|
175
|
+
case "table":
|
|
176
|
+
return renderTableBlock(block, width);
|
|
177
|
+
case "key-value":
|
|
178
|
+
return renderKeyValueBlock(block, width);
|
|
179
|
+
case "tree":
|
|
180
|
+
return renderTreeLines(block.nodes, unicode);
|
|
181
|
+
case "code":
|
|
182
|
+
return renderCodeBlock(block, width);
|
|
183
|
+
case "markdown": {
|
|
184
|
+
const md = new Markdown(block.text, 0, 0, markdownTheme, markdownDefaultTextStyle, {
|
|
185
|
+
transform: markdownTransform,
|
|
186
|
+
});
|
|
187
|
+
return md.render(Math.max(1, width - 2));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
13
191
|
const permission = (label, state, priority) => ({
|
|
14
192
|
text: `${label}:${state}`,
|
|
15
193
|
priority,
|
|
@@ -164,49 +342,107 @@ export class UserBlock {
|
|
|
164
342
|
];
|
|
165
343
|
}
|
|
166
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Subtle, non-interactive copy affordance under a completed response: it only tells the user the
|
|
347
|
+
* raw response can be copied (`/copy`, or `c` on an empty input); it never triggers itself.
|
|
348
|
+
* Falls back to ASCII when the terminal cannot render Unicode (dumb terminal / C locale).
|
|
349
|
+
*/
|
|
350
|
+
const copyHint = (width) => {
|
|
351
|
+
const unicode = terminalCapabilities({ env: process.env, columns: width, tty: true }).unicode;
|
|
352
|
+
return ` ${style.dim(unicode ? "⎘ copy · /copy" : "[copy] · /copy")}`;
|
|
353
|
+
};
|
|
167
354
|
export class AssistantBlock {
|
|
168
|
-
markdown = new Markdown("", 0, 0, markdownTheme
|
|
355
|
+
markdown = new Markdown("", 0, 0, markdownTheme, markdownDefaultTextStyle, {
|
|
356
|
+
transform: markdownTransform,
|
|
357
|
+
});
|
|
358
|
+
/** Bumped by TranscriptSync when this row's content changes; render caches on it. */
|
|
359
|
+
version = 0;
|
|
360
|
+
fold;
|
|
169
361
|
item;
|
|
362
|
+
cache;
|
|
170
363
|
constructor(item) {
|
|
171
364
|
this.item = item;
|
|
172
365
|
this.markdown.setText(item.text);
|
|
173
366
|
}
|
|
174
|
-
update(item) {
|
|
367
|
+
update(item, fold) {
|
|
175
368
|
if (item.text !== this.item.text)
|
|
176
369
|
this.markdown.setText(item.text);
|
|
177
370
|
this.item = item;
|
|
371
|
+
this.fold = fold;
|
|
178
372
|
}
|
|
179
373
|
invalidate() {
|
|
180
374
|
this.markdown.invalidate();
|
|
375
|
+
this.cache = undefined;
|
|
181
376
|
}
|
|
182
377
|
render(width) {
|
|
183
|
-
const lines = [""];
|
|
184
378
|
const reasoning = this.item.reasoning.trim();
|
|
379
|
+
// The live thinking view animates with streaming deltas and must never be cached.
|
|
380
|
+
const live = !!reasoning && !this.item.text && !this.item.done;
|
|
381
|
+
if (!live &&
|
|
382
|
+
this.cache?.width === width &&
|
|
383
|
+
this.cache.version === this.version &&
|
|
384
|
+
this.cache.fold?.expanded === this.fold?.expanded)
|
|
385
|
+
return this.cache.lines;
|
|
386
|
+
const lines = [""];
|
|
185
387
|
if (reasoning) {
|
|
186
|
-
if (
|
|
187
|
-
const words = reasoning.split(/\s+/).length;
|
|
188
|
-
lines.push(style.dim(style.italic(`✻ reasoning (${words} words)`)));
|
|
189
|
-
}
|
|
190
|
-
else {
|
|
388
|
+
if (live) {
|
|
191
389
|
lines.push(style.dim(style.italic("✻ thinking…")));
|
|
192
390
|
lines.push(...wrap(reasoning, width - 2)
|
|
193
391
|
.slice(-4)
|
|
194
392
|
.map((l) => style.dim(` ${l}`)));
|
|
195
393
|
}
|
|
394
|
+
else {
|
|
395
|
+
// Finished: a collapsible `+ Thought · 2.9s` header; expanded shows the bounded text.
|
|
396
|
+
const expanded = this.fold?.expanded ?? false;
|
|
397
|
+
const duration = reasoningDurationMs(this.item);
|
|
398
|
+
lines.push(`${style.dim(expanded ? "−" : "+")} ${style.dim(style.italic("Thought"))}${duration !== undefined ? ` ${style.dim(`· ${formatDuration(duration)}`)}` : ""}`);
|
|
399
|
+
if (expanded) {
|
|
400
|
+
const wrapped = wrap(reasoning, Math.max(1, width - 4));
|
|
401
|
+
lines.push(...wrapped.slice(0, REASONING_EXPAND_MAX_LINES).map((l) => ` ${style.dim(l)}`));
|
|
402
|
+
if (wrapped.length > REASONING_EXPAND_MAX_LINES)
|
|
403
|
+
lines.push(style.gray(` … reasoning truncated (${wrapped.length - REASONING_EXPAND_MAX_LINES} more lines)`));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
196
406
|
}
|
|
197
|
-
if (this.item.text)
|
|
407
|
+
if (this.item.text) {
|
|
198
408
|
lines.push(...this.markdown.render(width));
|
|
199
|
-
|
|
409
|
+
// The affordance appears only once the answer is complete (never while streaming).
|
|
410
|
+
if (this.item.done)
|
|
411
|
+
lines.push(copyHint(width));
|
|
412
|
+
}
|
|
413
|
+
const out = fit(lines, width);
|
|
414
|
+
if (!live)
|
|
415
|
+
this.cache = { width, version: this.version, fold: this.fold, lines: out };
|
|
416
|
+
return out;
|
|
200
417
|
}
|
|
201
418
|
}
|
|
202
419
|
export class ToolBlock {
|
|
203
420
|
item;
|
|
421
|
+
/** Bumped by TranscriptSync when this row's content changes; render caches on it. */
|
|
422
|
+
version = 0;
|
|
423
|
+
fold;
|
|
424
|
+
markdown;
|
|
425
|
+
cache;
|
|
204
426
|
constructor(item) {
|
|
205
427
|
this.item = item;
|
|
206
428
|
}
|
|
207
|
-
|
|
429
|
+
update(item, fold) {
|
|
430
|
+
this.item = item;
|
|
431
|
+
this.fold = fold;
|
|
432
|
+
}
|
|
433
|
+
invalidate() {
|
|
434
|
+
this.cache = undefined;
|
|
435
|
+
}
|
|
208
436
|
render(width) {
|
|
209
437
|
const i = this.item;
|
|
438
|
+
// Running/approval rows animate and must never be cached; terminal rows only change when
|
|
439
|
+
// TranscriptSync bumps `version`, so clock frames between events reuse the cached lines.
|
|
440
|
+
const live = i.status === "running" || i.status === "approval";
|
|
441
|
+
if (!live &&
|
|
442
|
+
this.cache?.width === width &&
|
|
443
|
+
this.cache.version === this.version &&
|
|
444
|
+
this.cache.fold?.expanded === this.fold?.expanded)
|
|
445
|
+
return this.cache.lines;
|
|
210
446
|
const icon = i.status === "running"
|
|
211
447
|
? style.cyan(SPINNER[clock.frame % SPINNER.length] ?? "…")
|
|
212
448
|
: i.status === "approval"
|
|
@@ -214,12 +450,13 @@ export class ToolBlock {
|
|
|
214
450
|
: i.status === "ok"
|
|
215
451
|
? style.green("✓")
|
|
216
452
|
: style.red("✗");
|
|
453
|
+
const marker = this.fold ? `${style.dim(this.fold.expanded ? "−" : "+")} ` : "";
|
|
217
454
|
const right = i.status === "approval"
|
|
218
455
|
? style.yellow("awaiting approval")
|
|
219
456
|
: i.durationMs !== undefined
|
|
220
457
|
? style.gray(formatDuration(i.durationMs))
|
|
221
458
|
: "";
|
|
222
|
-
const head = `${icon} ${style.bold(i.name)} ${style.gray(i.summary)}`;
|
|
459
|
+
const head = `${marker}${icon} ${style.bold(humanizeToolName(i.name))} ${style.gray(i.summary)}`;
|
|
223
460
|
const gap = width - visibleWidth(head) - visibleWidth(right) - 1;
|
|
224
461
|
const lines = [
|
|
225
462
|
gap > 0 ? `${head}${" ".repeat(gap + 1)}${right}` : truncateToWidth(head, width),
|
|
@@ -233,17 +470,93 @@ export class ToolBlock {
|
|
|
233
470
|
if (diff.lines.length > max)
|
|
234
471
|
lines.push(style.gray(` … ${diff.lines.length - max} more lines`));
|
|
235
472
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
473
|
+
// Native rich rendering: an inline image (when the terminal supports images and color is
|
|
474
|
+
// on) and/or a structured ui block replace the plain preview, exactly as the emitted text
|
|
475
|
+
// projection reads. Every rich line is indented two spaces like the preview lines.
|
|
476
|
+
const rich = richBodyLines(i, width, this.markdown);
|
|
477
|
+
const richLines = rich.lines;
|
|
478
|
+
if (rich.markdownCache)
|
|
479
|
+
this.markdown = rich.markdownCache;
|
|
480
|
+
if (richLines.length) {
|
|
481
|
+
lines.push(...richLines.map((line) => ` ${line}`));
|
|
482
|
+
}
|
|
483
|
+
else if (i.preview && (i.status === "error" || !diff)) {
|
|
484
|
+
const rows = previewRows(i.preview);
|
|
485
|
+
const cap = previewLinesFor(i.status);
|
|
486
|
+
const expanded = this.fold?.expanded ?? false;
|
|
487
|
+
const shown = expanded ? rows : rows.slice(0, cap);
|
|
239
488
|
const paint = i.status === "error" ? style.red : style.gray;
|
|
240
489
|
shown.forEach((l, n) => {
|
|
241
490
|
lines.push(` ${style.gray(n === 0 ? "⎿" : " ")} ${paint(l.replace(/\t/g, " "))}`);
|
|
242
491
|
});
|
|
243
|
-
if (
|
|
244
|
-
lines.push(style.gray(` … ${
|
|
492
|
+
if (!expanded && rows.length > shown.length)
|
|
493
|
+
lines.push(style.gray(` … ${rows.length - shown.length} more lines`));
|
|
245
494
|
}
|
|
246
|
-
|
|
495
|
+
const exitLine = exitCodeLine(i.exitCode);
|
|
496
|
+
if (exitLine)
|
|
497
|
+
lines.push(exitLine);
|
|
498
|
+
const out = fit(lines, width);
|
|
499
|
+
if (!live)
|
|
500
|
+
this.cache = { width, version: this.version, fold: this.fold, lines: out };
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* One collapsible row for a GROUPED batch of finished tool calls (e.g. `✓ Read File — 3 reads`).
|
|
506
|
+
* Collapsed it shows the aggregate header; expanded, each call with its status, duration,
|
|
507
|
+
* summary and capped preview/rich body. All members are terminal, so lines are cached per
|
|
508
|
+
* version/width/fold just like the other terminal rows.
|
|
509
|
+
*/
|
|
510
|
+
export class ToolGroupBlock {
|
|
511
|
+
group;
|
|
512
|
+
/** Bumped by TranscriptSync when this row's content changes; render caches on it. */
|
|
513
|
+
version = 0;
|
|
514
|
+
fold;
|
|
515
|
+
cache;
|
|
516
|
+
constructor(group) {
|
|
517
|
+
this.group = group;
|
|
518
|
+
}
|
|
519
|
+
update(group, fold) {
|
|
520
|
+
this.group = group;
|
|
521
|
+
this.fold = fold;
|
|
522
|
+
}
|
|
523
|
+
invalidate() {
|
|
524
|
+
this.cache = undefined;
|
|
525
|
+
}
|
|
526
|
+
render(width) {
|
|
527
|
+
if (this.cache?.width === width &&
|
|
528
|
+
this.cache.version === this.version &&
|
|
529
|
+
this.cache.fold?.expanded === this.fold?.expanded)
|
|
530
|
+
return this.cache.lines;
|
|
531
|
+
const g = this.group;
|
|
532
|
+
const expanded = this.fold?.expanded ?? false;
|
|
533
|
+
const icon = g.status === "error" ? style.red("✗") : style.green("✓");
|
|
534
|
+
const head = `${style.dim(expanded ? "−" : "+")} ${icon} ${style.bold(g.label)} — ${g.members.length} ${g.word}${g.totalMs > 0 ? ` ${style.gray(`· ${formatDuration(g.totalMs)}`)}` : ""}`;
|
|
535
|
+
const lines = [head];
|
|
536
|
+
if (expanded) {
|
|
537
|
+
for (const member of g.members) {
|
|
538
|
+
const micon = member.status === "error" ? style.red("✗") : style.green("✓");
|
|
539
|
+
const name = member.humanName === member.name
|
|
540
|
+
? style.bold(member.humanName)
|
|
541
|
+
: `${style.bold(member.humanName)} ${style.dim(`(${member.name})`)}`;
|
|
542
|
+
lines.push(` ${micon} ${name}${member.durationMs !== undefined
|
|
543
|
+
? ` ${style.gray(`· ${formatDuration(member.durationMs)}`)}`
|
|
544
|
+
: ""}${member.summary ? ` ${style.gray(member.summary)}` : ""}`);
|
|
545
|
+
const rich = richBodyLines(member, width);
|
|
546
|
+
if (rich.lines.length) {
|
|
547
|
+
lines.push(...rich.lines.map((line) => ` ${line}`));
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
lines.push(...memberPreviewLines(member, " "));
|
|
551
|
+
}
|
|
552
|
+
const exitLine = exitCodeLine(member.exitCode, " ");
|
|
553
|
+
if (exitLine)
|
|
554
|
+
lines.push(exitLine);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const out = fit(lines, width);
|
|
558
|
+
this.cache = { width, version: this.version, fold: this.fold, lines: out };
|
|
559
|
+
return out;
|
|
247
560
|
}
|
|
248
561
|
}
|
|
249
562
|
export class LineBlock {
|
|
@@ -263,7 +576,9 @@ export class LineBlock {
|
|
|
263
576
|
export class InfoBlock {
|
|
264
577
|
markdown;
|
|
265
578
|
constructor(text) {
|
|
266
|
-
this.markdown = new Markdown(text, 1, 0, markdownTheme
|
|
579
|
+
this.markdown = new Markdown(text, 1, 0, markdownTheme, markdownDefaultTextStyle, {
|
|
580
|
+
transform: markdownTransform,
|
|
581
|
+
});
|
|
267
582
|
}
|
|
268
583
|
invalidate() {
|
|
269
584
|
this.markdown.invalidate();
|
|
@@ -273,18 +588,139 @@ export class InfoBlock {
|
|
|
273
588
|
return fit(["", ...inner.map((l) => `${style.gray("│")} ${l}`)], width);
|
|
274
589
|
}
|
|
275
590
|
}
|
|
276
|
-
export function componentFor(
|
|
277
|
-
|
|
591
|
+
export function componentFor(entry) {
|
|
592
|
+
if (entry.kind === "group")
|
|
593
|
+
return new ToolGroupBlock(entry.group);
|
|
594
|
+
switch (entry.kind) {
|
|
278
595
|
case "user":
|
|
279
|
-
return new UserBlock(
|
|
596
|
+
return new UserBlock(entry.text);
|
|
280
597
|
case "assistant":
|
|
281
|
-
return new AssistantBlock(
|
|
598
|
+
return new AssistantBlock(entry);
|
|
282
599
|
case "tool":
|
|
283
|
-
return new ToolBlock(
|
|
600
|
+
return new ToolBlock(entry);
|
|
284
601
|
case "info":
|
|
285
|
-
return new InfoBlock(
|
|
602
|
+
return new InfoBlock(entry.text);
|
|
286
603
|
default:
|
|
287
|
-
return new LineBlock(
|
|
604
|
+
return new LineBlock(entry.text, entry.kind);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/** Applies a fresh entry (and its fold state) to a component; kinds never collide under a key. */
|
|
608
|
+
function updateComponent(component, entry, fold) {
|
|
609
|
+
if (entry.kind === "assistant" && component instanceof AssistantBlock)
|
|
610
|
+
return component.update(entry, fold);
|
|
611
|
+
if (entry.kind === "tool" && component instanceof ToolBlock)
|
|
612
|
+
return component.update(entry, fold);
|
|
613
|
+
if (entry.kind === "group" && component instanceof ToolGroupBlock)
|
|
614
|
+
return component.update(entry.group, fold);
|
|
615
|
+
}
|
|
616
|
+
/** Whether a reused component can adopt the (new) entry kind (foldable rows also carry `version`). */
|
|
617
|
+
function isCompatible(component, entry) {
|
|
618
|
+
return ((entry.kind === "assistant" && component instanceof AssistantBlock) ||
|
|
619
|
+
(entry.kind === "tool" && component instanceof ToolBlock) ||
|
|
620
|
+
(entry.kind === "group" && component instanceof ToolGroupBlock) ||
|
|
621
|
+
((entry.kind === "user" ||
|
|
622
|
+
entry.kind === "notice" ||
|
|
623
|
+
entry.kind === "info" ||
|
|
624
|
+
entry.kind === "error") &&
|
|
625
|
+
!(component instanceof AssistantBlock) &&
|
|
626
|
+
!(component instanceof ToolBlock) &&
|
|
627
|
+
!(component instanceof ToolGroupBlock)));
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Keeps a container of transcript components in step with view-model items. Rows are keyed
|
|
631
|
+
* (stable per fold candidate: tool ids, group ids, append-only item indices) and reused across
|
|
632
|
+
* syncs; batch groups replace their members' singleton components with one row once all of them
|
|
633
|
+
* finish. Content versions bump only when a row's entry actually changed, so clock frames
|
|
634
|
+
* between events reuse each component's cached lines instead of re-rendering. `setFoldResolver`
|
|
635
|
+
* supplies the effective expanded state; the same resolution feeds `entryAt`, so keybindings
|
|
636
|
+
* and mouse clicks agree with the renderer.
|
|
637
|
+
*/
|
|
638
|
+
export class TranscriptSync {
|
|
639
|
+
container = new Container();
|
|
640
|
+
rows = new Map();
|
|
641
|
+
order = [];
|
|
642
|
+
foldOf = (candidate) => candidate.defaultExpanded;
|
|
643
|
+
setFoldResolver(resolver) {
|
|
644
|
+
this.foldOf = resolver;
|
|
645
|
+
}
|
|
646
|
+
sync(items) {
|
|
647
|
+
const entries = groupToolEntries(items);
|
|
648
|
+
const kept = new Set();
|
|
649
|
+
for (const { entry, at } of entries) {
|
|
650
|
+
const key = entryKeyOf(entry, at);
|
|
651
|
+
kept.add(key);
|
|
652
|
+
const candidate = foldCandidateOf(entry, at);
|
|
653
|
+
const fold = candidate
|
|
654
|
+
? { expanded: this.foldOf(candidate), kind: candidate.kind }
|
|
655
|
+
: undefined;
|
|
656
|
+
const row = this.rows.get(key);
|
|
657
|
+
if (row && isCompatible(row.component, entry)) {
|
|
658
|
+
const same = row.entry === entry ||
|
|
659
|
+
(entry.kind === "group" &&
|
|
660
|
+
row.entry.kind === "group" &&
|
|
661
|
+
groupIdentity(entry.group) === groupIdentity(row.entry.group));
|
|
662
|
+
if (!same) {
|
|
663
|
+
row.version++;
|
|
664
|
+
row.entry = entry;
|
|
665
|
+
row.at = at;
|
|
666
|
+
}
|
|
667
|
+
updateComponent(row.component, entry, fold);
|
|
668
|
+
if (row.component instanceof AssistantBlock ||
|
|
669
|
+
row.component instanceof ToolBlock ||
|
|
670
|
+
row.component instanceof ToolGroupBlock)
|
|
671
|
+
row.component.version = row.version;
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
// Key collision across kinds (defensive; keys are per-kind in practice): drop any stale
|
|
675
|
+
// component so the container never renders a duplicated row, then add the fresh one.
|
|
676
|
+
if (row)
|
|
677
|
+
this.container.removeChild(row.component);
|
|
678
|
+
const component = componentFor(entry);
|
|
679
|
+
updateComponent(component, entry, fold);
|
|
680
|
+
this.rows.set(key, { entry, at, component, version: 0 });
|
|
681
|
+
this.container.addChild(component);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
for (const [key, row] of this.rows)
|
|
685
|
+
if (!kept.has(key)) {
|
|
686
|
+
this.container.removeChild(row.component);
|
|
687
|
+
this.rows.delete(key);
|
|
688
|
+
}
|
|
689
|
+
this.order = entries.map(({ entry, at }) => entryKeyOf(entry, at));
|
|
690
|
+
// Preserve external children (startup banner) at the front, then managed rows in item order.
|
|
691
|
+
const managed = new Set(this.order.map((k) => this.rows.get(k)?.component).filter((c) => !!c));
|
|
692
|
+
this.container.children = [
|
|
693
|
+
...this.container.children.filter((c) => !managed.has(c)),
|
|
694
|
+
...this.order.map((k) => this.rows.get(k).component),
|
|
695
|
+
];
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Fold candidate at a transcript content row, when the click landed on the row's header
|
|
699
|
+
* (assistant entries start with a blank spacer line, so their first two rows count). Used by
|
|
700
|
+
* click-to-toggle; keyboard toggling uses `foldCandidates` instead and needs no heights.
|
|
701
|
+
*/
|
|
702
|
+
entryAt(contentY, width) {
|
|
703
|
+
let acc = 0;
|
|
704
|
+
for (const key of this.order) {
|
|
705
|
+
const row = this.rows.get(key);
|
|
706
|
+
if (!row)
|
|
707
|
+
continue;
|
|
708
|
+
const height = row.component.render(width).length;
|
|
709
|
+
if (contentY >= acc && contentY < acc + height) {
|
|
710
|
+
const candidate = foldCandidateOf(row.entry, row.at);
|
|
711
|
+
if (!candidate)
|
|
712
|
+
return undefined;
|
|
713
|
+
const headerRows = row.entry.kind === "assistant" ? 2 : 1;
|
|
714
|
+
return contentY < acc + headerRows ? candidate : undefined;
|
|
715
|
+
}
|
|
716
|
+
acc += height;
|
|
717
|
+
}
|
|
718
|
+
return undefined;
|
|
719
|
+
}
|
|
720
|
+
reset() {
|
|
721
|
+
this.rows.clear();
|
|
722
|
+
this.order = [];
|
|
723
|
+
this.container.clear();
|
|
288
724
|
}
|
|
289
725
|
}
|
|
290
726
|
/** Startup screen rendered by core for the current width (cached until the width changes). */
|
|
@@ -303,34 +739,6 @@ export class BannerBlock {
|
|
|
303
739
|
return fit(this.cache.lines, width);
|
|
304
740
|
}
|
|
305
741
|
}
|
|
306
|
-
/** Keeps a container of transcript components in step with view-model items. */
|
|
307
|
-
export class TranscriptSync {
|
|
308
|
-
container = new Container();
|
|
309
|
-
rendered = [];
|
|
310
|
-
sync(items) {
|
|
311
|
-
if (items.length < this.rendered.length)
|
|
312
|
-
this.reset();
|
|
313
|
-
items.forEach((item, index) => {
|
|
314
|
-
const entry = this.rendered[index];
|
|
315
|
-
if (!entry) {
|
|
316
|
-
const component = componentFor(item);
|
|
317
|
-
this.container.addChild(component);
|
|
318
|
-
this.rendered.push({ item, component });
|
|
319
|
-
}
|
|
320
|
-
else if (entry.item !== item) {
|
|
321
|
-
if (entry.component instanceof AssistantBlock && item.kind === "assistant")
|
|
322
|
-
entry.component.update(item);
|
|
323
|
-
else if (entry.component instanceof ToolBlock && item.kind === "tool")
|
|
324
|
-
entry.component.item = item;
|
|
325
|
-
entry.item = item;
|
|
326
|
-
}
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
reset() {
|
|
330
|
-
this.container.clear();
|
|
331
|
-
this.rendered = [];
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
742
|
/** Renders one of several components (main conversation or a read-only child view). */
|
|
335
743
|
export class Switch {
|
|
336
744
|
pick;
|