@alisio/alisio-code 0.1.0-alpha.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +95 -16
  2. package/dist/analysis.d.ts +27 -0
  3. package/dist/analysis.js +145 -0
  4. package/dist/banner.js +2 -1
  5. package/dist/builtin.d.ts +3 -1
  6. package/dist/builtin.js +0 -24
  7. package/dist/main.js +140 -7
  8. package/dist/serve.d.ts +15 -0
  9. package/dist/serve.js +99 -0
  10. package/dist/tui/agent-manager.d.ts +105 -0
  11. package/dist/tui/agent-manager.js +269 -0
  12. package/dist/tui/agents.d.ts +5 -0
  13. package/dist/tui/agents.js +5 -0
  14. package/dist/tui/app.d.ts +42 -1
  15. package/dist/tui/app.js +1895 -97
  16. package/dist/tui/artifact-preview.d.ts +52 -0
  17. package/dist/tui/artifact-preview.js +212 -0
  18. package/dist/tui/artifacts.d.ts +54 -0
  19. package/dist/tui/artifacts.js +207 -0
  20. package/dist/tui/btw.d.ts +57 -0
  21. package/dist/tui/btw.js +76 -0
  22. package/dist/tui/components.d.ts +174 -12
  23. package/dist/tui/components.js +962 -66
  24. package/dist/tui/connect-input.d.ts +2 -0
  25. package/dist/tui/connect-input.js +1 -1
  26. package/dist/tui/exit.d.ts +10 -0
  27. package/dist/tui/exit.js +15 -0
  28. package/dist/tui/git-branch.d.ts +48 -0
  29. package/dist/tui/git-branch.js +99 -0
  30. package/dist/tui/goal.d.ts +75 -0
  31. package/dist/tui/goal.js +171 -0
  32. package/dist/tui/modes.d.ts +71 -0
  33. package/dist/tui/modes.js +90 -0
  34. package/dist/tui/open-path.d.ts +38 -0
  35. package/dist/tui/open-path.js +80 -0
  36. package/dist/tui/plan-review.d.ts +48 -0
  37. package/dist/tui/plan-review.js +76 -0
  38. package/dist/tui/queue.d.ts +2 -0
  39. package/dist/tui/queue.js +5 -1
  40. package/dist/tui/scroll.d.ts +8 -0
  41. package/dist/tui/scroll.js +12 -0
  42. package/dist/tui/settings-menu.d.ts +40 -0
  43. package/dist/tui/settings-menu.js +132 -0
  44. package/dist/tui/settings.d.ts +194 -0
  45. package/dist/tui/settings.js +482 -0
  46. package/dist/tui/state.d.ts +343 -7
  47. package/dist/tui/state.js +823 -54
  48. package/dist/tui/tasks.d.ts +123 -0
  49. package/dist/tui/tasks.js +369 -0
  50. package/dist/tui/theme.d.ts +42 -1
  51. package/dist/tui/theme.js +516 -1
  52. package/dist/tui/viewer-state.d.ts +7 -0
  53. package/dist/tui/viewer-state.js +36 -0
  54. package/dist/version.d.ts +7 -5
  55. package/dist/version.js +44 -8
  56. package/package.json +7 -9
