@alisio/alisio-code 0.1.0-alpha.14 → 0.1.0-alpha.16
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/main.js +21 -2
- package/dist/tui/agents.d.ts +66 -0
- package/dist/tui/agents.js +113 -0
- package/dist/tui/app.js +260 -15
- package/dist/tui/components.d.ts +77 -12
- package/dist/tui/components.js +490 -63
- package/dist/tui/state.d.ts +204 -1
- package/dist/tui/state.js +501 -12
- package/dist/tui/theme.d.ts +42 -1
- package/dist/tui/theme.js +516 -1
- package/package.json +6 -6
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, 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,
|
|
@@ -33,12 +211,14 @@ export class Header {
|
|
|
33
211
|
{ text: "◆ Alisio Code", priority: 8, paint: (t) => style.bold(style.brightCyan(t)) },
|
|
34
212
|
{ text: `v${i.version}`, priority: 1, paint: style.gray },
|
|
35
213
|
{
|
|
36
|
-
text: v.model || "not connected",
|
|
214
|
+
text: i.modelName || v.model || "not connected",
|
|
37
215
|
priority: 10,
|
|
38
|
-
paint: v.model ? style.bold : style.yellow,
|
|
216
|
+
paint: v.model ? (t) => style.bold(style.cyan(t)) : style.yellow,
|
|
39
217
|
},
|
|
40
|
-
|
|
41
|
-
{ text: i.
|
|
218
|
+
// `model · provider · effort` with each piece in a distinct color.
|
|
219
|
+
...(i.provider ? [{ text: i.provider, priority: 6, paint: style.magenta }] : []),
|
|
220
|
+
...(i.effort ? [{ text: i.effort, priority: 5, paint: style.yellow }] : []),
|
|
221
|
+
{ text: i.host, priority: 4, paint: style.gray },
|
|
42
222
|
{ text: i.apiMode, priority: 2, paint: style.gray },
|
|
43
223
|
], width);
|
|
44
224
|
const perms = i.readOnly
|
|
@@ -78,11 +258,13 @@ export class Footer {
|
|
|
78
258
|
budget;
|
|
79
259
|
hint;
|
|
80
260
|
statuses;
|
|
81
|
-
|
|
261
|
+
identity;
|
|
262
|
+
constructor(view, budget, hint, statuses = () => [], identity) {
|
|
82
263
|
this.view = view;
|
|
83
264
|
this.budget = budget;
|
|
84
265
|
this.hint = hint;
|
|
85
266
|
this.statuses = statuses;
|
|
267
|
+
this.identity = identity;
|
|
86
268
|
}
|
|
87
269
|
invalidate() { }
|
|
88
270
|
render(width) {
|
|
@@ -124,7 +306,22 @@ export class Footer {
|
|
|
124
306
|
(v.streaming || v.compacting
|
|
125
307
|
? "Esc interrupt · Ctrl+C clear/exit"
|
|
126
308
|
: "Enter send · Shift+Enter newline · /help commands · Ctrl+D exit");
|
|
127
|
-
|
|
309
|
+
// Status row below the editor: `agent: <name> · <model> · <provider> · <effort>`, each piece
|
|
310
|
+
// in a distinct color (model bold/cyan, provider magenta, effort yellow); the effort part is
|
|
311
|
+
// omitted when the active model advertises no supported levels. Truncation drops the lowest
|
|
312
|
+
// priority parts first (effort, then provider) before truncating what remains.
|
|
313
|
+
const identity = this.identity?.();
|
|
314
|
+
const identityLine = identity
|
|
315
|
+
? fitIdentityParts(identity, width)
|
|
316
|
+
.map((part) => ({
|
|
317
|
+
agent: (t) => style.bold(style.magenta(t)),
|
|
318
|
+
model: (t) => style.bold(style.cyan(t)),
|
|
319
|
+
provider: style.magenta,
|
|
320
|
+
effort: style.yellow,
|
|
321
|
+
})[part.role](part.text))
|
|
322
|
+
.join(style.gray(" · "))
|
|
323
|
+
: undefined;
|
|
324
|
+
return fit(identityLine ? [identityLine, line1, style.dim(hint)] : [line1, style.dim(hint)], width);
|
|
128
325
|
}
|
|
129
326
|
}
|
|
130
327
|
export class UserBlock {
|
|
@@ -145,49 +342,107 @@ export class UserBlock {
|
|
|
145
342
|
];
|
|
146
343
|
}
|
|
147
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
|
+
};
|
|
148
354
|
export class AssistantBlock {
|
|
149
|
-
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;
|
|
150
361
|
item;
|
|
362
|
+
cache;
|
|
151
363
|
constructor(item) {
|
|
152
364
|
this.item = item;
|
|
153
365
|
this.markdown.setText(item.text);
|
|
154
366
|
}
|
|
155
|
-
update(item) {
|
|
367
|
+
update(item, fold) {
|
|
156
368
|
if (item.text !== this.item.text)
|
|
157
369
|
this.markdown.setText(item.text);
|
|
158
370
|
this.item = item;
|
|
371
|
+
this.fold = fold;
|
|
159
372
|
}
|
|
160
373
|
invalidate() {
|
|
161
374
|
this.markdown.invalidate();
|
|
375
|
+
this.cache = undefined;
|
|
162
376
|
}
|
|
163
377
|
render(width) {
|
|
164
|
-
const lines = [""];
|
|
165
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 = [""];
|
|
166
387
|
if (reasoning) {
|
|
167
|
-
if (
|
|
168
|
-
const words = reasoning.split(/\s+/).length;
|
|
169
|
-
lines.push(style.dim(style.italic(`✻ reasoning (${words} words)`)));
|
|
170
|
-
}
|
|
171
|
-
else {
|
|
388
|
+
if (live) {
|
|
172
389
|
lines.push(style.dim(style.italic("✻ thinking…")));
|
|
173
390
|
lines.push(...wrap(reasoning, width - 2)
|
|
174
391
|
.slice(-4)
|
|
175
392
|
.map((l) => style.dim(` ${l}`)));
|
|
176
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
|
+
}
|
|
177
406
|
}
|
|
178
|
-
if (this.item.text)
|
|
407
|
+
if (this.item.text) {
|
|
179
408
|
lines.push(...this.markdown.render(width));
|
|
180
|
-
|
|
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;
|
|
181
417
|
}
|
|
182
418
|
}
|
|
183
419
|
export class ToolBlock {
|
|
184
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;
|
|
185
426
|
constructor(item) {
|
|
186
427
|
this.item = item;
|
|
187
428
|
}
|
|
188
|
-
|
|
429
|
+
update(item, fold) {
|
|
430
|
+
this.item = item;
|
|
431
|
+
this.fold = fold;
|
|
432
|
+
}
|
|
433
|
+
invalidate() {
|
|
434
|
+
this.cache = undefined;
|
|
435
|
+
}
|
|
189
436
|
render(width) {
|
|
190
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;
|
|
191
446
|
const icon = i.status === "running"
|
|
192
447
|
? style.cyan(SPINNER[clock.frame % SPINNER.length] ?? "…")
|
|
193
448
|
: i.status === "approval"
|
|
@@ -195,12 +450,13 @@ export class ToolBlock {
|
|
|
195
450
|
: i.status === "ok"
|
|
196
451
|
? style.green("✓")
|
|
197
452
|
: style.red("✗");
|
|
453
|
+
const marker = this.fold ? `${style.dim(this.fold.expanded ? "−" : "+")} ` : "";
|
|
198
454
|
const right = i.status === "approval"
|
|
199
455
|
? style.yellow("awaiting approval")
|
|
200
456
|
: i.durationMs !== undefined
|
|
201
457
|
? style.gray(formatDuration(i.durationMs))
|
|
202
458
|
: "";
|
|
203
|
-
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)}`;
|
|
204
460
|
const gap = width - visibleWidth(head) - visibleWidth(right) - 1;
|
|
205
461
|
const lines = [
|
|
206
462
|
gap > 0 ? `${head}${" ".repeat(gap + 1)}${right}` : truncateToWidth(head, width),
|
|
@@ -214,17 +470,93 @@ export class ToolBlock {
|
|
|
214
470
|
if (diff.lines.length > max)
|
|
215
471
|
lines.push(style.gray(` … ${diff.lines.length - max} more lines`));
|
|
216
472
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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);
|
|
220
488
|
const paint = i.status === "error" ? style.red : style.gray;
|
|
221
489
|
shown.forEach((l, n) => {
|
|
222
490
|
lines.push(` ${style.gray(n === 0 ? "⎿" : " ")} ${paint(l.replace(/\t/g, " "))}`);
|
|
223
491
|
});
|
|
224
|
-
if (
|
|
225
|
-
lines.push(style.gray(` … ${
|
|
492
|
+
if (!expanded && rows.length > shown.length)
|
|
493
|
+
lines.push(style.gray(` … ${rows.length - shown.length} more lines`));
|
|
226
494
|
}
|
|
227
|
-
|
|
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;
|
|
228
560
|
}
|
|
229
561
|
}
|
|
230
562
|
export class LineBlock {
|
|
@@ -244,7 +576,9 @@ export class LineBlock {
|
|
|
244
576
|
export class InfoBlock {
|
|
245
577
|
markdown;
|
|
246
578
|
constructor(text) {
|
|
247
|
-
this.markdown = new Markdown(text, 1, 0, markdownTheme
|
|
579
|
+
this.markdown = new Markdown(text, 1, 0, markdownTheme, markdownDefaultTextStyle, {
|
|
580
|
+
transform: markdownTransform,
|
|
581
|
+
});
|
|
248
582
|
}
|
|
249
583
|
invalidate() {
|
|
250
584
|
this.markdown.invalidate();
|
|
@@ -254,18 +588,139 @@ export class InfoBlock {
|
|
|
254
588
|
return fit(["", ...inner.map((l) => `${style.gray("│")} ${l}`)], width);
|
|
255
589
|
}
|
|
256
590
|
}
|
|
257
|
-
export function componentFor(
|
|
258
|
-
|
|
591
|
+
export function componentFor(entry) {
|
|
592
|
+
if (entry.kind === "group")
|
|
593
|
+
return new ToolGroupBlock(entry.group);
|
|
594
|
+
switch (entry.kind) {
|
|
259
595
|
case "user":
|
|
260
|
-
return new UserBlock(
|
|
596
|
+
return new UserBlock(entry.text);
|
|
261
597
|
case "assistant":
|
|
262
|
-
return new AssistantBlock(
|
|
598
|
+
return new AssistantBlock(entry);
|
|
263
599
|
case "tool":
|
|
264
|
-
return new ToolBlock(
|
|
600
|
+
return new ToolBlock(entry);
|
|
265
601
|
case "info":
|
|
266
|
-
return new InfoBlock(
|
|
602
|
+
return new InfoBlock(entry.text);
|
|
267
603
|
default:
|
|
268
|
-
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();
|
|
269
724
|
}
|
|
270
725
|
}
|
|
271
726
|
/** Startup screen rendered by core for the current width (cached until the width changes). */
|
|
@@ -284,34 +739,6 @@ export class BannerBlock {
|
|
|
284
739
|
return fit(this.cache.lines, width);
|
|
285
740
|
}
|
|
286
741
|
}
|
|
287
|
-
/** Keeps a container of transcript components in step with view-model items. */
|
|
288
|
-
export class TranscriptSync {
|
|
289
|
-
container = new Container();
|
|
290
|
-
rendered = [];
|
|
291
|
-
sync(items) {
|
|
292
|
-
if (items.length < this.rendered.length)
|
|
293
|
-
this.reset();
|
|
294
|
-
items.forEach((item, index) => {
|
|
295
|
-
const entry = this.rendered[index];
|
|
296
|
-
if (!entry) {
|
|
297
|
-
const component = componentFor(item);
|
|
298
|
-
this.container.addChild(component);
|
|
299
|
-
this.rendered.push({ item, component });
|
|
300
|
-
}
|
|
301
|
-
else if (entry.item !== item) {
|
|
302
|
-
if (entry.component instanceof AssistantBlock && item.kind === "assistant")
|
|
303
|
-
entry.component.update(item);
|
|
304
|
-
else if (entry.component instanceof ToolBlock && item.kind === "tool")
|
|
305
|
-
entry.component.item = item;
|
|
306
|
-
entry.item = item;
|
|
307
|
-
}
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
reset() {
|
|
311
|
-
this.container.clear();
|
|
312
|
-
this.rendered = [];
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
742
|
/** Renders one of several components (main conversation or a read-only child view). */
|
|
316
743
|
export class Switch {
|
|
317
744
|
pick;
|