@alisio/alisio-code 0.1.0-alpha.15 → 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, 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 (this.item.done || this.item.text) {
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
- 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;
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
- invalidate() { }
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
- if (i.preview && (i.status === "error" || !diff)) {
237
- const preview = i.preview.split("\n").filter((l) => l.trim());
238
- 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);
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 (preview.length > shown.length)
244
- 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`));
245
494
  }
246
- 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;
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(item) {
277
- switch (item.kind) {
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(item.text);
596
+ return new UserBlock(entry.text);
280
597
  case "assistant":
281
- return new AssistantBlock(item);
598
+ return new AssistantBlock(entry);
282
599
  case "tool":
283
- return new ToolBlock(item);
600
+ return new ToolBlock(entry);
284
601
  case "info":
285
- return new InfoBlock(item.text);
602
+ return new InfoBlock(entry.text);
286
603
  default:
287
- 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();
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;