@@ -1,13 +1,357 @@
1
- import { Container, getCapabilities, Image, Key, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
1
+ import { homedir } from "node:os";
2
+ import { Container, getCapabilities, getImageDimensions, Image, Key, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
3
+ import { terminalCapabilities } from "../banner.js";
4
+ import { artifactLines } from "./artifacts.js";
2
5
  import { attachmentCaption, MAX_ATTACHMENTS_PER_MESSAGE, } from "./attachments.js";
6
+ import { btwCurrent, btwHints, btwPosition, btwUsageLine, btwWindow, reduceBtw, } from "./btw.js";
7
+ import { branchDisplay } from "./git-branch.js";
8
+ import { initialPlanReviewState, reducePlanReview, } from "./plan-review.js";
3
9
  import { initialQuestionState, reduceQuestions, } from "./questions.js";
4
- import { contextLevel, contextPercent, editSummary, fitSegments, formatContext, formatDuration, formatTokens, } from "./state.js";
5
- import { imageTheme, levelColor, markdownTheme, style } from "./theme.js";
10
+ import { scrollWindow } from "./scroll.js";
11
+ import { contextLevel, contextPercent, editSummary, entryKeyOf, fitIdentityParts, fitSegments, foldCandidateOf, formatContext, formatDuration, formatTokens, groupIdentity, groupToolEntries, humanizeToolName, isUiBlock, previewLinesFor, previewRows, quietFor, REASONING_EXPAND_MAX_LINES, reasoningDurationMs, runPhase, STALLED_MS, } from "./state.js";
12
+ import { highlightCode, imageTheme, levelColor, markdownDefaultTextStyle, markdownTheme, markdownTransform, sanitizeCode, style, } from "./theme.js";
6
13
  export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
14
  /** Shared animation clock advanced by the app while work is running. */
8
15
  export const clock = { frame: 0, now: Date.now() };
9
16
  const fit = (lines, width) => lines.map((l) => truncateToWidth(l, width));
10
17
  const wrap = (text, width) => text.split("\n").flatMap((line) => (line ? wrapTextWithAnsi(line, Math.max(1, width)) : [""]));
18
+ /** ANSI-aware right padding to a visible width. */
19
+ const padTo = (text, width) => `${text}${" ".repeat(Math.max(0, width - visibleWidth(text)))}`;
20
+ const wrapCell = (text, width) => text.split("\n").flatMap((line) => (line ? wrapTextWithAnsi(line, Math.max(1, width)) : [""]));
21
+ /**
22
+ * Rendered body of a tool call's rich parts (inline image + native ui block), reusing the exact
23
+ * singleton rendering so grouped detail rows and plain rows agree. Returns [] when there is none.
24
+ */
25
+ function richBodyLines(view, width, markdownCache) {
26
+ const lines = [];
27
+ if (view.image) {
28
+ const inline = getCapabilities().images !== null && process.env.NO_COLOR === undefined;
29
+ if (inline) {
30
+ const image = new Image(view.image.data, view.image.mimeType, imageTheme, {
31
+ maxWidthCells: Math.max(10, Math.min(60, width - 4)),
32
+ });
33
+ lines.push(...image.render(Math.max(1, width - 2)));
34
+ }
35
+ else {
36
+ const dims = getImageDimensions(view.image.data, view.image.mimeType);
37
+ lines.push(style.dim(`[image: ${view.image.mimeType}${dims ? ` ${dims.widthPx}x${dims.heightPx}` : ""}]`));
38
+ }
39
+ }
40
+ if (view.ui) {
41
+ const inner = Math.max(1, width - 2);
42
+ if (view.ui.kind === "markdown") {
43
+ // Cache the Markdown instance so streaming/resizes never re-parse every frame.
44
+ if (markdownCache?.text !== view.ui.text)
45
+ markdownCache = {
46
+ text: view.ui.text,
47
+ renderer: new Markdown(view.ui.text, 0, 0, markdownTheme, markdownDefaultTextStyle, {
48
+ transform: markdownTransform,
49
+ }),
50
+ };
51
+ lines.push(...markdownCache.renderer.render(inner));
52
+ }
53
+ else {
54
+ lines.push(...renderUiBlock(view.ui, width, unicodeAt(width)));
55
+ }
56
+ }
57
+ return { lines, markdownCache: markdownCache ?? undefined };
58
+ }
59
+ /** Terminal Unicode support, mirroring the copy hint detection in AssistantBlock. */
60
+ const unicodeAt = (width) => terminalCapabilities({ env: process.env, columns: width, tty: true }).unicode;
61
+ /** Capped preview rows of one member call inside an expanded group (`… N more lines` footer). */
62
+ function memberPreviewLines(member, indent) {
63
+ const rows = previewRows(member.preview ?? "");
64
+ if (!rows.length)
65
+ return [];
66
+ const cap = previewLinesFor(member.status);
67
+ const shown = rows.slice(0, cap);
68
+ const paint = member.status === "error" ? style.red : style.gray;
69
+ const lines = shown.map((line, n) => `${indent} ${style.gray(n === 0 ? "⎿" : " ")} ${paint(line.replace(/\t/g, " "))}`);
70
+ if (rows.length > shown.length)
71
+ lines.push(style.gray(`${indent} … ${rows.length - shown.length} more lines`));
72
+ return lines;
73
+ }
74
+ /** `Command exited with code 0.` (green) / `Command exited with code N.` (red) when known. */
75
+ export const exitCodeLine = (code, indent = " ") => code === undefined
76
+ ? undefined
77
+ : `${indent}${code === 0 ? style.green("Command exited with code 0.") : style.red(`Command exited with code ${code}.`)}`;
78
+ /**
79
+ * Aligned-column table rendering: cells wrap to their column width, the header is bold, a dim
80
+ * separator row sits under it, and the whole block respects the given inner width. Long values
81
+ * wrap instead of being cut; the final `truncateToWidth` pass only bites on very narrow
82
+ * terminals.
83
+ */
84
+ function renderTableBlock(block, width) {
85
+ const caption = block.caption ? [style.dim(block.caption)] : [];
86
+ if (!block.columns.length)
87
+ return [...caption, style.gray("(empty table)")];
88
+ const inner = Math.max(1, width - 2);
89
+ const n = block.columns.length;
90
+ const natural = block.columns.map((header, i) => Math.max(1, visibleWidth(header), ...block.rows.map((row) => visibleWidth(row[i] ?? ""))));
91
+ const separatorCost = (n - 1) * 3;
92
+ const totalNatural = natural.reduce((a, b) => a + b, 0);
93
+ let widths;
94
+ if (totalNatural + separatorCost <= inner) {
95
+ widths = natural;
96
+ }
97
+ else {
98
+ const budget = Math.max(n, inner - separatorCost);
99
+ const scale = Math.min(1, budget / totalNatural);
100
+ widths = natural.map((w) => Math.max(1, Math.floor(w * scale)));
101
+ let left = budget - widths.reduce((a, b) => a + b, 0);
102
+ // Hand the leftover to the widest columns that still want space (round-robin, deterministic).
103
+ for (let round = 0; round < n && left > 0; round++) {
104
+ const widest = natural.findIndex((w, i) => (widths[i] ?? 0) < w);
105
+ if (widest < 0)
106
+ break;
107
+ widths[widest] = (widths[widest] ?? 0) + 1;
108
+ left--;
109
+ }
110
+ }
111
+ const cols = block.columns.map((header, i) => ({
112
+ header: wrapCell(header, widths[i] ?? 1),
113
+ body: block.rows.map((row) => wrapCell(row[i] ?? "", widths[i] ?? 1)),
114
+ }));
115
+ const headerHeight = Math.max(...cols.map((c) => c.header.length));
116
+ const lines = [];
117
+ for (let li = 0; li < headerHeight; li++)
118
+ lines.push(cols.map((c, i) => style.bold(padTo(c.header[li] ?? "", widths[i] ?? 1))).join(" | "));
119
+ lines.push(style.gray(cols.map((c, i) => "─".repeat(widths[i] ?? 1)).join(" │ ")));
120
+ for (const [ri] of block.rows.entries()) {
121
+ const wrapped = cols.map((c, i) => c.body[ri] ?? [""]);
122
+ const height = Math.max(...wrapped.map((w) => w.length));
123
+ for (let li = 0; li < height; li++)
124
+ lines.push(cols.map((c, i) => padTo(wrapped[i]?.[li] ?? "", widths[i] ?? 1)).join(" | "));
125
+ }
126
+ return [...caption, ...lines];
127
+ }
128
+ /** Two-column key-value rendering: bright-cyan keys padded left, values wrapped on the right. */
129
+ function renderKeyValueBlock(block, width) {
130
+ const caption = block.caption ? [style.dim(block.caption)] : [];
131
+ if (!block.entries.length)
132
+ return [...caption, style.gray("(empty)")];
133
+ const inner = Math.max(1, width - 2);
134
+ const keyWidth = Math.min(24, Math.max(...block.entries.map(([k]) => visibleWidth(k)), 1));
135
+ const valueWidth = Math.max(1, inner - keyWidth - 2);
136
+ const lines = [];
137
+ for (const [key, value] of block.entries) {
138
+ wrapCell(value, valueWidth).forEach((line, i) => {
139
+ const keyPart = i === 0 ? style.brightCyan(padTo(key, keyWidth)) : " ".repeat(keyWidth);
140
+ lines.push(`${keyPart} ${line}`);
141
+ });
142
+ }
143
+ return [...caption, ...lines];
144
+ }
145
+ /**
146
+ * Tree rendering with branch glyphs: `├─`/`└─`/`│` when the terminal supports Unicode, ASCII
147
+ * `|-`/`` `- ``/`|` otherwise.
148
+ */
149
+ export function renderTreeLines(nodes, unicode) {
150
+ const branch = unicode ? "├─ " : "|- ";
151
+ const last = unicode ? "└─ " : "`- ";
152
+ const vertical = unicode ? "│ " : "| ";
153
+ const gap = " ";
154
+ const lines = [];
155
+ const walk = (items, prefix) => {
156
+ items.forEach((node, index) => {
157
+ const isLast = index === items.length - 1;
158
+ const meta = node.meta ? ` ${style.dim(`(${node.meta})`)}` : "";
159
+ lines.push(`${prefix}${isLast ? last : branch}${node.label}${meta}`);
160
+ if (node.children?.length)
161
+ walk(node.children, `${prefix}${isLast ? gap : vertical}`);
162
+ });
163
+ };
164
+ walk(nodes, "");
165
+ return lines;
166
+ }
167
+ /** Mini code block: dim fenced header, highlighted body (the same highlighter as markdown), dim close. */
168
+ function renderCodeBlock(block, _width) {
169
+ const caption = block.caption ? [style.dim(block.caption)] : [];
170
+ return [
171
+ ...caption,
172
+ style.dim(block.lang ? `\`\`\`${block.lang}` : "```"),
173
+ ...highlightCode(block.code, block.lang),
174
+ style.dim("```"),
175
+ ];
176
+ }
177
+ /** Longest JSON/unknown-block text shown inline before a `… truncated` marker. */
178
+ const MAX_BLOCK_LINES = 200;
179
+ const MAX_BLOCK_CHARS = 20_000;
180
+ /** Caps free-form text by lines and characters, appending a dim marker when anything is cut. */
181
+ function capText(text) {
182
+ let out = text.length > MAX_BLOCK_CHARS ? text.slice(0, MAX_BLOCK_CHARS) : text;
183
+ const lines = out.split("\n");
184
+ if (lines.length > MAX_BLOCK_LINES)
185
+ out = lines.slice(0, MAX_BLOCK_LINES).join("\n");
186
+ return { text: out, truncated: out.length < text.length };
187
+ }
188
+ /** `JSON.stringify` that never throws and never returns `undefined`. */
189
+ function jsonText(value) {
190
+ try {
191
+ return JSON.stringify(value, null, 2) ?? String(value);
192
+ }
193
+ catch {
194
+ return String(value);
195
+ }
196
+ }
197
+ function renderCappedCode(code, lang, caption) {
198
+ const capped = capText(code);
199
+ return [
200
+ ...renderCodeBlock({
201
+ kind: "code",
202
+ code: capped.text,
203
+ ...(lang ? { lang } : {}),
204
+ ...(caption ? { caption } : {}),
205
+ }, 0),
206
+ ...(capped.truncated ? [style.gray("… truncated")] : []),
207
+ ];
208
+ }
209
+ function renderDiffBlock(block) {
210
+ const caption = [block.caption, block.path].filter(Boolean).join(" · ") || undefined;
211
+ if (block.patch !== undefined)
212
+ return renderCappedCode(block.patch, "diff", caption);
213
+ if (block.before === undefined && block.after === undefined)
214
+ return [...(caption ? [style.dim(caption)] : []), style.gray("(empty diff)")];
215
+ return [
216
+ ...(caption ? [style.dim(caption)] : []),
217
+ ...renderCappedCode(block.before ?? "", block.lang, "before"),
218
+ ...renderCappedCode(block.after ?? "", block.lang, "after"),
219
+ ];
220
+ }
221
+ function renderTerminalBlock(block) {
222
+ const head = block.command
223
+ ? [style.dim(`$ ${block.command}${block.cwd ? ` (${block.cwd})` : ""}`)]
224
+ : [];
225
+ const status = [
226
+ block.exitCode !== undefined ? `exit ${block.exitCode}` : "",
227
+ block.durationMs !== undefined ? formatDuration(block.durationMs) : "",
228
+ ]
229
+ .filter(Boolean)
230
+ .join(" · ");
231
+ const paint = block.exitCode !== undefined && block.exitCode !== 0 ? style.red : style.gray;
232
+ return [
233
+ ...head,
234
+ ...renderCappedCode(sanitizeCode(block.output), undefined),
235
+ ...(block.truncated ? [style.gray("… output truncated")] : []),
236
+ ...(status ? [paint(status)] : []),
237
+ ];
238
+ }
239
+ function renderTestResultsBlock(block, width, unicode) {
240
+ const cases = block.suites.flatMap((suite) => suite.cases.map((c) => ({ suite, c })));
241
+ const counts = new Map();
242
+ for (const { c } of cases)
243
+ counts.set(c.status, (counts.get(c.status) ?? 0) + 1);
244
+ const summary = [
245
+ block.framework,
246
+ ["passed", "failed", "skipped", "todo"]
247
+ .filter((status) => counts.get(status))
248
+ .map((status) => `${counts.get(status)} ${status}`)
249
+ .join(", ") || "no tests",
250
+ block.durationMs !== undefined ? formatDuration(block.durationMs) : undefined,
251
+ ]
252
+ .filter(Boolean)
253
+ .join(" · ");
254
+ const table = renderTableBlock({
255
+ kind: "table",
256
+ columns: ["suite", "case", "status", "time"],
257
+ rows: cases.map(({ suite, c }) => [
258
+ suite.name,
259
+ c.name,
260
+ c.status,
261
+ c.durationMs !== undefined ? formatDuration(c.durationMs) : "",
262
+ ]),
263
+ caption: summary,
264
+ }, width);
265
+ const errors = cases
266
+ .filter(({ c }) => c.status === "failed" && c.error)
267
+ .map(({ suite, c }) => style.red(`${unicode ? "✗" : "x"} ${suite.name} ${unicode ? "›" : ">"} ${c.name}${suite.file ? ` (${suite.file}${c.line !== undefined ? `:${c.line}` : ""})` : ""}: ${c.error}`));
268
+ return [...table, ...errors];
269
+ }
270
+ const PROGRESS_GLYPHS = {
271
+ unicode: { completed: "✓", running: "●", pending: "○", failed: "✗", cancelled: "⊘" },
272
+ ascii: { completed: "[x]", running: "[>]", pending: "[ ]", failed: "[!]", cancelled: "[-]" },
273
+ };
274
+ function renderProgressBlock(block, unicode) {
275
+ const glyphs = PROGRESS_GLYPHS[unicode ? "unicode" : "ascii"];
276
+ const paint = {
277
+ completed: style.green,
278
+ running: style.brightCyan,
279
+ pending: style.gray,
280
+ failed: style.red,
281
+ cancelled: style.gray,
282
+ };
283
+ return [
284
+ ...(block.title ? [style.bold(block.title)] : []),
285
+ ...(block.steps.length ? [] : [style.gray("(no steps)")]),
286
+ ...block.steps.map((step) => `${paint[step.status](glyphs[step.status])} ${step.label}${step.detail ? ` ${style.dim(step.detail)}` : ""}`),
287
+ ];
288
+ }
289
+ /** Text fallback for a kind this build does not know (or a malformed block): never throws. */
290
+ function renderUnknownBlock(block) {
291
+ const kind = block && typeof block === "object" && typeof block.kind === "string"
292
+ ? block.kind
293
+ : "unknown";
294
+ return [style.gray(`[ui block: ${kind}]`), ...renderCappedCode(jsonText(block), "json")];
295
+ }
296
+ /** Local path of a published artifact (set by the TUI from the artifact store). */
297
+ let artifactPath;
298
+ export function setArtifactPathResolver(resolve) {
299
+ artifactPath = resolve;
300
+ }
301
+ /** The two-line announcement of an `artifact` block (kind, name, size and local path). */
302
+ export function renderArtifactBlock(block, width, unicode, path) {
303
+ return artifactLines(block.artifact, {
304
+ width,
305
+ unicode,
306
+ home: homedir(),
307
+ ...((path ?? artifactPath?.(block.artifact.id))
308
+ ? { path: path ?? artifactPath?.(block.artifact.id) }
309
+ : {}),
310
+ });
311
+ }
312
+ /**
313
+ * Renders a `{type:"ui"}` block to styled lines for the given inner width. Blocks come from
314
+ * persisted transcripts too, so anything that does not validate (a kind added by a newer Alisio,
315
+ * or a malformed plugin block) falls back to labeled JSON instead of throwing.
316
+ */
317
+ export function renderUiBlock(block, width, unicode) {
318
+ if (!isUiBlock(block))
319
+ return renderUnknownBlock(block);
320
+ switch (block.kind) {
321
+ case "table":
322
+ return renderTableBlock(block, width);
323
+ case "key-value":
324
+ return renderKeyValueBlock(block, width);
325
+ case "tree":
326
+ return renderTreeLines(block.nodes, unicode);
327
+ case "code":
328
+ return renderCodeBlock(block, width);
329
+ case "markdown": {
330
+ const md = new Markdown(block.text, 0, 0, markdownTheme, markdownDefaultTextStyle, {
331
+ transform: markdownTransform,
332
+ });
333
+ return md.render(Math.max(1, width - 2));
334
+ }
335
+ case "diff":
336
+ return renderDiffBlock(block);
337
+ case "terminal":
338
+ return renderTerminalBlock(block);
339
+ case "mermaid":
340
+ return renderCappedCode(block.source, "mermaid", block.title);
341
+ case "math":
342
+ return block.latex.split("\n");
343
+ case "json":
344
+ return renderCappedCode(jsonText(block.value), "json", block.caption);
345
+ case "test-results":
346
+ return renderTestResultsBlock(block, width, unicode);
347
+ case "progress":
348
+ return renderProgressBlock(block, unicode);
349
+ case "artifact":
350
+ return renderArtifactBlock(block, width, unicode);
351
+ default:
352
+ return renderUnknownBlock(block);
353
+ }
354
+ }
11
355
  const permission = (label, state, priority) => ({
12
356
  text: `${label}:${state}`,
13
357
  priority,
@@ -28,20 +372,31 @@ export class Header {
28
372
  render(width) {
29
373
  const i = this.info(), v = this.view();
30
374
  const line1 = line([
31
- { text: "◆ alisio", priority: 8, paint: (t) => style.bold(style.brightCyan(t)) },
375
+ { text: "◆ Alisio Code", priority: 8, paint: (t) => style.bold(style.brightCyan(t)) },
32
376
  { text: `v${i.version}`, priority: 1, paint: style.gray },
33
377
  {
34
- text: v.model || "not connected",
378
+ text: i.modelName || v.model || "not connected",
35
379
  priority: 10,
36
- paint: v.model ? style.bold : style.yellow,
380
+ paint: v.model ? (t) => style.bold(style.cyan(t)) : style.yellow,
37
381
  },
38
- ...(i.provider ? [{ text: i.provider, priority: 6, paint: style.cyan }] : []),
39
- { text: i.host, priority: 5, paint: style.gray },
382
+ // `model · provider · effort` with each piece in a distinct color.
383
+ ...(i.provider ? [{ text: i.provider, priority: 6, paint: style.magenta }] : []),
384
+ ...(i.effort ? [{ text: i.effort, priority: 5, paint: style.yellow }] : []),
385
+ { text: i.host, priority: 4, paint: style.gray },
40
386
  { text: i.apiMode, priority: 2, paint: style.gray },
41
387
  ], width);
42
388
  const perms = i.readOnly
43
389
  ? [{ text: "read-only", priority: 10, paint: style.red }]
44
390
  : [
391
+ ...(i.mode
392
+ ? [
393
+ {
394
+ text: `mode:${i.mode}`,
395
+ priority: 10,
396
+ paint: i.mode === "full" || i.mode === "custom" ? style.red : style.green,
397
+ },
398
+ ]
399
+ : []),
45
400
  permission("write", i.write, 10),
46
401
  permission("process", i.process, 10),
47
402
  {
@@ -53,6 +408,19 @@ export class Header {
53
408
  const line2 = line([
54
409
  { text: i.cwd, priority: 6, paint: style.cyan },
55
410
  { text: `session ${i.session}`, priority: 4, paint: style.gray },
411
+ // Priority 3 (below session's 4): the branch is ambient context, so on narrow terminals it
412
+ // drops first — before the session ID and permissions. At equal priority fitSegments drops
413
+ // the FIRST lowest-priority segment, which would mis-drop the session when branch sits
414
+ // after it, so the branch keeps a strictly lower priority instead.
415
+ ...(i.branch
416
+ ? [
417
+ {
418
+ text: branchDisplay(i.branch, terminalCapabilities({ env: process.env, columns: width, tty: true }).unicode),
419
+ priority: 3,
420
+ paint: style.cyan,
421
+ },
422
+ ]
423
+ : []),
56
424
  ...perms,
57
425
  ], width);
58
426
  return fit([line1, line2, style.gray("─".repeat(Math.max(0, width)))], width);
@@ -63,11 +431,13 @@ export class Footer {
63
431
  budget;
64
432
  hint;
65
433
  statuses;
66
- constructor(view, budget, hint, statuses = () => []) {
434
+ identity;
435
+ constructor(view, budget, hint, statuses = () => [], identity) {
67
436
  this.view = view;
68
437
  this.budget = budget;
69
438
  this.hint = hint;
70
439
  this.statuses = statuses;
440
+ this.identity = identity;
71
441
  }
72
442
  invalidate() { }
73
443
  render(width) {
@@ -78,10 +448,14 @@ export class Footer {
78
448
  const bar = pct === undefined
79
449
  ? style.gray("░".repeat(cells))
80
450
  : levelColor(level)("█".repeat(filled)) + style.gray("░".repeat(cells - filled));
451
+ const quiet = v.streaming ? quietFor(v, clock.now) : 0;
452
+ const quietNote = quiet
453
+ ? ` · no response for ${formatDuration(quiet)}${quiet >= STALLED_MS ? " (stuck?)" : ""}`
454
+ : "";
81
455
  const state = v.compacting
82
456
  ? `${SPINNER[clock.frame % SPINNER.length]} compacting`
83
457
  : v.streaming
84
- ? `${SPINNER[clock.frame % SPINNER.length]} streaming ${formatDuration(clock.now - (v.runStartedAt ?? clock.now))}`
458
+ ? `${SPINNER[clock.frame % SPINNER.length]} ${runPhase(v).label} ${formatDuration(clock.now - (v.runStartedAt ?? clock.now))}${quietNote}`
85
459
  : "● idle";
86
460
  const s = v.stats;
87
461
  const segments = [
@@ -90,7 +464,15 @@ export class Footer {
90
464
  priority: 10,
91
465
  paint: pct === undefined ? style.gray : levelColor(level),
92
466
  },
93
- { text: state, priority: 9, paint: v.streaming || v.compacting ? style.cyan : style.green },
467
+ {
468
+ text: state,
469
+ priority: 9,
470
+ paint: quiet >= STALLED_MS
471
+ ? style.yellow
472
+ : v.streaming || v.compacting
473
+ ? style.cyan
474
+ : style.green,
475
+ },
94
476
  {
95
477
  text: `↑${formatTokens(s.input)} ↓${formatTokens(s.output)}${s.cached ? ` ⚡${formatTokens(s.cached)}` : ""}`,
96
478
  priority: 6,
@@ -109,7 +491,23 @@ export class Footer {
109
491
  (v.streaming || v.compacting
110
492
  ? "Esc interrupt · Ctrl+C clear/exit"
111
493
  : "Enter send · Shift+Enter newline · /help commands · Ctrl+D exit");
112
- return fit([line1, style.dim(hint)], width);
494
+ // Status row below the editor: `agent: <name> · <model> · <provider> · <effort>`, each piece
495
+ // in a distinct color (model bold/cyan, provider magenta, effort yellow); the effort part is
496
+ // omitted when the active model advertises no supported levels. Truncation drops the lowest
497
+ // priority parts first (effort, then provider) before truncating what remains.
498
+ const identity = this.identity?.();
499
+ const identityLine = identity
500
+ ? fitIdentityParts(identity, width)
501
+ .map((part) => ({
502
+ agent: (t) => style.bold(style.magenta(t)),
503
+ mode: (t) => (/full|custom/.test(t) ? style.red(t) : style.green(t)),
504
+ model: (t) => style.bold(style.cyan(t)),
505
+ provider: style.magenta,
506
+ effort: style.yellow,
507
+ })[part.role](part.text))
508
+ .join(style.gray(" · "))
509
+ : undefined;
510
+ return fit(identityLine ? [identityLine, line1, style.dim(hint)] : [line1, style.dim(hint)], width);
113
511
  }
114
512
  }
115
513
  export class UserBlock {
@@ -130,49 +528,107 @@ export class UserBlock {
130
528
  ];
131
529
  }
132
530
  }
531
+ /**
532
+ * Subtle, non-interactive copy affordance under a completed response: it only tells the user the
533
+ * raw response can be copied (`/copy`, or `c` on an empty input); it never triggers itself.
534
+ * Falls back to ASCII when the terminal cannot render Unicode (dumb terminal / C locale).
535
+ */
536
+ const copyHint = (width) => {
537
+ const unicode = terminalCapabilities({ env: process.env, columns: width, tty: true }).unicode;
538
+ return ` ${style.dim(unicode ? "⎘ copy · /copy" : "[copy] · /copy")}`;
539
+ };
133
540
  export class AssistantBlock {
134
- markdown = new Markdown("", 0, 0, markdownTheme);
541
+ markdown = new Markdown("", 0, 0, markdownTheme, markdownDefaultTextStyle, {
542
+ transform: markdownTransform,
543
+ });
544
+ /** Bumped by TranscriptSync when this row's content changes; render caches on it. */
545
+ version = 0;
546
+ fold;
135
547
  item;
548
+ cache;
136
549
  constructor(item) {
137
550
  this.item = item;
138
551
  this.markdown.setText(item.text);
139
552
  }
140
- update(item) {
553
+ update(item, fold) {
141
554
  if (item.text !== this.item.text)
142
555
  this.markdown.setText(item.text);
143
556
  this.item = item;
557
+ this.fold = fold;
144
558
  }
145
559
  invalidate() {
146
560
  this.markdown.invalidate();
561
+ this.cache = undefined;
147
562
  }
148
563
  render(width) {
149
- const lines = [""];
150
564
  const reasoning = this.item.reasoning.trim();
565
+ // The live thinking view animates with streaming deltas and must never be cached.
566
+ const live = !!reasoning && !this.item.text && !this.item.done;
567
+ if (!live &&
568
+ this.cache?.width === width &&
569
+ this.cache.version === this.version &&
570
+ this.cache.fold?.expanded === this.fold?.expanded)
571
+ return this.cache.lines;
572
+ const lines = [""];
151
573
  if (reasoning) {
152
- if (this.item.done || this.item.text) {
153
- const words = reasoning.split(/\s+/).length;
154
- lines.push(style.dim(style.italic(`✻ reasoning (${words} words)`)));
155
- }
156
- else {
574
+ if (live) {
157
575
  lines.push(style.dim(style.italic("✻ thinking…")));
158
576
  lines.push(...wrap(reasoning, width - 2)
159
577
  .slice(-4)
160
578
  .map((l) => style.dim(` ${l}`)));
161
579
  }
580
+ else {
581
+ // Finished: a collapsible `+ Thought · 2.9s` header; expanded shows the bounded text.
582
+ const expanded = this.fold?.expanded ?? false;
583
+ const duration = reasoningDurationMs(this.item);
584
+ lines.push(`${style.dim(expanded ? "−" : "+")} ${style.dim(style.italic("Thought"))}${duration !== undefined ? ` ${style.dim(`· ${formatDuration(duration)}`)}` : ""}`);
585
+ if (expanded) {
586
+ const wrapped = wrap(reasoning, Math.max(1, width - 4));
587
+ lines.push(...wrapped.slice(0, REASONING_EXPAND_MAX_LINES).map((l) => ` ${style.dim(l)}`));
588
+ if (wrapped.length > REASONING_EXPAND_MAX_LINES)
589
+ lines.push(style.gray(` … reasoning truncated (${wrapped.length - REASONING_EXPAND_MAX_LINES} more lines)`));
590
+ }
591
+ }
162
592
  }
163
- if (this.item.text)
593
+ if (this.item.text) {
164
594
  lines.push(...this.markdown.render(width));
165
- return fit(lines, width);
595
+ // The affordance appears only once the answer is complete (never while streaming).
596
+ if (this.item.done)
597
+ lines.push(copyHint(width));
598
+ }
599
+ const out = fit(lines, width);
600
+ if (!live)
601
+ this.cache = { width, version: this.version, fold: this.fold, lines: out };
602
+ return out;
166
603
  }
167
604
  }
168
605
  export class ToolBlock {
169
606
  item;
607
+ /** Bumped by TranscriptSync when this row's content changes; render caches on it. */
608
+ version = 0;
609
+ fold;
610
+ markdown;
611
+ cache;
170
612
  constructor(item) {
171
613
  this.item = item;
172
614
  }
173
- invalidate() { }
615
+ update(item, fold) {
616
+ this.item = item;
617
+ this.fold = fold;
618
+ }
619
+ invalidate() {
620
+ this.cache = undefined;
621
+ }
174
622
  render(width) {
175
623
  const i = this.item;
624
+ // Running/approval rows animate and must never be cached; terminal rows only change when
625
+ // TranscriptSync bumps `version`, so clock frames between events reuse the cached lines.
626
+ const live = i.status === "running" || i.status === "approval";
627
+ if (!live &&
628
+ this.cache?.width === width &&
629
+ this.cache.version === this.version &&
630
+ this.cache.fold?.expanded === this.fold?.expanded)
631
+ return this.cache.lines;
176
632
  const icon = i.status === "running"
177
633
  ? style.cyan(SPINNER[clock.frame % SPINNER.length] ?? "…")
178
634
  : i.status === "approval"
@@ -180,12 +636,13 @@ export class ToolBlock {
180
636
  : i.status === "ok"
181
637
  ? style.green("✓")
182
638
  : style.red("✗");
639
+ const marker = this.fold ? `${style.dim(this.fold.expanded ? "−" : "+")} ` : "";
183
640
  const right = i.status === "approval"
184
641
  ? style.yellow("awaiting approval")
185
642
  : i.durationMs !== undefined
186
643
  ? style.gray(formatDuration(i.durationMs))
187
644
  : "";
188
- const head = `${icon} ${style.bold(i.name)} ${style.gray(i.summary)}`;
645
+ const head = `${marker}${icon} ${style.bold(humanizeToolName(i.name))} ${style.gray(i.summary)}`;
189
646
  const gap = width - visibleWidth(head) - visibleWidth(right) - 1;
190
647
  const lines = [
191
648
  gap > 0 ? `${head}${" ".repeat(gap + 1)}${right}` : truncateToWidth(head, width),
@@ -199,17 +656,93 @@ export class ToolBlock {
199
656
  if (diff.lines.length > max)
200
657
  lines.push(style.gray(` … ${diff.lines.length - max} more lines`));
201
658
  }
202
- if (i.preview && (i.status === "error" || !diff)) {
203
- const preview = i.preview.split("\n").filter((l) => l.trim());
204
- const shown = preview.slice(0, i.status === "error" ? 6 : 3);
659
+ // Native rich rendering: an inline image (when the terminal supports images and color is
660
+ // on) and/or a structured ui block replace the plain preview, exactly as the emitted text
661
+ // projection reads. Every rich line is indented two spaces like the preview lines.
662
+ const rich = richBodyLines(i, width, this.markdown);
663
+ const richLines = rich.lines;
664
+ if (rich.markdownCache)
665
+ this.markdown = rich.markdownCache;
666
+ if (richLines.length) {
667
+ lines.push(...richLines.map((line) => ` ${line}`));
668
+ }
669
+ else if (i.preview && (i.status === "error" || !diff)) {
670
+ const rows = previewRows(i.preview);
671
+ const cap = previewLinesFor(i.status);
672
+ const expanded = this.fold?.expanded ?? false;
673
+ const shown = expanded ? rows : rows.slice(0, cap);
205
674
  const paint = i.status === "error" ? style.red : style.gray;
206
675
  shown.forEach((l, n) => {
207
676
  lines.push(` ${style.gray(n === 0 ? "⎿" : " ")} ${paint(l.replace(/\t/g, " "))}`);
208
677
  });
209
- if (preview.length > shown.length)
210
- lines.push(style.gray(` … ${preview.length - shown.length} more lines`));
678
+ if (!expanded && rows.length > shown.length)
679
+ lines.push(style.gray(` … ${rows.length - shown.length} more lines`));
211
680
  }
212
- return fit(lines, width);
681
+ const exitLine = exitCodeLine(i.exitCode);
682
+ if (exitLine)
683
+ lines.push(exitLine);
684
+ const out = fit(lines, width);
685
+ if (!live)
686
+ this.cache = { width, version: this.version, fold: this.fold, lines: out };
687
+ return out;
688
+ }
689
+ }
690
+ /**
691
+ * One collapsible row for a GROUPED batch of finished tool calls (e.g. `✓ Read File — 3 reads`).
692
+ * Collapsed it shows the aggregate header; expanded, each call with its status, duration,
693
+ * summary and capped preview/rich body. All members are terminal, so lines are cached per
694
+ * version/width/fold just like the other terminal rows.
695
+ */
696
+ export class ToolGroupBlock {
697
+ group;
698
+ /** Bumped by TranscriptSync when this row's content changes; render caches on it. */
699
+ version = 0;
700
+ fold;
701
+ cache;
702
+ constructor(group) {
703
+ this.group = group;
704
+ }
705
+ update(group, fold) {
706
+ this.group = group;
707
+ this.fold = fold;
708
+ }
709
+ invalidate() {
710
+ this.cache = undefined;
711
+ }
712
+ render(width) {
713
+ if (this.cache?.width === width &&
714
+ this.cache.version === this.version &&
715
+ this.cache.fold?.expanded === this.fold?.expanded)
716
+ return this.cache.lines;
717
+ const g = this.group;
718
+ const expanded = this.fold?.expanded ?? false;
719
+ const icon = g.status === "error" ? style.red("✗") : style.green("✓");
720
+ const head = `${style.dim(expanded ? "−" : "+")} ${icon} ${style.bold(g.label)} — ${g.members.length} ${g.word}${g.totalMs > 0 ? ` ${style.gray(`· ${formatDuration(g.totalMs)}`)}` : ""}`;
721
+ const lines = [head];
722
+ if (expanded) {
723
+ for (const member of g.members) {
724
+ const micon = member.status === "error" ? style.red("✗") : style.green("✓");
725
+ const name = member.humanName === member.name
726
+ ? style.bold(member.humanName)
727
+ : `${style.bold(member.humanName)} ${style.dim(`(${member.name})`)}`;
728
+ lines.push(` ${micon} ${name}${member.durationMs !== undefined
729
+ ? ` ${style.gray(`· ${formatDuration(member.durationMs)}`)}`
730
+ : ""}${member.summary ? ` ${style.gray(member.summary)}` : ""}`);
731
+ const rich = richBodyLines(member, width);
732
+ if (rich.lines.length) {
733
+ lines.push(...rich.lines.map((line) => ` ${line}`));
734
+ }
735
+ else {
736
+ lines.push(...memberPreviewLines(member, " "));
737
+ }
738
+ const exitLine = exitCodeLine(member.exitCode, " ");
739
+ if (exitLine)
740
+ lines.push(exitLine);
741
+ }
742
+ }
743
+ const out = fit(lines, width);
744
+ this.cache = { width, version: this.version, fold: this.fold, lines: out };
745
+ return out;
213
746
  }
214
747
  }
215
748
  export class LineBlock {
@@ -229,7 +762,9 @@ export class LineBlock {
229
762
  export class InfoBlock {
230
763
  markdown;
231
764
  constructor(text) {
232
- this.markdown = new Markdown(text, 1, 0, markdownTheme);
765
+ this.markdown = new Markdown(text, 1, 0, markdownTheme, markdownDefaultTextStyle, {
766
+ transform: markdownTransform,
767
+ });
233
768
  }
234
769
  invalidate() {
235
770
  this.markdown.invalidate();
@@ -239,18 +774,184 @@ export class InfoBlock {
239
774
  return fit(["", ...inner.map((l) => `${style.gray("│")} ${l}`)], width);
240
775
  }
241
776
  }
242
- export function componentFor(item) {
243
- switch (item.kind) {
777
+ /**
778
+ * Minimum usable inner width (cells) the transcript keeps no matter how narrow the terminal is or
779
+ * how large the configured inset grows. The effective inset is clamped so this floor is honored,
780
+ * which also keeps the inner width positive.
781
+ */
782
+ export const MIN_CONTENT_WIDTH = 20;
783
+ /**
784
+ * Inset actually applied on each side: the configured value capped so the inner column stays at
785
+ * least `MIN_CONTENT_WIDTH` wide. On terminals narrower than that floor the inset degrades to 0
786
+ * rather than overflowing (the inner width then simply equals the terminal width).
787
+ */
788
+ export function effectiveContentInset(width, inset) {
789
+ const requested = Math.max(0, Math.floor(inset));
790
+ const max = Math.max(0, Math.floor((Math.floor(width) - MIN_CONTENT_WIDTH) / 2));
791
+ return Math.min(requested, max);
792
+ }
793
+ /** Inner width handed to transcript children after the effective inset is applied. Always >= 1. */
794
+ export function contentInnerWidth(width, inset) {
795
+ return Math.max(1, Math.floor(width) - effectiveContentInset(width, inset) * 2);
796
+ }
797
+ /**
798
+ * Applies the transcript's horizontal inset in ONE place: it hands children a reduced width and
799
+ * prefixes every produced line with the inset, so all transcript chrome (text, copy hint, tool
800
+ * sub-lines, reasoning lines, group headers) shares the same visual column. The reduced width is a
801
+ * deterministic function of the terminal width, so per-row caches keyed by width stay effective.
802
+ */
803
+ export class ContentInset {
804
+ inner;
805
+ inset;
806
+ constructor(inner, inset) {
807
+ this.inner = inner;
808
+ this.inset = inset;
809
+ }
810
+ invalidate() {
811
+ this.inner.invalidate();
812
+ }
813
+ render(width) {
814
+ const inset = effectiveContentInset(width, this.inset());
815
+ const lines = this.inner.render(contentInnerWidth(width, inset));
816
+ if (!inset)
817
+ return lines;
818
+ const pad = " ".repeat(inset);
819
+ return lines.map((line) => `${pad}${line}`);
820
+ }
821
+ }
822
+ export function componentFor(entry) {
823
+ if (entry.kind === "group")
824
+ return new ToolGroupBlock(entry.group);
825
+ switch (entry.kind) {
244
826
  case "user":
245
- return new UserBlock(item.text);
827
+ return new UserBlock(entry.text);
246
828
  case "assistant":
247
- return new AssistantBlock(item);
829
+ return new AssistantBlock(entry);
248
830
  case "tool":
249
- return new ToolBlock(item);
831
+ return new ToolBlock(entry);
250
832
  case "info":
251
- return new InfoBlock(item.text);
833
+ return new InfoBlock(entry.text);
252
834
  default:
253
- return new LineBlock(item.text, item.kind);
835
+ return new LineBlock(entry.text, entry.kind);
836
+ }
837
+ }
838
+ /** Applies a fresh entry (and its fold state) to a component; kinds never collide under a key. */
839
+ function updateComponent(component, entry, fold) {
840
+ if (entry.kind === "assistant" && component instanceof AssistantBlock)
841
+ return component.update(entry, fold);
842
+ if (entry.kind === "tool" && component instanceof ToolBlock)
843
+ return component.update(entry, fold);
844
+ if (entry.kind === "group" && component instanceof ToolGroupBlock)
845
+ return component.update(entry.group, fold);
846
+ }
847
+ /** Whether a reused component can adopt the (new) entry kind (foldable rows also carry `version`). */
848
+ function isCompatible(component, entry) {
849
+ return ((entry.kind === "assistant" && component instanceof AssistantBlock) ||
850
+ (entry.kind === "tool" && component instanceof ToolBlock) ||
851
+ (entry.kind === "group" && component instanceof ToolGroupBlock) ||
852
+ ((entry.kind === "user" ||
853
+ entry.kind === "notice" ||
854
+ entry.kind === "info" ||
855
+ entry.kind === "error") &&
856
+ !(component instanceof AssistantBlock) &&
857
+ !(component instanceof ToolBlock) &&
858
+ !(component instanceof ToolGroupBlock)));
859
+ }
860
+ /**
861
+ * Keeps a container of transcript components in step with view-model items. Rows are keyed
862
+ * (stable per fold candidate: tool ids, group ids, append-only item indices) and reused across
863
+ * syncs; batch groups replace their members' singleton components with one row once all of them
864
+ * finish. Content versions bump only when a row's entry actually changed, so clock frames
865
+ * between events reuse each component's cached lines instead of re-rendering. `setFoldResolver`
866
+ * supplies the effective expanded state; the same resolution feeds `entryAt`, so keybindings
867
+ * and mouse clicks agree with the renderer.
868
+ */
869
+ export class TranscriptSync {
870
+ container = new Container();
871
+ rows = new Map();
872
+ order = [];
873
+ foldOf = (candidate) => candidate.defaultExpanded;
874
+ setFoldResolver(resolver) {
875
+ this.foldOf = resolver;
876
+ }
877
+ sync(items) {
878
+ const entries = groupToolEntries(items);
879
+ const kept = new Set();
880
+ for (const { entry, at } of entries) {
881
+ const key = entryKeyOf(entry, at);
882
+ kept.add(key);
883
+ const candidate = foldCandidateOf(entry, at);
884
+ const fold = candidate
885
+ ? { expanded: this.foldOf(candidate), kind: candidate.kind }
886
+ : undefined;
887
+ const row = this.rows.get(key);
888
+ if (row && isCompatible(row.component, entry)) {
889
+ const same = row.entry === entry ||
890
+ (entry.kind === "group" &&
891
+ row.entry.kind === "group" &&
892
+ groupIdentity(entry.group) === groupIdentity(row.entry.group));
893
+ if (!same) {
894
+ row.version++;
895
+ row.entry = entry;
896
+ row.at = at;
897
+ }
898
+ updateComponent(row.component, entry, fold);
899
+ if (row.component instanceof AssistantBlock ||
900
+ row.component instanceof ToolBlock ||
901
+ row.component instanceof ToolGroupBlock)
902
+ row.component.version = row.version;
903
+ }
904
+ else {
905
+ // Key collision across kinds (defensive; keys are per-kind in practice): drop any stale
906
+ // component so the container never renders a duplicated row, then add the fresh one.
907
+ if (row)
908
+ this.container.removeChild(row.component);
909
+ const component = componentFor(entry);
910
+ updateComponent(component, entry, fold);
911
+ this.rows.set(key, { entry, at, component, version: 0 });
912
+ this.container.addChild(component);
913
+ }
914
+ }
915
+ for (const [key, row] of this.rows)
916
+ if (!kept.has(key)) {
917
+ this.container.removeChild(row.component);
918
+ this.rows.delete(key);
919
+ }
920
+ this.order = entries.map(({ entry, at }) => entryKeyOf(entry, at));
921
+ // Preserve external children (startup banner) at the front, then managed rows in item order.
922
+ const managed = new Set(this.order.map((k) => this.rows.get(k)?.component).filter((c) => !!c));
923
+ this.container.children = [
924
+ ...this.container.children.filter((c) => !managed.has(c)),
925
+ ...this.order.map((k) => this.rows.get(k).component),
926
+ ];
927
+ }
928
+ /**
929
+ * Fold candidate at a transcript content row, when the click landed on the row's header
930
+ * (assistant entries start with a blank spacer line, so their first two rows count). Used by
931
+ * click-to-toggle; keyboard toggling uses `foldCandidates` instead and needs no heights.
932
+ */
933
+ entryAt(contentY, width) {
934
+ let acc = 0;
935
+ for (const key of this.order) {
936
+ const row = this.rows.get(key);
937
+ if (!row)
938
+ continue;
939
+ const height = row.component.render(width).length;
940
+ if (contentY >= acc && contentY < acc + height) {
941
+ const candidate = foldCandidateOf(row.entry, row.at);
942
+ if (!candidate)
943
+ return undefined;
944
+ const headerRows = row.entry.kind === "assistant" ? 2 : 1;
945
+ return contentY < acc + headerRows ? candidate : undefined;
946
+ }
947
+ acc += height;
948
+ }
949
+ return undefined;
950
+ }
951
+ reset() {
952
+ this.rows.clear();
953
+ this.order = [];
954
+ this.container.clear();
254
955
  }
255
956
  }
256
957
  /** Startup screen rendered by core for the current width (cached until the width changes). */
@@ -269,34 +970,6 @@ export class BannerBlock {
269
970
  return fit(this.cache.lines, width);
270
971
  }
271
972
  }
272
- /** Keeps a container of transcript components in step with view-model items. */
273
- export class TranscriptSync {
274
- container = new Container();
275
- rendered = [];
276
- sync(items) {
277
- if (items.length < this.rendered.length)
278
- this.reset();
279
- items.forEach((item, index) => {
280
- const entry = this.rendered[index];
281
- if (!entry) {
282
- const component = componentFor(item);
283
- this.container.addChild(component);
284
- this.rendered.push({ item, component });
285
- }
286
- else if (entry.item !== item) {
287
- if (entry.component instanceof AssistantBlock && item.kind === "assistant")
288
- entry.component.update(item);
289
- else if (entry.component instanceof ToolBlock && item.kind === "tool")
290
- entry.component.item = item;
291
- entry.item = item;
292
- }
293
- });
294
- }
295
- reset() {
296
- this.container.clear();
297
- this.rendered = [];
298
- }
299
- }
300
973
  /** Renders one of several components (main conversation or a read-only child view). */
301
974
  export class Switch {
302
975
  pick;
@@ -485,3 +1158,226 @@ export class QuestionPanel {
485
1158
  return fit(lines, width);
486
1159
  }
487
1160
  }
1161
+ /**
1162
+ * The plan review panel (`exit_plan`): the three decisions under the plan the transcript already
1163
+ * shows. "Add context" turns the panel into a one-line text field (Enter sends, Esc goes back);
1164
+ * Esc while choosing is "Skip for now". The logic lives in `plan-review.ts`.
1165
+ */
1166
+ export class PlanReviewPanel {
1167
+ header;
1168
+ onSubmit;
1169
+ placeholder;
1170
+ state;
1171
+ constructor(options, header, onSubmit, placeholder = "Add context for the plan") {
1172
+ this.header = header;
1173
+ this.onSubmit = onSubmit;
1174
+ this.placeholder = placeholder;
1175
+ this.state = initialPlanReviewState(options);
1176
+ }
1177
+ invalidate() { }
1178
+ dispatch(action) {
1179
+ const { state, effect } = reducePlanReview(this.state, action);
1180
+ this.state = state;
1181
+ if (effect)
1182
+ this.onSubmit(effect);
1183
+ }
1184
+ handleInput(data) {
1185
+ const text = this.state.mode === "text";
1186
+ if (matchesKey(data, Key.escape))
1187
+ return this.dispatch({ type: "escape" });
1188
+ if (matchesKey(data, Key.enter))
1189
+ return this.dispatch({ type: "confirm" });
1190
+ if (!text) {
1191
+ if (matchesKey(data, Key.up))
1192
+ return this.dispatch({ type: "up" });
1193
+ if (matchesKey(data, Key.down))
1194
+ return this.dispatch({ type: "down" });
1195
+ return;
1196
+ }
1197
+ if (matchesKey(data, Key.left))
1198
+ return this.dispatch({ type: "left" });
1199
+ if (matchesKey(data, Key.right))
1200
+ return this.dispatch({ type: "right" });
1201
+ if (matchesKey(data, Key.home))
1202
+ return this.dispatch({ type: "home" });
1203
+ if (matchesKey(data, Key.end))
1204
+ return this.dispatch({ type: "end" });
1205
+ if (matchesKey(data, Key.backspace))
1206
+ return this.dispatch({ type: "backspace" });
1207
+ if (matchesKey(data, Key.delete))
1208
+ return this.dispatch({ type: "delete" });
1209
+ this.dispatch({ type: "insert", text: data });
1210
+ }
1211
+ render(width) {
1212
+ const s = this.state;
1213
+ const lines = [];
1214
+ const { title, revision, artifactId, question } = this.header;
1215
+ lines.push(truncateToWidth(`${style.bold(style.yellow("Plan review"))} ${style.dim(`${title}${revision > 1 ? ` · revision ${revision}` : ""}`)}`, width));
1216
+ lines.push(...wrap(question, Math.max(1, width - 2)).map((l) => ` ${l}`));
1217
+ s.options.forEach((option, i) => {
1218
+ const focused = i === s.cursor;
1219
+ const cursor = focused ? style.cyan("❯ ") : " ";
1220
+ const label = focused ? style.bold(style.cyan(option.label)) : option.label;
1221
+ const tag = option.recommended ? ` ${style.green("(recommended)")}` : "";
1222
+ lines.push(truncateToWidth(`${cursor}${label}${tag}`, width));
1223
+ });
1224
+ if (s.mode === "text") {
1225
+ const value = Array.from(s.text.value);
1226
+ const before = value.slice(0, s.text.cursor).join("");
1227
+ const current = value[s.text.cursor] ?? " ";
1228
+ const after = value.slice(s.text.cursor + 1).join("");
1229
+ lines.push(truncateToWidth(value.length
1230
+ ? ` ${style.brightCyan("›")} ${before}${style.brightCyan(`▏${current}`)}${after}`
1231
+ : ` ${style.brightCyan("›")} ${style.dim(this.placeholder)} ${style.brightCyan("▏")}`, width));
1232
+ }
1233
+ if (artifactId)
1234
+ lines.push(style.dim(` Saved as plan.md (artifact ${artifactId}) · /artifacts`));
1235
+ lines.push(style.dim(` ${s.mode === "text" ? "Enter send · Esc back to the choices" : "↑↓ select · Enter confirm · Esc skip for now"}`));
1236
+ return fit(lines, width);
1237
+ }
1238
+ }
1239
+ /**
1240
+ * The `/btw` side-question panel (picker slot, never the transcript): a pending question with a
1241
+ * spinner, then the answer rendered as Markdown like assistant text, scrollable, with ←/→ through
1242
+ * the session's earlier side answers. `onClose` runs on Esc (the app cancels a pending question).
1243
+ */
1244
+ /**
1245
+ * A scrollable Markdown panel in the picker slot (`/changelog`): Up/Down and PgUp/PgDn scroll,
1246
+ * Home/End jump, Esc or q closes. Pure rendering: the text and the height come from the caller.
1247
+ */
1248
+ export class MarkdownPanel {
1249
+ title;
1250
+ markdown;
1251
+ onClose;
1252
+ height;
1253
+ scroll = 0;
1254
+ cache;
1255
+ constructor(title, markdown, onClose, height) {
1256
+ this.title = title;
1257
+ this.markdown = markdown;
1258
+ this.onClose = onClose;
1259
+ this.height = height;
1260
+ }
1261
+ invalidate() {
1262
+ this.cache = undefined;
1263
+ }
1264
+ handleInput(data) {
1265
+ if (matchesKey(data, Key.escape) || data === "q")
1266
+ return this.onClose();
1267
+ const page = Math.max(1, this.height());
1268
+ if (matchesKey(data, Key.up))
1269
+ this.scroll -= 1;
1270
+ else if (matchesKey(data, Key.down))
1271
+ this.scroll += 1;
1272
+ else if (matchesKey(data, Key.pageUp))
1273
+ this.scroll -= page;
1274
+ else if (matchesKey(data, Key.pageDown))
1275
+ this.scroll += page;
1276
+ else if (matchesKey(data, Key.home))
1277
+ this.scroll = 0;
1278
+ else if (matchesKey(data, Key.end))
1279
+ this.scroll = Number.MAX_SAFE_INTEGER;
1280
+ }
1281
+ render(width) {
1282
+ const inner = Math.max(1, width - 2);
1283
+ if (this.cache?.width !== inner)
1284
+ this.cache = {
1285
+ width: inner,
1286
+ lines: new Markdown(this.markdown, 0, 0, markdownTheme, markdownDefaultTextStyle, {
1287
+ transform: markdownTransform,
1288
+ }).render(inner),
1289
+ };
1290
+ const view = scrollWindow(this.cache.lines, this.scroll, Math.max(3, this.height()));
1291
+ this.scroll = view.scroll;
1292
+ const bar = style.gray("│");
1293
+ return fit([
1294
+ "",
1295
+ style.bold(style.yellow(this.title)),
1296
+ ...(view.above ? [`${bar} ${style.dim("↑ more")}`] : []),
1297
+ ...view.lines.map((line) => `${bar} ${line}`),
1298
+ ...(view.below ? [`${bar} ${style.dim("↓ more")}`] : []),
1299
+ style.dim(` ${view.above || view.below ? "↑/↓ PgUp/PgDn scroll · " : ""}Esc close`),
1300
+ ], width);
1301
+ }
1302
+ }
1303
+ export class BtwPanel {
1304
+ state;
1305
+ onClose;
1306
+ height;
1307
+ markdown;
1308
+ constructor(state, onClose, height) {
1309
+ this.state = state;
1310
+ this.onClose = onClose;
1311
+ this.height = height;
1312
+ }
1313
+ get pending() {
1314
+ return !!this.state.pending;
1315
+ }
1316
+ dispatch(action) {
1317
+ this.state = reduceBtw(this.state, action);
1318
+ }
1319
+ invalidate() {
1320
+ this.markdown = undefined;
1321
+ }
1322
+ handleInput(data) {
1323
+ if (matchesKey(data, Key.escape))
1324
+ return this.onClose();
1325
+ if (matchesKey(data, Key.left))
1326
+ return this.dispatch({ type: "prev" });
1327
+ if (matchesKey(data, Key.right))
1328
+ return this.dispatch({ type: "next" });
1329
+ if (matchesKey(data, Key.up))
1330
+ return this.dispatch({ type: "scroll", lines: -1 });
1331
+ if (matchesKey(data, Key.down))
1332
+ return this.dispatch({ type: "scroll", lines: 1 });
1333
+ if (matchesKey(data, Key.pageUp))
1334
+ return this.dispatch({ type: "scroll", lines: -this.height() });
1335
+ if (matchesKey(data, Key.pageDown))
1336
+ return this.dispatch({ type: "scroll", lines: this.height() });
1337
+ }
1338
+ answerLines(id, text, width) {
1339
+ if (this.markdown?.id === id && this.markdown.width === width)
1340
+ return this.markdown.lines;
1341
+ const lines = new Markdown(text, 0, 0, markdownTheme, markdownDefaultTextStyle, {
1342
+ transform: markdownTransform,
1343
+ }).render(Math.max(1, width));
1344
+ this.markdown = { id, width, lines };
1345
+ return lines;
1346
+ }
1347
+ render(width) {
1348
+ const s = this.state;
1349
+ const inner = Math.max(1, width - 2);
1350
+ const bar = style.gray("│");
1351
+ const current = btwCurrent(s);
1352
+ const position = btwPosition(s);
1353
+ const lines = [
1354
+ "",
1355
+ `${style.bold(style.yellow("btw"))}${position ? ` ${style.dim(position)}` : ""}${style.dim(" · side question, not added to the conversation")}`,
1356
+ ];
1357
+ const question = s.pending?.question ?? s.error?.question ?? current?.question ?? "";
1358
+ lines.push(...wrap(question, inner).map((l) => `${bar} ${style.bold(l)}`));
1359
+ let overflow = false;
1360
+ if (s.pending) {
1361
+ const spinner = SPINNER[clock.frame % SPINNER.length] ?? "…";
1362
+ lines.push(`${bar} ${style.cyan(spinner)} Thinking… ${style.dim(formatDuration(clock.now - s.pending.startedAt))}`);
1363
+ }
1364
+ else if (s.error) {
1365
+ lines.push(...wrap(s.error.message, inner).map((l) => `${bar} ${style.red(l)}`));
1366
+ }
1367
+ else if (current) {
1368
+ const body = this.answerLines(current.id, current.answer, inner);
1369
+ const view = btwWindow(body, s.scroll, Math.max(3, this.height()));
1370
+ if (view.scroll !== s.scroll)
1371
+ this.state = { ...s, scroll: view.scroll };
1372
+ overflow = view.above || view.below;
1373
+ if (view.above)
1374
+ lines.push(`${bar} ${style.dim("↑ more")}`);
1375
+ lines.push(...view.lines.map((l) => `${bar} ${l}`));
1376
+ if (view.below)
1377
+ lines.push(`${bar} ${style.dim("↓ more")}`);
1378
+ lines.push(`${bar} ${style.dim(btwUsageLine(current))}`);
1379
+ }
1380
+ lines.push(style.dim(` ${btwHints(this.state, overflow)}`));
1381
+ return fit(lines, width);
1382
+ }
1383
+ }