@alisio/alisio-code 0.1.0-alpha.9 → 0.1.1

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 +133 -6
  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
package/dist/tui/state.js CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Pure presentation logic for the TUI: formatting, command parsing and the reduction of
3
+ * versioned runner events into a view model. No terminal or pi-tui imports here.
4
+ */
5
+ import { CommandCatalog, describeOutputLimitSource } from "@alisio/core";
1
6
  const trimZero = (value) => value.replace(/\.0$/, "");
2
7
  export function formatTokens(n) {
3
8
  if (!Number.isFinite(n) || n < 0)
@@ -24,6 +29,55 @@ export function providerModelItems(provider, models, current) {
24
29
  }
25
30
  /** Text markers remain meaningful without color: [x] active, [ ] inactive, [!] failed, [*] pending. */
26
31
  /** Group headings are derived from the primary category (or "General") of each plugin. */
32
+ /**
33
+ * Reserved value prefix marking a non-selectable group-header row in picker item lists. It can
34
+ * never collide with real entry ids (plugin ids, server names), which never start with "_".
35
+ */
36
+ export const GROUP_HEADER_PREFIX = "__group:";
37
+ /** True when the item is a group header emitted under GROUP_HEADER_PREFIX (never selectable). */
38
+ export const isGroupHeader = (item) => item.value.startsWith(GROUP_HEADER_PREFIX);
39
+ /**
40
+ * Nearest non-header index from `index` stepping `dir` (1 = down, -1 = up), wrapping at the
41
+ * edges exactly like a plain list would, so navigation skips header rows in both directions and
42
+ * never stops on one. Returns -1 for an empty list and `index` unchanged when every item is a
43
+ * header (callers then treat it as "no move").
44
+ */
45
+ export function skipGroupHeaders(items, index, dir) {
46
+ const count = items.length;
47
+ if (!count)
48
+ return -1;
49
+ let next = (index + dir + count) % count;
50
+ for (let steps = 0; steps < count && next !== index; steps++) {
51
+ if (!isGroupHeader(items[next]))
52
+ return next;
53
+ next = (next + dir + count) % count;
54
+ }
55
+ return index;
56
+ }
57
+ /**
58
+ * The display sequence a picker should render for a filter: rows matching SelectList's own
59
+ * prefix semantics, plus the group header of every group that still has at least one visible
60
+ * row, in original order. Groups with no match emit no header.
61
+ */
62
+ export function visibleGroupedItems(items, filter) {
63
+ const query = filter.toLowerCase();
64
+ const visible = [];
65
+ let header;
66
+ for (const item of items) {
67
+ if (isGroupHeader(item)) {
68
+ header = item;
69
+ continue;
70
+ }
71
+ if (item.value.toLowerCase().startsWith(query)) {
72
+ if (header) {
73
+ visible.push(header);
74
+ header = undefined;
75
+ }
76
+ visible.push(item);
77
+ }
78
+ }
79
+ return visible;
80
+ }
27
81
  export function pluginCatalogItems(entries) {
28
82
  const marker = (entry) => entry.status === "active"
29
83
  ? "[x]"
@@ -37,11 +91,15 @@ export function pluginCatalogItems(entries) {
37
91
  const primary = entry.categories[0] ?? "General";
38
92
  grouped.set(primary, [...(grouped.get(primary) ?? []), entry]);
39
93
  }
40
- return [...grouped].flatMap(([title, group]) => group.map((entry, index) => ({
41
- value: entry.id,
42
- label: `${index === 0 ? `${title} · ` : ""}${marker(entry)} ${entry.name} · ${entry.builtin ? "built-in" : entry.source}`,
43
- description: `${entry.status}${entry.categories.length ? ` · ${entry.categories.join(", ")}` : ""} · ${entry.description}`,
44
- })));
94
+ // Every group opens with its own non-selectable header row; plugin rows are indented under it.
95
+ return [...grouped].flatMap(([title, group]) => [
96
+ { value: `${GROUP_HEADER_PREFIX}${title}`, label: `${title} ›` },
97
+ ...group.map((entry) => ({
98
+ value: entry.id,
99
+ label: ` ${marker(entry)} ${entry.name} · ${entry.builtin ? "built-in" : entry.source}`,
100
+ description: `${entry.status}${entry.categories.length ? ` · ${entry.categories.join(", ")}` : ""} · ${entry.description}`,
101
+ })),
102
+ ]);
45
103
  }
46
104
  export const pluginToggleNeedsConfirmation = (entry) => !entry.builtin;
47
105
  const mcpSourceTitle = (kind) => ({
@@ -198,46 +256,27 @@ export function fitSegments(segments, width, separator) {
198
256
  kept[0] = { ...only, text: truncatePlain(only.text, width) };
199
257
  return kept;
200
258
  }
201
- export const COMMANDS = [
202
- { name: "help", description: "Show commands and keys" },
203
- { name: "connect", description: "Configure a provider and choose its active model" },
204
- {
205
- name: "model",
206
- description: "Switch provider and model",
207
- aliases: ["models"],
208
- },
209
- { name: "compact", description: "Summarize older history", argumentHint: "[focus]" },
210
- { name: "stats", description: "Session statistics" },
211
- { name: "clear", description: "Start a new session", aliases: ["new"] },
212
- { name: "sessions", description: "List recent sessions" },
213
- { name: "resume", description: "Resume a session by ID or prefix", argumentHint: "<id>" },
214
- { name: "tools", description: "List tools and permission state" },
215
- {
216
- name: "plugins",
217
- description: "Browse and manage project plugins",
218
- aliases: ["plugin"],
219
- },
220
- {
221
- name: "skills",
222
- description: "Browse and manage effective skills",
223
- aliases: ["skill"],
224
- },
225
- { name: "mcp", description: "Browse and manage MCP servers" },
226
- { name: "copy", description: "Copy the last assistant response to the clipboard" },
227
- {
228
- name: "ask",
229
- description: "Ask the agent to turn your question into a multiple-choice ask_user_question",
230
- argumentHint: "<question>",
231
- },
232
- { name: "exit", description: "Exit Alisio", aliases: ["quit"] },
233
- ];
259
+ /** Built-ins only: plugin, template and skill commands are resolved against the live app. */
260
+ const BUILTIN_CATALOG = new CommandCatalog();
261
+ /**
262
+ * Built-in slash commands the TUI offers, derived from the core `CommandCatalog` (the TUI
263
+ * surface of `BUILTIN_COMMANDS`). Kept as `COMMANDS` for compatibility.
264
+ */
265
+ export const COMMANDS = BUILTIN_CATALOG.list("tui")
266
+ .filter((c) => c.source === "builtin")
267
+ .map((c) => ({
268
+ name: c.name,
269
+ description: c.description,
270
+ ...(c.argumentHint ? { argumentHint: c.argumentHint } : {}),
271
+ ...(c.aliases ? { aliases: c.aliases } : {}),
272
+ }));
234
273
  /** Every TUI slash name (commands, aliases and routing prefixes); templates cannot take them. */
235
274
  export function reservedCommandNames() {
236
275
  return [...COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]), "command"];
237
276
  }
238
277
  export function resolveCommand(name) {
239
- const lower = name.toLowerCase();
240
- return COMMANDS.find((c) => c.name === lower || c.aliases?.includes(lower))?.name;
278
+ const found = BUILTIN_CATALOG.resolve(name);
279
+ return found?.source === "builtin" && found.surfaces.includes("tui") ? found.name : undefined;
241
280
  }
242
281
  export function parseCommand(input) {
243
282
  const match = /^\/([A-Za-z][\w:.-]*)(?:\s+([\s\S]*))?$/.exec(input.trim());
@@ -245,6 +284,151 @@ export function parseCommand(input) {
245
284
  return undefined;
246
285
  return { name: match[1], args: (match[2] ?? "").trim() };
247
286
  }
287
+ /**
288
+ * The effective reasoning effort to send for a model: the persisted level when the model
289
+ * supports it, otherwise the model's default level (silent fallback), and nothing when the
290
+ * model advertises no effort levels at all.
291
+ */
292
+ export function effectiveEffort(persisted, capability) {
293
+ if (!capability?.supportedLevels?.length)
294
+ return undefined;
295
+ if (persisted && capability.supportedLevels.includes(persisted))
296
+ return persisted;
297
+ return capability.defaultLevel;
298
+ }
299
+ /** Validates a `/effort <level>` argument against the active model; returns an error or nothing. */
300
+ export function validateEffortLevel(level, capability) {
301
+ if (!capability?.supportedLevels?.length)
302
+ return "This model does not advertise reasoning effort levels, so no effort is sent to it.";
303
+ if (!capability.supportedLevels.includes(level))
304
+ return `Unsupported effort level "${level}". Supported levels: ${capability.supportedLevels.join(", ")}.`;
305
+ return undefined;
306
+ }
307
+ export function effortPickerItems(capability, active) {
308
+ const items = capability.supportedLevels.map((level) => ({
309
+ value: level,
310
+ label: [
311
+ level,
312
+ level === active ? "(current)" : undefined,
313
+ level === capability.defaultLevel ? "(default)" : undefined,
314
+ ]
315
+ .filter(Boolean)
316
+ .join(" · "),
317
+ ...(level === capability.defaultLevel ? { description: "The model's own default" } : {}),
318
+ }));
319
+ return [
320
+ ...items,
321
+ {
322
+ value: "!clear",
323
+ label: "Auto · provider default",
324
+ description: "Clear the saved effort; the provider uses its own default",
325
+ },
326
+ ];
327
+ }
328
+ /**
329
+ * The ordered identity parts for the status line below the editor:
330
+ * `agent: <name> · <model> · <provider> · <effort>`, with the effort part omitted when the
331
+ * active model advertises no supported levels (`effort === undefined`). Colors are applied by
332
+ * the renderer per role; this module stays terminal-free and testable.
333
+ */
334
+ export function identityParts(input) {
335
+ const parts = [
336
+ { text: `agent: ${input.agent}`, priority: 10, role: "agent" },
337
+ // Between the agent and the model: it drops after the effort and the provider, before the model.
338
+ ...(input.mode ? [{ text: `mode: ${input.mode}`, priority: 8.5, role: "mode" }] : []),
339
+ { text: input.model || "not connected", priority: 9, role: "model" },
340
+ ];
341
+ if (input.provider)
342
+ parts.push({ text: input.provider, priority: 8, role: "provider" });
343
+ if (input.effort)
344
+ parts.push({ text: input.effort, priority: 7, role: "effort" });
345
+ return parts;
346
+ }
347
+ /** Drops the lowest-priority identity parts until the line fits `width`; truncates the survivor. */
348
+ export function fitIdentityParts(input, width) {
349
+ return fitSegments(identityParts(input), width, " · ");
350
+ }
351
+ export const SKILL_COMPLETION_DESCRIPTION_LIMIT = 80;
352
+ /** Status hint prefixes (no color): shadowed < disabled < locked states shown by /skills. */
353
+ const skillStatus = (skill) => !skill.effective
354
+ ? "shadowed"
355
+ : !skill.enabled
356
+ ? "disabled"
357
+ : skill.locked
358
+ ? "locked by plugin"
359
+ : undefined;
360
+ /**
361
+ * OpenCode-style scope marker for a skill's slash entry, mirroring the catalog scopes the /skills
362
+ * manager shows: user, project, config and plugin (plugin skills are locked by their owner plugin).
363
+ */
364
+ export const skillScopeMarker = (skill) => {
365
+ const marker = { user: "[u]", project: "[p]", config: "[c]", plugin: "[l]" }[skill.scope];
366
+ return marker ?? (skill.scope ? `[${skill.scope}]` : undefined);
367
+ };
368
+ /**
369
+ * Description for a skill's first-class `skill:<id>` slash entry: scope marker, status hint, then
370
+ * the skill description truncated to the shared limit. The marker and status are prepended before
371
+ * truncating, so they survive the cap just like the /skills argument completions.
372
+ */
373
+ export function skillSlashDescription(skill) {
374
+ const marker = skillScopeMarker(skill);
375
+ const status = skillStatus(skill);
376
+ if (!marker && !status)
377
+ return truncatePlain(skill.description, SKILL_COMPLETION_DESCRIPTION_LIMIT);
378
+ return truncatePlain(`${[marker, status].filter(Boolean).join(" ")}${status ? " · " : " "}${skill.description}`, SKILL_COMPLETION_DESCRIPTION_LIMIT);
379
+ }
380
+ /**
381
+ * Suggested completions for `/skills <prefix>`: every entry the /skills manager shows, filtered by
382
+ * prefix (case-insensitive) on name or description. Selecting a suggestion only fills the argument;
383
+ * submitting still opens the skills manager.
384
+ */
385
+ export function skillCompletions(catalog, prefix, limit = 20) {
386
+ const query = prefix.trim().toLowerCase();
387
+ const items = query
388
+ ? catalog.filter((skill) => [skill.displayId, skill.name, skill.description].join(" ").toLowerCase().includes(query))
389
+ : catalog;
390
+ return items.slice(0, limit).map((skill) => {
391
+ const status = skillStatus(skill);
392
+ return {
393
+ value: skill.id,
394
+ label: skill.displayId,
395
+ description: truncatePlain(status ? `${status} · ${skill.description}` : skill.description, SKILL_COMPLETION_DESCRIPTION_LIMIT),
396
+ };
397
+ });
398
+ }
399
+ function argumentCompletionsFor(source, context) {
400
+ const sessions = context.sessions;
401
+ if (source.name === "resume" && sessions)
402
+ return {
403
+ getArgumentCompletions: (prefix) => sessions(prefix).slice(0, 20),
404
+ };
405
+ if (source.name === "skills")
406
+ return {
407
+ getArgumentCompletions: (prefix) => skillCompletions(context.skills, prefix, 20),
408
+ };
409
+ return {};
410
+ }
411
+ export function slashCompletionCommands(sources, context, options = {}) {
412
+ const entries = sources.flatMap((source) => {
413
+ const entry = (name) => ({
414
+ name,
415
+ description: source.description,
416
+ ...(source.argumentHint ? { argumentHint: source.argumentHint } : {}),
417
+ ...argumentCompletionsFor(source, context),
418
+ });
419
+ return [
420
+ entry(source.name),
421
+ ...(source.aliases ?? []).filter((a) => a !== source.name).map(entry),
422
+ ];
423
+ });
424
+ if (options.skillEntries !== false)
425
+ for (const skill of context.skills)
426
+ entries.push({
427
+ name: `skill:${skill.id}`,
428
+ description: skillSlashDescription(skill),
429
+ });
430
+ return entries;
431
+ }
248
432
  function parseArgs(args) {
249
433
  try {
250
434
  const value = JSON.parse(args);
@@ -350,19 +534,40 @@ export function addItem(state, item) {
350
534
  const withModel = (stats, model) => typeof model === "string" && model && !stats.models.includes(model)
351
535
  ? { ...stats, models: [...stats.models, model] }
352
536
  : stats;
353
- function appendAssistant(state, field, delta) {
537
+ /**
538
+ * Streaming assistant items stamp the thinking interval: the first reasoning delta records
539
+ * `reasoningStartedAt` and every later one refreshes `reasoningDoneAt`, so a completed thought
540
+ * section can show a `Thought · 2.9s` duration. The stamp stops once text arrives (the model
541
+ * moved on to the answer), so the duration measures the visible reasoning span, never the turn.
542
+ */
543
+ const withReasoningStamp = (item, field, at) => {
544
+ if (field !== "reasoning" || item.text)
545
+ return item;
546
+ return { ...item, reasoningStartedAt: item.reasoningStartedAt ?? at, reasoningDoneAt: at };
547
+ };
548
+ function appendAssistant(state, field, delta, at = Date.now()) {
354
549
  const last = state.items.at(-1);
355
550
  if (last?.kind === "assistant" && !last.done)
356
551
  return {
357
552
  ...state,
358
- items: [...state.items.slice(0, -1), { ...last, [field]: last[field] + delta }],
553
+ items: [
554
+ ...state.items.slice(0, -1),
555
+ withReasoningStamp({ ...last, [field]: last[field] + delta }, field, at),
556
+ ],
359
557
  };
360
- return addItem(state, {
558
+ return addItem(state, withReasoningStamp({
361
559
  kind: "assistant",
362
560
  text: field === "text" ? delta : "",
363
561
  reasoning: field === "reasoning" ? delta : "",
364
562
  done: false,
365
- });
563
+ }, field, at));
564
+ }
565
+ /** Approximated thinking interval of a completed reasoning section, when the events carried timestamps. */
566
+ export function reasoningDurationMs(item) {
567
+ const { reasoningStartedAt, reasoningDoneAt } = item;
568
+ if (reasoningStartedAt === undefined || reasoningDoneAt === undefined)
569
+ return undefined;
570
+ return Math.max(0, reasoningDoneAt - reasoningStartedAt);
366
571
  }
367
572
  function updateTool(state, id, update) {
368
573
  const index = state.items.findLastIndex((i) => i.kind === "tool" && i.id === id);
@@ -374,6 +579,98 @@ function updateTool(state, id, update) {
374
579
  return { ...state, items };
375
580
  }
376
581
  export function reduceEvent(state, event) {
582
+ const next = reduceEventItems(state, event);
583
+ const terminal = event.type === "run_completed" ||
584
+ event.type === "run_failed" ||
585
+ event.type === "run_cancelled" ||
586
+ event.type === "run_turns_exceeded";
587
+ if (terminal) {
588
+ const { lastEventAt: _ended, retry: _retry, recovery: _recovery, ...rest } = next;
589
+ return rest;
590
+ }
591
+ return { ...next, lastEventAt: Date.parse(event.timestamp) || Date.now() };
592
+ }
593
+ /** No event for this long: the footer says how long the run has been quiet. */
594
+ export const QUIET_MS = 15_000;
595
+ /** No event for this long: the footer marks the run as possibly stuck. */
596
+ export const STALLED_MS = 60_000;
597
+ /** A short, safe label from tool arguments: a title or a file name, never commands or URLs. */
598
+ function shortArg(args) {
599
+ const input = parseArgs(args);
600
+ if (!input)
601
+ return "";
602
+ const first = Array.isArray(input.inputs) ? input.inputs[0] : {};
603
+ const value = [input.title, input.fileName, input.path, input.file_path, first?.path].find((v) => typeof v === "string" && v.trim() !== "");
604
+ if (!value)
605
+ return "";
606
+ return truncatePlain(oneLine(value.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? ""), 40);
607
+ }
608
+ /** What the live run is doing, from the transcript items and flags the events already update. */
609
+ export function runPhase(view) {
610
+ if (view.compacting)
611
+ return { label: "compacting context", silentByDesign: false };
612
+ if (view.retry)
613
+ return {
614
+ label: `the model did not respond; retrying (${view.retry.attempt}/${view.retry.of})`,
615
+ silentByDesign: false,
616
+ };
617
+ if (view.recovery)
618
+ return {
619
+ label: `the response was cut off; retrying in smaller steps (${view.recovery.attempt}/${view.recovery.of})`,
620
+ silentByDesign: false,
621
+ };
622
+ const tool = view.items.findLast((i) => i.kind === "tool" && i.status !== "ok" && i.status !== "error");
623
+ if (tool?.kind === "tool") {
624
+ const name = tool.name.replace(/^p_[0-9a-f]{10}_/, "");
625
+ const arg = shortArg(tool.args);
626
+ const detail = arg ? ` (${arg})` : "";
627
+ if (tool.status === "approval")
628
+ return {
629
+ label: `waiting for your approval: ${humanizeToolName(name)}`,
630
+ silentByDesign: true,
631
+ };
632
+ if (name === "ask_user_question")
633
+ return { label: "waiting for your answer", silentByDesign: true };
634
+ if (name === "exit_plan")
635
+ return { label: "waiting for your plan decision", silentByDesign: true };
636
+ if (name === "task" || name === "task_wait")
637
+ return { label: "a sub-agent is working", silentByDesign: true };
638
+ if (name === "python_run")
639
+ return { label: `running Python${detail}`, silentByDesign: false };
640
+ if (name === "data_inspect" || name === "data_query")
641
+ return { label: `reading your data${detail}`, silentByDesign: false };
642
+ if (name === "artifact_create")
643
+ return { label: `publishing the artifact${detail}`, silentByDesign: false };
644
+ return { label: `running ${humanizeToolName(name)}${detail}`, silentByDesign: false };
645
+ }
646
+ const last = view.items.at(-1);
647
+ if (last?.kind === "assistant" && !last.done)
648
+ return {
649
+ label: last.text ? "writing the answer" : "thinking",
650
+ silentByDesign: false,
651
+ };
652
+ return { label: "waiting for the model", silentByDesign: false };
653
+ }
654
+ /** Milliseconds of silence worth reporting (0 when the run is busy, or silent by design). */
655
+ export function quietFor(view, now) {
656
+ if (view.lastEventAt === undefined || runPhase(view).silentByDesign)
657
+ return 0;
658
+ const idle = Math.max(0, now - view.lastEventAt);
659
+ return idle >= QUIET_MS ? idle : 0;
660
+ }
661
+ /** What the retried request produced (or the end of the turn) ends the retry label. */
662
+ const RETRY_ENDERS = new Set([
663
+ "text_delta",
664
+ "reasoning_delta",
665
+ "turn_completed",
666
+ "tool_started",
667
+ "compaction_started",
668
+ ]);
669
+ function reduceEventItems(state, event) {
670
+ if ((state.retry || state.recovery) && RETRY_ENDERS.has(event.type)) {
671
+ const { retry: _retry, recovery: _recovery, ...rest } = state;
672
+ return reduceEventItems(rest, event);
673
+ }
377
674
  const d = (event.data ?? {});
378
675
  const at = Date.parse(event.timestamp);
379
676
  switch (event.type) {
@@ -385,9 +682,9 @@ export function reduceEvent(state, event) {
385
682
  stats: withModel({ ...state.stats, runs: state.stats.runs + 1 }, d.model),
386
683
  };
387
684
  case "text_delta":
388
- return appendAssistant(state, "text", String(d.delta ?? ""));
685
+ return appendAssistant(state, "text", String(d.delta ?? ""), at);
389
686
  case "reasoning_delta":
390
- return appendAssistant(state, "reasoning", String(d.delta ?? ""));
687
+ return appendAssistant(state, "reasoning", String(d.delta ?? ""), at);
391
688
  case "turn_completed": {
392
689
  const usage = d.usage;
393
690
  const last = state.items.at(-1);
@@ -409,15 +706,18 @@ export function reduceEvent(state, event) {
409
706
  },
410
707
  };
411
708
  }
412
- case "tool_started":
709
+ case "tool_started": {
710
+ const name = String(d.name ?? "tool");
413
711
  return addItem(state, {
414
712
  kind: "tool",
415
713
  id: String(d.id ?? ""),
416
- name: String(d.name ?? "tool"),
714
+ name,
417
715
  args: typeof d.arguments === "string" ? d.arguments : "",
418
716
  summary: summarizeToolArgs(String(d.name ?? ""), typeof d.arguments === "string" ? d.arguments : ""),
419
717
  status: "running",
718
+ toolKind: toolKindOf(name, typeof d.effect === "string" ? d.effect : undefined),
420
719
  });
720
+ }
421
721
  case "approval_requested":
422
722
  return updateTool(state, d.id, { status: "approval" });
423
723
  case "approval_resolved":
@@ -429,6 +729,9 @@ export function reduceEvent(state, event) {
429
729
  status: d.isError ? "error" : "ok",
430
730
  ...(typeof d.durationMs === "number" ? { durationMs: d.durationMs } : {}),
431
731
  ...(typeof d.preview === "string" ? { preview: d.preview } : {}),
732
+ ...exitCodePart(d.preview),
733
+ ...(isUiBlock(d.ui) ? { ui: d.ui } : {}),
734
+ ...(isImagePart(d.image) ? { image: d.image } : {}),
432
735
  });
433
736
  return {
434
737
  ...next,
@@ -443,7 +746,8 @@ export function reduceEvent(state, event) {
443
746
  }
444
747
  case "run_completed":
445
748
  case "run_failed":
446
- case "run_cancelled": {
749
+ case "run_cancelled":
750
+ case "run_turns_exceeded": {
447
751
  const ended = {
448
752
  ...state,
449
753
  streaming: false,
@@ -455,6 +759,11 @@ export function reduceEvent(state, event) {
455
759
  };
456
760
  if (event.type === "run_failed")
457
761
  return addItem(ended, { kind: "error", text: String(d.error ?? "Run failed") });
762
+ if (event.type === "run_turns_exceeded")
763
+ return addItem(ended, {
764
+ kind: "notice",
765
+ text: "Turn limit reached — the answer may be incomplete. Continue with another prompt or raise limits.maxTurns (/settings → Max turns).",
766
+ });
458
767
  if (event.type === "run_cancelled") {
459
768
  const reason = String(d.error ?? "cancelled");
460
769
  return addItem(ended, {
@@ -464,6 +773,10 @@ export function reduceEvent(state, event) {
464
773
  }
465
774
  return ended;
466
775
  }
776
+ case "truncation_recovery":
777
+ return { ...state, recovery: { attempt: Number(d.attempt) || 1, of: Number(d.of) || 1 } };
778
+ case "request_retry":
779
+ return { ...state, retry: { attempt: Number(d.attempt) || 1, of: Number(d.of) || 1 } };
467
780
  case "compaction_started":
468
781
  return { ...state, compacting: true };
469
782
  case "compaction_completed": {
@@ -505,7 +818,9 @@ export function reduceEvent(state, event) {
505
818
  case "response_truncated":
506
819
  return addItem(state, {
507
820
  kind: "notice",
508
- text: "Response cut by max output tokens — the answer may be incomplete. Raise limits.maxOutputTokens to allow longer answers.",
821
+ text: `Response cut by max output tokens${typeof d.maxOutputTokens === "number"
822
+ ? ` (${d.maxOutputTokens} tokens, ${describeOutputLimitSource(d.source)})`
823
+ : ""} — the answer may be incomplete. Raise limits.maxOutputTokens (/settings → Agent max output tokens) to allow longer answers.`,
509
824
  });
510
825
  case "model_changed":
511
826
  return {
@@ -532,6 +847,455 @@ export function lastAssistantText(items) {
532
847
  }
533
848
  return undefined;
534
849
  }
850
+ /**
851
+ * True when a printable key should copy the last assistant response instead of typing: bound to
852
+ * `c`/`y` (and uppercase) on an EMPTY editor line, while no autocomplete is showing and no turn
853
+ * is running — the same "empty input" convention Enter and Ctrl+D already use. A non-empty input
854
+ * never triggers, so typing `c` or `y` mid-message is unaffected.
855
+ */
856
+ export function editorCopyKey(data, input) {
857
+ return ((data === "c" || data === "C" || data === "y" || data === "Y") &&
858
+ input.text === "" &&
859
+ !input.autocomplete &&
860
+ !input.busy);
861
+ }
862
+ /** True when an unknown event value is a `Node` of a tree UI block. */
863
+ export function isTreeNode(value) {
864
+ if (!value || typeof value !== "object" || Array.isArray(value))
865
+ return false;
866
+ const record = value;
867
+ if (typeof record.label !== "string")
868
+ return false;
869
+ if (record.meta !== undefined && typeof record.meta !== "string")
870
+ return false;
871
+ if (record.children !== undefined && !Array.isArray(record.children))
872
+ return false;
873
+ return record.children === undefined || record.children.every(isTreeNode);
874
+ }
875
+ /** True when an unknown event value is an Alisio `UiBlock` (validated defensively). */
876
+ export function isUiBlock(value) {
877
+ if (!value || typeof value !== "object" || Array.isArray(value))
878
+ return false;
879
+ const block = value;
880
+ switch (block.kind) {
881
+ case "artifact": {
882
+ const a = block.artifact;
883
+ return (!!a &&
884
+ typeof a === "object" &&
885
+ typeof a.id === "string" &&
886
+ typeof a.fileName === "string" &&
887
+ typeof a.kind === "string" &&
888
+ typeof a.bytes === "number" &&
889
+ typeof a.status === "string");
890
+ }
891
+ case "table":
892
+ return (Array.isArray(block.columns) &&
893
+ block.columns.every((c) => typeof c === "string") &&
894
+ Array.isArray(block.rows) &&
895
+ block.rows.every((r) => Array.isArray(r) && r.every((c) => typeof c === "string")) &&
896
+ (block.caption === undefined || typeof block.caption === "string"));
897
+ case "key-value":
898
+ return (Array.isArray(block.entries) &&
899
+ block.entries.every((e) => Array.isArray(e) &&
900
+ e.length === 2 &&
901
+ typeof e[0] === "string" &&
902
+ typeof e[1] === "string") &&
903
+ (block.caption === undefined || typeof block.caption === "string"));
904
+ case "tree":
905
+ return Array.isArray(block.nodes) && block.nodes.every(isTreeNode);
906
+ case "code":
907
+ return (typeof block.code === "string" &&
908
+ (block.lang === undefined || typeof block.lang === "string") &&
909
+ (block.caption === undefined || typeof block.caption === "string"));
910
+ case "markdown":
911
+ return typeof block.text === "string";
912
+ case "diff":
913
+ return optionalStrings(block, ["path", "patch", "before", "after", "lang", "caption"]);
914
+ case "terminal":
915
+ return (typeof block.output === "string" &&
916
+ optionalStrings(block, ["command", "cwd"]) &&
917
+ optionalNumbers(block, ["exitCode", "durationMs"]) &&
918
+ (block.truncated === undefined || typeof block.truncated === "boolean"));
919
+ case "mermaid":
920
+ return typeof block.source === "string" && optionalStrings(block, ["title"]);
921
+ case "math":
922
+ return (typeof block.latex === "string" &&
923
+ (block.display === undefined || typeof block.display === "boolean"));
924
+ case "json":
925
+ return ("value" in block &&
926
+ optionalNumbers(block, ["collapsedDepth"]) &&
927
+ optionalStrings(block, ["caption"]));
928
+ case "test-results":
929
+ return (optionalStrings(block, ["framework"]) &&
930
+ optionalNumbers(block, ["durationMs"]) &&
931
+ Array.isArray(block.suites) &&
932
+ block.suites.every((suite) => isRecord(suite) &&
933
+ typeof suite.name === "string" &&
934
+ optionalStrings(suite, ["file"]) &&
935
+ Array.isArray(suite.cases) &&
936
+ suite.cases.every((c) => isRecord(c) &&
937
+ typeof c.name === "string" &&
938
+ TEST_STATUSES.has(c.status) &&
939
+ optionalStrings(c, ["error"]) &&
940
+ optionalNumbers(c, ["durationMs", "line"]))));
941
+ case "progress":
942
+ return (optionalStrings(block, ["title"]) &&
943
+ Array.isArray(block.steps) &&
944
+ block.steps.every((step) => isRecord(step) &&
945
+ typeof step.label === "string" &&
946
+ PROGRESS_STATUSES.has(step.status) &&
947
+ optionalStrings(step, ["detail"])));
948
+ default:
949
+ return false;
950
+ }
951
+ }
952
+ const TEST_STATUSES = new Set(["passed", "failed", "skipped", "todo"]);
953
+ const PROGRESS_STATUSES = new Set(["pending", "running", "completed", "failed", "cancelled"]);
954
+ const isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
955
+ const optionalStrings = (record, keys) => keys.every((key) => record[key] === undefined || typeof record[key] === "string");
956
+ const optionalNumbers = (record, keys) => keys.every((key) => record[key] === undefined || typeof record[key] === "number");
957
+ /** True when an unknown event value is a `{type:"image"}`-style part (mime + base64 data). */
958
+ export function isImagePart(value) {
959
+ if (!value || typeof value !== "object" || Array.isArray(value))
960
+ return false;
961
+ const record = value;
962
+ return typeof record.mimeType === "string" && typeof record.data === "string";
963
+ }
964
+ /**
965
+ * Display blocks the standard tools attach for the web UI only (spec §8.6): the TUI already
966
+ * shows the same content from the text (output rows, exit line), so rendering them would repeat it.
967
+ */
968
+ const WEB_ONLY_UI = {
969
+ write_file: "diff",
970
+ edit_file: "diff",
971
+ shell: "terminal",
972
+ run_process: "terminal",
973
+ };
974
+ /** Rich (ui/image) parts of a persisted tool result, if any. */
975
+ export function richPartsOf(result, toolName) {
976
+ if (!result)
977
+ return {};
978
+ const skip = toolName !== undefined && Object.hasOwn(WEB_ONLY_UI, toolName)
979
+ ? WEB_ONLY_UI[toolName]
980
+ : undefined;
981
+ const ui = result.content.find((part) => part.type === "ui" && part.block.kind !== skip);
982
+ const image = result.content.find((part) => part.type === "image");
983
+ return {
984
+ ...(ui?.type === "ui" ? { ui: ui.block } : {}),
985
+ ...(image?.type === "image" ? { image: { mimeType: image.mimeType, data: image.data } } : {}),
986
+ };
987
+ }
988
+ // ============================================================================
989
+ // Tool presentation model — the pure extension point for transcript display.
990
+ // Everything the TUI renders beyond a plain `TranscriptItem` is a
991
+ // `TranscriptEntry`, and anything collapsible declares a `FoldCandidate` here,
992
+ // so the renderer, the keybinding and (optionally) mouse clicks stay consistent
993
+ // without importing pi-tui. Future block kinds hook in by extending these
994
+ // shapes, never by special-casing components.
995
+ // ============================================================================
996
+ /** Words keyed by their canonical upper-case spelling that stay acronyms when humanized. */
997
+ const ACRONYM_WORDS = new Set([
998
+ "MCP",
999
+ "HTTP",
1000
+ "HTTPS",
1001
+ "URL",
1002
+ "API",
1003
+ "CLI",
1004
+ "JSON",
1005
+ "CSV",
1006
+ "XML",
1007
+ "HTML",
1008
+ "SQL",
1009
+ "SQLITE",
1010
+ "DNS",
1011
+ "SSH",
1012
+ "IP",
1013
+ "ID",
1014
+ "DB",
1015
+ "UI",
1016
+ "OS",
1017
+ "IDE",
1018
+ "TUI",
1019
+ "PNG",
1020
+ "SVG",
1021
+ "PDF",
1022
+ "YAML",
1023
+ "TOML",
1024
+ "2FA",
1025
+ "V2",
1026
+ ]);
1027
+ /**
1028
+ * `read_file` → `Read File`, `search_text` → `Search Text`, `mcp_devforge_time_diff` →
1029
+ * `MCP · Devforge Time Diff`. Splits on `_`/`-`/`.`/spaces, keeps known acronyms (MCP, HTTP…)
1030
+ * and bare numeric suffixes (`read_file_2` → `Read File 2`). The machine name stays available
1031
+ * dimmed in grouped detail rows and in the `/tools` and `/stats` reports.
1032
+ */
1033
+ export function humanizeToolName(name) {
1034
+ const trimmed = name.trim();
1035
+ let rest = trimmed;
1036
+ let prefix = "";
1037
+ const mcp = /^mcp_/i.exec(rest);
1038
+ if (mcp) {
1039
+ prefix = "MCP · ";
1040
+ rest = rest.slice(mcp[0].length);
1041
+ }
1042
+ const words = rest
1043
+ .split(/[_\-. /]+/)
1044
+ .filter(Boolean)
1045
+ .map((word) => {
1046
+ const upper = word.toUpperCase();
1047
+ if (ACRONYM_WORDS.has(upper))
1048
+ return upper;
1049
+ if (/^\d+$/.test(upper))
1050
+ return upper;
1051
+ return `${word[0]?.toUpperCase() ?? ""}${word.slice(1).toLowerCase()}`;
1052
+ });
1053
+ return words.length ? `${prefix}${words.join(" ")}` : trimmed;
1054
+ }
1055
+ /** Verb and count noun used by grouped batch rows per tool kind. */
1056
+ export const TOOL_KIND_VERB = {
1057
+ read: "Explored",
1058
+ write: "Edited",
1059
+ process: "Ran",
1060
+ mcp: "Queried",
1061
+ internal: "Ran",
1062
+ other: "Called",
1063
+ };
1064
+ export const TOOL_KIND_WORD = {
1065
+ read: "reads",
1066
+ write: "files",
1067
+ process: "commands",
1068
+ mcp: "calls",
1069
+ internal: "tasks",
1070
+ other: "calls",
1071
+ };
1072
+ /**
1073
+ * Capability kind of a tool call. Live events carry the registry `effect` (exact); replayed
1074
+ * history falls back to conservative name heuristics. `mcp_*` is always the mcp kind.
1075
+ */
1076
+ export function toolKindOf(name, effect) {
1077
+ if (name.startsWith("mcp_"))
1078
+ return "mcp";
1079
+ switch (effect) {
1080
+ case "read":
1081
+ case "write":
1082
+ case "process":
1083
+ case "internal":
1084
+ return effect;
1085
+ default:
1086
+ break;
1087
+ }
1088
+ if (/^(read|search|list|git|context|ask|web|fetch)_/.test(name))
1089
+ return "read";
1090
+ if (/^(write|edit|append|delete|rename|move|mkdir|patch)_/.test(name))
1091
+ return "write";
1092
+ if (/^(run|shell|execute)/.test(name))
1093
+ return "process";
1094
+ if (name === "task" || name.startsWith("task_"))
1095
+ return "internal";
1096
+ return "other";
1097
+ }
1098
+ /** Terminal-preview caps for collapsed rows (same values the renderer always used). */
1099
+ export const OUTPUT_PREVIEW_LINES_OK = 3;
1100
+ export const OUTPUT_PREVIEW_LINES_ERROR = 6;
1101
+ export const previewLinesFor = (status) => status === "error" ? OUTPUT_PREVIEW_LINES_ERROR : OUTPUT_PREVIEW_LINES_OK;
1102
+ /** Preview rows as the renderer shows them: blank lines dropped, tabs kept for the caller to expand. */
1103
+ export const previewRows = (preview) => preview.split("\n").filter((line) => line.trim());
1104
+ /** True when the preview exceeds the collapsed cap (the row becomes a collapsible "output"). */
1105
+ export function previewTruncated(preview, status) {
1106
+ return !!preview && previewRows(preview).length > previewLinesFor(status);
1107
+ }
1108
+ /**
1109
+ * Exit code derived safely from a text preview. `run_process`/`shell`/`search_text` return
1110
+ * `{stdout, stderr, exitCode, truncated}` JSON; read-effect tools may carry an appended
1111
+ * `\n<instructions>…` block, retried on the JSON prefix. Any other shape yields nothing.
1112
+ */
1113
+ export function exitCodeOf(preview) {
1114
+ if (!preview)
1115
+ return undefined;
1116
+ const codeOf = (text) => {
1117
+ try {
1118
+ const value = JSON.parse(text);
1119
+ const code = value && typeof value === "object" && !Array.isArray(value)
1120
+ ? value.exitCode
1121
+ : undefined;
1122
+ return typeof code === "number" ? code : undefined;
1123
+ }
1124
+ catch {
1125
+ return undefined;
1126
+ }
1127
+ };
1128
+ const direct = codeOf(preview);
1129
+ if (direct !== undefined)
1130
+ return direct;
1131
+ const cut = preview.indexOf("\n<instructions>");
1132
+ if (cut > 0)
1133
+ return codeOf(preview.slice(0, cut));
1134
+ return undefined;
1135
+ }
1136
+ const exitCodePart = (preview) => {
1137
+ if (typeof preview !== "string")
1138
+ return {};
1139
+ const code = exitCodeOf(preview);
1140
+ return code === undefined ? {} : { exitCode: code };
1141
+ };
1142
+ export function toolItemView(item) {
1143
+ return {
1144
+ id: item.id,
1145
+ name: item.name,
1146
+ humanName: humanizeToolName(item.name),
1147
+ kind: item.toolKind ?? toolKindOf(item.name),
1148
+ summary: item.summary,
1149
+ status: item.status,
1150
+ ...(item.durationMs !== undefined ? { durationMs: item.durationMs } : {}),
1151
+ ...(item.preview !== undefined ? { preview: item.preview } : {}),
1152
+ ...(item.ui ? { ui: item.ui } : {}),
1153
+ ...(item.image ? { image: item.image } : {}),
1154
+ ...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
1155
+ };
1156
+ }
1157
+ /**
1158
+ * Consecutive tool calls of the same kind whose statuses are ALL terminal (ok/error) merge into
1159
+ * one `group` entry; running/approval calls always stay singleton so their spinner and live state
1160
+ * stay visible. A kind change splits the run (`read` never merges with `shell`). Order is
1161
+ * preserved; every entry carries its index within `items` (fold keys for assistant items need
1162
+ * it). One linear pass — called on state changes only, never per frame.
1163
+ */
1164
+ export function groupToolEntries(items) {
1165
+ const out = [];
1166
+ let run = [];
1167
+ let runKind;
1168
+ const flush = () => {
1169
+ if (!run.length)
1170
+ return;
1171
+ const views = run.map((r) => toolItemView(r.item));
1172
+ const first = views[0];
1173
+ const groupable = views.length > 1 &&
1174
+ first !== undefined &&
1175
+ views.every((v) => v.kind === runKind) &&
1176
+ views.every((v) => v.status === "ok" || v.status === "error");
1177
+ if (groupable) {
1178
+ const sameName = views.every((v) => v.name === first.name);
1179
+ out.push({
1180
+ entry: {
1181
+ kind: "group",
1182
+ group: {
1183
+ id: first.id,
1184
+ members: views,
1185
+ kind: first.kind,
1186
+ status: views.some((v) => v.status === "error") ? "error" : "ok",
1187
+ totalMs: views.reduce((sum, v) => sum + (v.durationMs ?? 0), 0),
1188
+ label: sameName ? first.humanName : TOOL_KIND_VERB[first.kind],
1189
+ word: TOOL_KIND_WORD[first.kind],
1190
+ },
1191
+ },
1192
+ at: run[0]?.at ?? 0,
1193
+ });
1194
+ }
1195
+ else {
1196
+ for (const r of run)
1197
+ out.push({ entry: r.item, at: r.at });
1198
+ }
1199
+ run = [];
1200
+ runKind = undefined;
1201
+ };
1202
+ items.forEach((item, at) => {
1203
+ if (item.kind === "tool") {
1204
+ const kind = item.toolKind ?? toolKindOf(item.name);
1205
+ if (runKind === undefined) {
1206
+ runKind = kind;
1207
+ run.push({ item, at });
1208
+ }
1209
+ else if (runKind === kind) {
1210
+ run.push({ item, at });
1211
+ }
1212
+ else {
1213
+ flush();
1214
+ runKind = kind;
1215
+ run.push({ item, at });
1216
+ }
1217
+ }
1218
+ else {
1219
+ flush();
1220
+ out.push({ entry: item, at });
1221
+ }
1222
+ });
1223
+ flush();
1224
+ return out;
1225
+ }
1226
+ /** Content identity of a group (fold/diff purposes): member ids, statuses and durations. */
1227
+ export function groupIdentity(group) {
1228
+ return `${group.id}|${group.members.length}|${group.members
1229
+ .map((m) => `${m.id}:${m.status}:${m.durationMs ?? ""}`)
1230
+ .join(",")}`;
1231
+ }
1232
+ /** Cap for the reasoning text when expanded; longer sections show a truncation note. */
1233
+ export const REASONING_EXPAND_MAX_LINES = 40;
1234
+ /**
1235
+ * The fold contract of one transcript entry: nothing (not collapsible), or a candidate with its
1236
+ * default state. `at` is the entry's index within the ORIGINAL items array (assistant fold keys
1237
+ * are stable because transcript items are append-only).
1238
+ */
1239
+ export function foldCandidateOf(entry, at) {
1240
+ if (entry.kind === "assistant") {
1241
+ const reasoning = entry.reasoning.trim();
1242
+ if (!reasoning || !(entry.done || entry.text))
1243
+ return undefined;
1244
+ return { key: `a:${at}`, kind: "reasoning", defaultExpanded: false };
1245
+ }
1246
+ if (entry.kind === "group")
1247
+ return { key: `g:${entry.group.id}`, kind: "group", defaultExpanded: false };
1248
+ if (entry.kind === "tool") {
1249
+ if (entry.status === "running" || entry.status === "approval")
1250
+ return undefined;
1251
+ // Rich blocks keep their native rendering (tables/trees/images are already bounded);
1252
+ // only plain text output folds when it exceeds the collapsed cap.
1253
+ if (entry.ui || entry.image)
1254
+ return undefined;
1255
+ if (previewTruncated(entry.preview, entry.status))
1256
+ return { key: `t:${entry.id}`, kind: "output", defaultExpanded: false };
1257
+ return undefined;
1258
+ }
1259
+ return undefined;
1260
+ }
1261
+ /** Stable key of a transcript entry (used by the component sync to reuse row instances). */
1262
+ export function entryKeyOf(entry, at) {
1263
+ switch (entry.kind) {
1264
+ case "assistant":
1265
+ return `a:${at}`;
1266
+ case "group":
1267
+ return `g:${entry.group.id}`;
1268
+ case "tool":
1269
+ return `t:${entry.id}`;
1270
+ case "user":
1271
+ return `u:${at}`;
1272
+ case "notice":
1273
+ return `n:${at}`;
1274
+ case "info":
1275
+ return `i:${at}`;
1276
+ case "error":
1277
+ return `e:${at}`;
1278
+ }
1279
+ }
1280
+ /** Every fold candidate of the transcript in display order; the keybinding targets the last one. */
1281
+ export function foldCandidates(items) {
1282
+ const candidates = [];
1283
+ for (const { entry, at } of groupToolEntries(items)) {
1284
+ const candidate = foldCandidateOf(entry, at);
1285
+ if (candidate)
1286
+ candidates.push(candidate);
1287
+ }
1288
+ return candidates;
1289
+ }
1290
+ /**
1291
+ * True when a printable key should toggle the nearest collapsible transcript row instead of
1292
+ * typing: bound to `x`/`X` on an EMPTY editor line (no autocomplete), the same "empty input"
1293
+ * convention Enter, Ctrl+D and `c`/`y` already use. Unlike copy, it also works while a turn runs
1294
+ * (the nearest candidate may still exist mid-stream). A non-empty input never triggers.
1295
+ */
1296
+ export function foldToggleKey(data, input) {
1297
+ return (data === "x" || data === "X") && input.text === "" && !input.autocomplete;
1298
+ }
535
1299
  /** Rebuilds transcript items from persisted history (used by /resume). */
536
1300
  export function itemsFromHistory(messages) {
537
1301
  const items = [];
@@ -547,6 +1311,11 @@ export function itemsFromHistory(messages) {
547
1311
  items.push({ kind: "assistant", text: m.text, reasoning: "", done: true });
548
1312
  for (const c of m.calls) {
549
1313
  const r = results.get(c.id);
1314
+ const preview = r?.result.content
1315
+ .filter((p) => p.type === "text")
1316
+ .map((x) => x.text)
1317
+ .join("\n")
1318
+ .slice(0, 2_000);
550
1319
  items.push({
551
1320
  kind: "tool",
552
1321
  id: c.id,
@@ -554,10 +1323,10 @@ export function itemsFromHistory(messages) {
554
1323
  args: c.arguments,
555
1324
  summary: summarizeToolArgs(c.name, c.arguments),
556
1325
  status: r?.result.isError ? "error" : "ok",
557
- preview: r?.result.content
558
- .map((x) => x.text)
559
- .join("\n")
560
- .slice(0, 2_000),
1326
+ preview,
1327
+ toolKind: toolKindOf(c.name),
1328
+ ...exitCodePart(preview),
1329
+ ...richPartsOf(r?.result, c.name),
561
1330
  });
562
1331
  }
563
1332
  }