@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.
@@ -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
- ...(i.provider ? [{ text: i.provider, priority: 6, paint: style.cyan }] : []),
41
- { text: i.host, priority: 5, paint: style.gray },
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
- constructor(view, budget, hint, statuses = () => []) {
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
- return fit([line1, style.dim(hint)], width);
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 (this.item.done || this.item.text) {
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
- return fit(lines, width);
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
- invalidate() { }
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
- if (i.preview && (i.status === "error" || !diff)) {
218
- const preview = i.preview.split("\n").filter((l) => l.trim());
219
- const shown = preview.slice(0, i.status === "error" ? 6 : 3);
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 (preview.length > shown.length)
225
- lines.push(style.gray(` … ${preview.length - shown.length} more lines`));
492
+ if (!expanded && rows.length > shown.length)
493
+ lines.push(style.gray(` … ${rows.length - shown.length} more lines`));
226
494
  }
227
- return fit(lines, width);
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(item) {
258
- switch (item.kind) {
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(item.text);
596
+ return new UserBlock(entry.text);
261
597
  case "assistant":
262
- return new AssistantBlock(item);
598
+ return new AssistantBlock(entry);
263
599
  case "tool":
264
- return new ToolBlock(item);
600
+ return new ToolBlock(entry);
265
601
  case "info":
266
- return new InfoBlock(item.text);
602
+ return new InfoBlock(entry.text);
267
603
  default:
268
- return new LineBlock(item.text, item.kind);
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;