@bubblebrain-ai/bubble 0.0.3 → 0.0.5

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 (97) hide show
  1. package/README.md +8 -3
  2. package/dist/agent/budget-ledger.d.ts +20 -0
  3. package/dist/agent/budget-ledger.js +51 -0
  4. package/dist/agent/execution-governor.d.ts +14 -0
  5. package/dist/agent/execution-governor.js +172 -14
  6. package/dist/agent/profiles.d.ts +59 -0
  7. package/dist/agent/profiles.js +460 -0
  8. package/dist/agent/subagent-control.d.ts +52 -0
  9. package/dist/agent/subagent-control.js +38 -0
  10. package/dist/agent/task-classifier.d.ts +1 -1
  11. package/dist/agent/task-classifier.js +60 -0
  12. package/dist/agent/tool-intent.d.ts +14 -0
  13. package/dist/agent/tool-intent.js +125 -1
  14. package/dist/agent.d.ts +60 -1
  15. package/dist/agent.js +606 -53
  16. package/dist/bin.d.ts +2 -0
  17. package/dist/bin.js +45 -0
  18. package/dist/context/budget.js +1 -0
  19. package/dist/context/compact-llm.js +7 -6
  20. package/dist/context/compact.js +6 -6
  21. package/dist/context/projector.d.ts +3 -3
  22. package/dist/context/projector.js +32 -18
  23. package/dist/context/prune.d.ts +2 -2
  24. package/dist/context/prune.js +1 -4
  25. package/dist/main.d.ts +1 -1
  26. package/dist/main.js +13 -6
  27. package/dist/mcp/manager.js +1 -0
  28. package/dist/orchestrator/default-hooks.js +92 -1
  29. package/dist/orchestrator/hooks.d.ts +10 -0
  30. package/dist/prompt/compose.d.ts +1 -0
  31. package/dist/prompt/compose.js +20 -1
  32. package/dist/prompt/environment.js +21 -2
  33. package/dist/prompt/provider-prompts/deepseek.d.ts +1 -0
  34. package/dist/prompt/provider-prompts/deepseek.js +8 -0
  35. package/dist/prompt/provider-prompts/glm.d.ts +1 -0
  36. package/dist/prompt/provider-prompts/glm.js +7 -0
  37. package/dist/prompt/provider-prompts/kimi.d.ts +1 -0
  38. package/dist/prompt/provider-prompts/kimi.js +7 -0
  39. package/dist/prompt/reminders.d.ts +5 -1
  40. package/dist/prompt/reminders.js +51 -6
  41. package/dist/prompt/runtime.d.ts +1 -1
  42. package/dist/prompt/runtime.js +16 -3
  43. package/dist/prompt/task-reminders.d.ts +2 -0
  44. package/dist/prompt/task-reminders.js +56 -0
  45. package/dist/provider-artifacts.d.ts +7 -0
  46. package/dist/provider-artifacts.js +60 -0
  47. package/dist/provider.d.ts +6 -7
  48. package/dist/provider.js +77 -15
  49. package/dist/session-log.js +3 -1
  50. package/dist/slash-commands/commands.js +2 -3
  51. package/dist/system-prompt.d.ts +2 -0
  52. package/dist/tools/agent-lifecycle.d.ts +6 -0
  53. package/dist/tools/agent-lifecycle.js +355 -0
  54. package/dist/tools/bash.js +12 -7
  55. package/dist/tools/edit-apply.d.ts +25 -0
  56. package/dist/tools/edit-apply.js +197 -0
  57. package/dist/tools/edit.js +64 -52
  58. package/dist/tools/exit-plan-mode.js +3 -1
  59. package/dist/tools/file-mutation-queue.d.ts +1 -0
  60. package/dist/tools/file-mutation-queue.js +32 -0
  61. package/dist/tools/glob.js +1 -0
  62. package/dist/tools/grep.js +1 -0
  63. package/dist/tools/index.d.ts +1 -1
  64. package/dist/tools/index.js +3 -3
  65. package/dist/tools/lsp.js +2 -0
  66. package/dist/tools/memory.js +2 -0
  67. package/dist/tools/question.js +2 -0
  68. package/dist/tools/read.js +1 -0
  69. package/dist/tools/skill.js +1 -0
  70. package/dist/tools/task.js +1 -0
  71. package/dist/tools/todo.js +1 -0
  72. package/dist/tools/tool-search.js +2 -1
  73. package/dist/tools/web-fetch.js +1 -0
  74. package/dist/tools/web-search.js +1 -0
  75. package/dist/tools/write.js +10 -1
  76. package/dist/tui/display-history.d.ts +8 -1
  77. package/dist/tui/image-paste.d.ts +41 -0
  78. package/dist/tui/image-paste.js +217 -0
  79. package/dist/tui/markdown-inline.d.ts +22 -0
  80. package/dist/tui/markdown-inline.js +68 -0
  81. package/dist/tui/render-signature.d.ts +1 -0
  82. package/dist/tui/render-signature.js +7 -0
  83. package/dist/tui/run.js +814 -269
  84. package/dist/tui/tool-renderers/fallback.d.ts +2 -0
  85. package/dist/tui/tool-renderers/fallback.js +75 -0
  86. package/dist/tui/tool-renderers/registry.d.ts +3 -0
  87. package/dist/tui/tool-renderers/registry.js +11 -0
  88. package/dist/tui/tool-renderers/subagent.d.ts +2 -0
  89. package/dist/tui/tool-renderers/subagent.js +114 -0
  90. package/dist/tui/tool-renderers/types.d.ts +36 -0
  91. package/dist/tui/tool-renderers/types.js +1 -0
  92. package/dist/tui/tool-renderers/write-preview.d.ts +12 -0
  93. package/dist/tui/tool-renderers/write-preview.js +22 -0
  94. package/dist/tui/tool-renderers/write.d.ts +6 -0
  95. package/dist/tui/tool-renderers/write.js +82 -0
  96. package/dist/types.d.ts +90 -10
  97. package/package.json +3 -3
package/dist/tui/run.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BoxRenderable, CodeRenderable, createCliRenderer, DiffRenderable, getTreeSitterClient, MarkdownRenderable, LineNumberRenderable, StyledText, RGBA, fg, bg, bold, dim, TextAttributes, TextRenderable, } from "@opentui/core";
1
+ import { BoxRenderable, CodeRenderable, createCliRenderer, DiffRenderable, getTreeSitterClient, MarkdownRenderable, LineNumberRenderable, StyledText, RGBA, fg, bg, bold, italic, dim, TextAttributes, TextRenderable, } from "@opentui/core";
2
2
  import { createComponent, createElement, insert, render, spread, useKeyboard, useRenderer, useSelectionHandler, useTerminalDimensions, } from "@opentui/solid";
3
3
  import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
4
4
  import { AgentAbortError } from "../agent.js";
@@ -13,6 +13,11 @@ import { sidebarMcpRowsFromStates, renderMcpRowMarker } from "./sidebar-mcp.js";
13
13
  import { expandAtMentions, filterFileSuggestions, findAtContext, listProjectFiles } from "./file-mentions.js";
14
14
  import { compactDisplayMessages } from "./display-history.js";
15
15
  import { createMarkdownSyntaxStyle, createSubtleMarkdownSyntaxStyle } from "./markdown-theme.js";
16
+ import { markdownInlineSegments } from "./markdown-inline.js";
17
+ import { hashString } from "./render-signature.js";
18
+ import { findToolRenderer } from "./tool-renderers/registry.js";
19
+ import { writeToolKey } from "./tool-renderers/write.js";
20
+ import { formatWritePreview, isWritePreviewTool } from "./tool-renderers/write-preview.js";
16
21
  import { getNextPermissionMode, PERMISSION_MODE_INFO } from "../permission/mode.js";
17
22
  import { getContextBudget } from "../context/budget.js";
18
23
  import { getLspService } from "../lsp/index.js";
@@ -20,6 +25,7 @@ import { inferBashPrefix } from "../approval/session-cache.js";
20
25
  import { createFrames } from "./opencode-spinner.js";
21
26
  import { copyTextToClipboard } from "./clipboard.js";
22
27
  import { readGitSidebarState } from "./sidebar-state.js";
28
+ import { buildImageContentPartsFromLabels, imageAttachmentLabelPattern, resolveComposerImagePaths, resolveImageInput, } from "./image-paste.js";
23
29
  import { isModeCycleKeyEvent, isModeCycleSequence, isModifiedEnterSequence, PROMPT_TEXTAREA_KEYBINDINGS, } from "./prompt-keybindings.js";
24
30
  import { keyNameFromEvent, keyNameFromSequence } from "./global-key-router.js";
25
31
  const treeSitterClient = getTreeSitterClient();
@@ -54,6 +60,7 @@ const DEFAULT_THEME = {
54
60
  messageAssistantText: "#eeeeee",
55
61
  messageAssistantAccent: "#fab283",
56
62
  messageThinkingText: "#8b949e",
63
+ messageThinkingContentText: "#6e7681",
57
64
  messageThinkingBorder: "#282828",
58
65
  toolText: "#a6acb8",
59
66
  toolPending: "#fab283",
@@ -102,10 +109,31 @@ const QUESTION_MAX_OPTIONS = 10;
102
109
  const QUESTION_MAX_CONFIRM_ROWS = 3;
103
110
  const QUESTION_PANEL_MIN_HEIGHT = 9;
104
111
  const HOME_LOGO = [
105
- " /\\_/\\ █▀▀▄ █ █ █▀▀▄ █▀▀▄ █ █▀▀",
106
- "( o.o ) █▀▀▄ █ █ █▀▀▄ █▀▀▄ █ █▀▀",
107
- " > ^ < ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀▀ ▀▀▀▀",
112
+ { text: " /\\___/\\ ", tone: "primary" },
113
+ { text: "( ◕ ◕ )", tone: "primary" },
114
+ { text: "( ω )", tone: "warning" },
115
+ { text: " (\")_(\") ", tone: "warning" },
116
+ { text: "", tone: "primary" },
117
+ { text: "· ◌ ○ ◯ ·", tone: "textMuted" },
118
+ { text: "", tone: "primary" },
119
+ { text: "██████╗ ██╗ ██╗██████╗ ██████╗ ██╗ ███████╗", tone: "primary" },
120
+ { text: "██╔══██╗██║ ██║██╔══██╗██╔══██╗██║ ██╔════╝", tone: "primary" },
121
+ { text: "██████╔╝██║ ██║██████╔╝██████╔╝██║ █████╗ ", tone: "warning" },
122
+ { text: "██╔══██╗██║ ██║██╔══██╗██╔══██╗██║ ██╔══╝ ", tone: "warning" },
123
+ { text: "██████╔╝╚██████╔╝██████╔╝██████╔╝███████╗███████╗", tone: "accent" },
124
+ { text: "╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝", tone: "accent" },
125
+ { text: "", tone: "primary" },
126
+ { text: "── your bubble coding companion ──", tone: "secondary" },
108
127
  ];
128
+ function homeLogoColor(tone) {
129
+ switch (tone) {
130
+ case "primary": return theme.primary;
131
+ case "warning": return theme.warning;
132
+ case "accent": return theme.accent;
133
+ case "secondary": return theme.secondary;
134
+ case "textMuted": return theme.textMuted;
135
+ }
136
+ }
109
137
  const HOME_TIPS = [
110
138
  "Type @ followed by a filename to attach file context",
111
139
  "Press Shift+Tab to cycle Build, Plan, and Bypass modes",
@@ -263,6 +291,10 @@ function OpenTuiApp(props) {
263
291
  .filter((message) => message.role === "user" && message.content !== "(multimedia)")
264
292
  .map((message) => message.content)
265
293
  .slice(-PROMPT_HISTORY_LIMIT);
294
+ let nextImageAttachmentIndex = nextImageLabelIndex(displayMessages);
295
+ const pendingImageAttachments = new Map();
296
+ let composerImageResolutionSeq = 0;
297
+ let applyingComposerImageReplacement = false;
266
298
  let promptHistoryIndex;
267
299
  let promptHistoryDraft = "";
268
300
  const [isRunning, setIsRunning] = createSignal(false);
@@ -313,20 +345,16 @@ function OpenTuiApp(props) {
313
345
  let rootBox;
314
346
  let sidebarShell;
315
347
  let transcriptHost;
316
- const transcriptState = { entries: [], expandedThinking: new Set(), expandedCompactions: new Set() };
348
+ const transcriptState = {
349
+ entries: [],
350
+ expandedCompactions: new Set(),
351
+ expandedWrites: new Set(),
352
+ defaultWritesExpanded: false,
353
+ };
317
354
  let dock;
318
355
  let homeComposerShell;
319
356
  let sessionComposerShell;
320
357
  const promptScannerSyncs = new Set();
321
- const thinkingSpinnerFrames = createFrames({
322
- width: 4,
323
- color: theme.primary,
324
- style: "blocks",
325
- inactiveFactor: 0.45,
326
- minAlpha: 0.25,
327
- });
328
- let thinkingSpinnerFrameIndex = 0;
329
- let thinkingSpinnerTimer;
330
358
  let approvalRoot;
331
359
  let approvalHeaderTitle;
332
360
  let approvalMetaIcon;
@@ -1519,7 +1547,6 @@ function OpenTuiApp(props) {
1519
1547
  for (const timer of questionSyncTimers)
1520
1548
  clearTimeout(timer);
1521
1549
  questionSyncTimers.clear();
1522
- stopThinkingSpinner();
1523
1550
  if (props.options.planHandlerRef)
1524
1551
  props.options.planHandlerRef.current = undefined;
1525
1552
  if (props.options.approvalHandlerRef)
@@ -1663,6 +1690,16 @@ function OpenTuiApp(props) {
1663
1690
  event.preventDefault?.();
1664
1691
  return true;
1665
1692
  }
1693
+ if (event.ctrl && name === "t" && !picker) {
1694
+ toggleThinkingVisibility();
1695
+ event.preventDefault?.();
1696
+ return true;
1697
+ }
1698
+ if (event.ctrl && name === "o" && !picker) {
1699
+ toggleVisibleWriteBlocks();
1700
+ event.preventDefault?.();
1701
+ return true;
1702
+ }
1666
1703
  if (routeModalKey(event))
1667
1704
  return true;
1668
1705
  if (cycleModeFromKey(event))
@@ -1683,12 +1720,12 @@ function OpenTuiApp(props) {
1683
1720
  plan: pendingPlan()?.plan,
1684
1721
  selectedOption: approvalOptionIdx(),
1685
1722
  showThinking: showThinking(),
1686
- onToggleThinking: (key) => {
1687
- if (transcriptState.expandedThinking.has(key)) {
1688
- transcriptState.expandedThinking.delete(key);
1723
+ onToggleWrite: (key) => {
1724
+ if (transcriptState.expandedWrites.has(key)) {
1725
+ transcriptState.expandedWrites.delete(key);
1689
1726
  }
1690
1727
  else {
1691
- transcriptState.expandedThinking.add(key);
1728
+ transcriptState.expandedWrites.add(key);
1692
1729
  }
1693
1730
  syncSessionMessages();
1694
1731
  },
@@ -1703,52 +1740,61 @@ function OpenTuiApp(props) {
1703
1740
  },
1704
1741
  };
1705
1742
  }
1706
- function syncSessionMessages(messages = currentTranscriptMessages(streamingDisplay)) {
1707
- if (!transcriptHost)
1743
+ function toggleThinkingVisibility() {
1744
+ if (!currentTranscriptMessages(streamingDisplay).some((message) => !!message.reasoning?.trim())) {
1745
+ setNotice("No thinking blocks to toggle");
1708
1746
  return;
1709
- updateTranscriptHost(transcriptHost, transcriptState, messages, transcriptOptions(), props.syntaxStyle, props.subtleSyntaxStyle);
1710
- syncThinkingSpinner();
1711
- syncPromptSurfaces();
1712
- }
1713
- function renderThinkingSpinnerFrame() {
1714
- const frame = thinkingSpinnerFrames[thinkingSpinnerFrameIndex % thinkingSpinnerFrames.length] ?? "";
1715
- let rendered = false;
1716
- for (const entry of transcriptState.entries) {
1717
- const ref = entry.refs.reasoningToggleText;
1718
- if (!ref || !entry.refs.reasoningStreaming)
1719
- continue;
1720
- ref.content = thinkingToggleLabel(entry.refs.reasoningExpanded === true, true, frame);
1721
- ref.requestRender();
1722
- rendered = true;
1723
- }
1724
- if (rendered) {
1725
- transcriptHost?.requestRender();
1726
- rootBox?.requestRender();
1727
1747
  }
1748
+ setShowThinking((prev) => {
1749
+ const next = !prev;
1750
+ setNotice(next ? "Thinking blocks visible" : "Thinking blocks hidden");
1751
+ return next;
1752
+ });
1753
+ redrawTranscript();
1728
1754
  }
1729
- function stopThinkingSpinner() {
1730
- if (thinkingSpinnerTimer)
1731
- clearInterval(thinkingSpinnerTimer);
1732
- thinkingSpinnerTimer = undefined;
1733
- thinkingSpinnerFrameIndex = 0;
1734
- }
1735
- function syncThinkingSpinner() {
1736
- const hasStreamingThinking = transcriptState.entries.some((entry) => !!entry.refs.reasoningToggleText && entry.refs.reasoningStreaming === true);
1737
- if (!hasStreamingThinking) {
1738
- stopThinkingSpinner();
1755
+ function toggleVisibleWriteBlocks() {
1756
+ const keys = collectVisibleWriteKeys();
1757
+ if (!keys.length) {
1758
+ setNotice("No write previews to toggle");
1739
1759
  return;
1740
1760
  }
1741
- renderThinkingSpinnerFrame();
1742
- if (thinkingSpinnerTimer)
1761
+ const shouldExpand = keys.some((key) => !transcriptState.expandedWrites.has(key));
1762
+ transcriptState.defaultWritesExpanded = shouldExpand;
1763
+ for (const key of keys) {
1764
+ if (shouldExpand)
1765
+ transcriptState.expandedWrites.add(key);
1766
+ else
1767
+ transcriptState.expandedWrites.delete(key);
1768
+ }
1769
+ setNotice(shouldExpand ? "Write previews expanded" : "Write previews collapsed");
1770
+ syncSessionMessages();
1771
+ }
1772
+ function collectVisibleWriteKeys() {
1773
+ const messages = currentTranscriptMessages(streamingDisplay)
1774
+ .filter((message) => hasRenderableMessage(message, showThinking()));
1775
+ const keys = [];
1776
+ for (const [index, message] of messages.entries()) {
1777
+ const messageKey = transcriptMessageKey(message, index);
1778
+ for (const tool of message.toolCalls ?? []) {
1779
+ if (isWritePreviewTool(tool)) {
1780
+ keys.push(writeToolKey(messageKey, tool));
1781
+ }
1782
+ }
1783
+ }
1784
+ return keys;
1785
+ }
1786
+ function syncSessionMessages(messages = currentTranscriptMessages(streamingDisplay)) {
1787
+ if (!transcriptHost)
1743
1788
  return;
1744
- thinkingSpinnerTimer = setInterval(() => {
1745
- thinkingSpinnerFrameIndex = (thinkingSpinnerFrameIndex + 1) % thinkingSpinnerFrames.length;
1746
- renderThinkingSpinnerFrame();
1747
- }, PROMPT_SCANNER_INTERVAL_MS);
1789
+ updateTranscriptHost(transcriptHost, transcriptState, messages, transcriptOptions(), props.syntaxStyle, props.subtleSyntaxStyle);
1790
+ syncPromptSurfaces();
1748
1791
  }
1749
1792
  function redrawTranscript(extra, baseMessages = displayMessages) {
1750
- const shouldFollow = shouldFollowTranscriptBeforeUpdate();
1751
1793
  streamingDisplay = extra;
1794
+ renderTranscriptNow(streamingDisplay, baseMessages);
1795
+ }
1796
+ function renderTranscriptNow(extra, baseMessages = displayMessages) {
1797
+ const shouldFollow = shouldFollowTranscriptBeforeUpdate();
1752
1798
  const nextMessages = compactDisplayMessages(extra ? [...baseMessages, extra] : baseMessages);
1753
1799
  syncSessionMessages(nextMessages);
1754
1800
  rootBox?.requestRender();
@@ -2728,6 +2774,9 @@ function OpenTuiApp(props) {
2728
2774
  function onPromptContentChange(value) {
2729
2775
  const nextValue = typeof value === "string" ? value : readPromptText();
2730
2776
  promptText = nextValue;
2777
+ if (!applyingComposerImageReplacement) {
2778
+ void applyComposerImagePathReplacement(nextValue);
2779
+ }
2731
2780
  if (promptHistoryIndex !== undefined
2732
2781
  && nextValue !== (promptHistory[promptHistoryIndex] ?? "")) {
2733
2782
  resetPromptHistoryBrowse();
@@ -2995,8 +3044,62 @@ function OpenTuiApp(props) {
2995
3044
  setPromptText(`/${skillName} `);
2996
3045
  redrawDock();
2997
3046
  }
3047
+ async function applyComposerImagePathReplacement(snapshot) {
3048
+ const seq = ++composerImageResolutionSeq;
3049
+ const result = await resolveComposerImagePaths(snapshot, { labelStart: nextImageAttachmentIndex });
3050
+ if (seq !== composerImageResolutionSeq)
3051
+ return;
3052
+ if (result.attachments.length === 0)
3053
+ return;
3054
+ if ((readPromptText() || promptText) !== snapshot)
3055
+ return;
3056
+ for (const attachment of result.attachments) {
3057
+ pendingImageAttachments.set(attachment.label, attachment);
3058
+ }
3059
+ nextImageAttachmentIndex = Math.max(nextImageAttachmentIndex, result.nextLabelIndex);
3060
+ applyingComposerImageReplacement = true;
3061
+ try {
3062
+ setPromptText(result.text);
3063
+ }
3064
+ finally {
3065
+ applyingComposerImageReplacement = false;
3066
+ }
3067
+ }
3068
+ async function expandTextParts(parts) {
3069
+ const expandedParts = [];
3070
+ for (const part of parts) {
3071
+ if (part.type !== "text") {
3072
+ expandedParts.push(part);
3073
+ continue;
3074
+ }
3075
+ const expansion = await expandAtMentions(part.text, props.args.cwd);
3076
+ if (expansion.missing.length)
3077
+ addMessage("error", `Could not resolve @mention: ${expansion.missing.join(", ")}`);
3078
+ for (const skipped of expansion.skipped)
3079
+ addMessage("error", `Skipped @${skipped.path}: ${skipped.reason}`);
3080
+ expandedParts.push({ type: "text", text: expansion.text });
3081
+ }
3082
+ return expandedParts;
3083
+ }
2998
3084
  async function handleInput(input) {
2999
3085
  setNotice("");
3086
+ const labeledInput = buildImageContentPartsFromLabels(input, pendingImageAttachments);
3087
+ if (labeledInput.actualInput) {
3088
+ await runAgentInput(await expandTextParts(labeledInput.actualInput), labeledInput.displayInput);
3089
+ for (const label of labeledInput.usedLabels)
3090
+ pendingImageAttachments.delete(label);
3091
+ return;
3092
+ }
3093
+ const imageInput = await resolveImageInput(input, { labelStart: nextImageAttachmentIndex });
3094
+ for (const error of imageInput.errors)
3095
+ addMessage("error", `Skipped image: ${error}`);
3096
+ if (imageInput.attachments.length > 0) {
3097
+ await runAgentInput(await expandTextParts(imageInput.actualInput), imageInput.displayInput);
3098
+ nextImageAttachmentIndex += imageInput.attachments.length;
3099
+ return;
3100
+ }
3101
+ if (imageInput.imagePathCount > 0)
3102
+ return;
3000
3103
  if (input.startsWith("/")) {
3001
3104
  const skillInvocation = parseSkillInvocation(input, skills);
3002
3105
  if (skillInvocation) {
@@ -3016,12 +3119,7 @@ function OpenTuiApp(props) {
3016
3119
  }
3017
3120
  async function executeSlash(input) {
3018
3121
  if (/^\/(?:thinking|toggle-thinking)(?:\s|$)/.test(input.trim())) {
3019
- setShowThinking((prev) => {
3020
- const next = !prev;
3021
- setNotice(next ? "Thinking blocks visible" : "Thinking blocks hidden");
3022
- return next;
3023
- });
3024
- redrawTranscript();
3122
+ toggleThinkingVisibility();
3025
3123
  return true;
3026
3124
  }
3027
3125
  const wasHomeSurfaceActive = isHomeSurfaceActive();
@@ -3071,7 +3169,9 @@ function OpenTuiApp(props) {
3071
3169
  const isCompactResult = result.startsWith("✓ Compaction complete");
3072
3170
  if (isCompactResult) {
3073
3171
  setNotice(result);
3074
- redrawTranscript();
3172
+ displayMessages = reconstructDisplayMessages(props.agent.messages);
3173
+ streamingDisplay = undefined;
3174
+ redrawTranscript(undefined, displayMessages);
3075
3175
  setTimeout(() => setNotice(""), 4000);
3076
3176
  }
3077
3177
  else {
@@ -3461,6 +3561,8 @@ function OpenTuiApp(props) {
3461
3561
  let assistantContent = "";
3462
3562
  let assistantReasoning = "";
3463
3563
  const toolCalls = [];
3564
+ let currentTurnHasToolCall = false;
3565
+ let turnStartedAt;
3464
3566
  let runError;
3465
3567
  let runCancelled = false;
3466
3568
  try {
@@ -3469,46 +3571,72 @@ function OpenTuiApp(props) {
3469
3571
  assistantContent = "";
3470
3572
  assistantReasoning = "";
3471
3573
  toolCalls.length = 0;
3574
+ currentTurnHasToolCall = false;
3575
+ turnStartedAt = Date.now();
3472
3576
  redrawTranscript({
3473
3577
  role: "assistant",
3474
3578
  content: "",
3475
3579
  status: "thinking",
3476
3580
  streaming: true,
3581
+ turnStartedAt,
3477
3582
  });
3478
3583
  }
3479
3584
  else if (event.type === "text_delta") {
3480
3585
  assistantContent += event.content;
3586
+ }
3587
+ else if (event.type === "reasoning_delta") {
3588
+ assistantReasoning += event.content;
3481
3589
  redrawTranscript({
3482
3590
  role: "assistant",
3483
- content: assistantContent,
3591
+ content: "",
3484
3592
  reasoning: assistantReasoning || undefined,
3485
3593
  toolCalls: toolCalls.length ? [...toolCalls] : undefined,
3486
- status: "responding",
3594
+ status: "thinking",
3487
3595
  streaming: true,
3596
+ turnStartedAt,
3488
3597
  });
3489
3598
  }
3490
- else if (event.type === "reasoning_delta") {
3491
- assistantReasoning += event.content;
3599
+ else if (event.type === "tool_call_start") {
3600
+ currentTurnHasToolCall = true;
3492
3601
  redrawTranscript({
3493
3602
  role: "assistant",
3494
- content: assistantContent,
3603
+ content: "",
3495
3604
  reasoning: assistantReasoning || undefined,
3496
3605
  toolCalls: toolCalls.length ? [...toolCalls] : undefined,
3497
- status: "thinking",
3606
+ status: toolCalls.length ? undefined : "thinking",
3498
3607
  streaming: true,
3608
+ turnStartedAt,
3499
3609
  });
3500
3610
  }
3611
+ else if (event.type === "tool_call_delta") {
3612
+ currentTurnHasToolCall = true;
3613
+ }
3614
+ else if (event.type === "tool_call_end") {
3615
+ currentTurnHasToolCall = true;
3616
+ }
3501
3617
  else if (event.type === "tool_start") {
3502
- toolCalls.push({ id: event.id, name: event.name, args: event.args, status: "running" });
3618
+ currentTurnHasToolCall = true;
3619
+ const now = Date.now();
3620
+ const existing = toolCalls.find((item) => item.id === event.id);
3621
+ if (existing) {
3622
+ existing.args = event.args;
3623
+ existing.streamingArgs = false;
3624
+ existing.status = "running";
3625
+ existing.startedAt = existing.startedAt ?? now;
3626
+ }
3627
+ else {
3628
+ toolCalls.push({ id: event.id, name: event.name, args: event.args, status: "running", startedAt: now });
3629
+ }
3503
3630
  if (event.name === "question") {
3504
3631
  scheduleQuestionSync();
3505
3632
  }
3506
3633
  redrawTranscript({
3507
3634
  role: "assistant",
3508
- content: assistantContent,
3635
+ content: "",
3509
3636
  reasoning: assistantReasoning || undefined,
3510
3637
  toolCalls: [...toolCalls],
3511
3638
  streaming: true,
3639
+ turnStartedAt,
3512
3640
  });
3513
3641
  }
3514
3642
  else if (event.type === "tool_end") {
@@ -3518,12 +3646,14 @@ function OpenTuiApp(props) {
3518
3646
  call.isError = event.result.isError;
3519
3647
  call.metadata = event.result.metadata;
3520
3648
  call.status = event.result.isError ? "error" : "completed";
3649
+ call.completedAt = Date.now();
3521
3650
  redrawTranscript({
3522
3651
  role: "assistant",
3523
- content: assistantContent,
3652
+ content: currentTurnHasToolCall ? "" : assistantContent,
3524
3653
  reasoning: assistantReasoning || undefined,
3525
3654
  toolCalls: [...toolCalls],
3526
3655
  streaming: true,
3656
+ turnStartedAt,
3527
3657
  });
3528
3658
  }
3529
3659
  if (event.name === "question") {
@@ -3532,6 +3662,30 @@ function OpenTuiApp(props) {
3532
3662
  refreshGitSidebar();
3533
3663
  syncSidebarLsp();
3534
3664
  }
3665
+ else if (event.type === "tool_update") {
3666
+ const call = toolCalls.find((item) => item.id === event.id);
3667
+ if (call) {
3668
+ call.metadata = mergeToolMetadata(call.metadata, event.update.metadata);
3669
+ call.result = event.update.message ?? call.result;
3670
+ const finished = event.update.status === "failed" || event.update.status === "blocked" || event.update.status === "cancelled" || event.update.status === "completed";
3671
+ call.status = event.update.status === "failed" || event.update.status === "blocked" || event.update.status === "cancelled"
3672
+ ? "error"
3673
+ : event.update.status === "completed"
3674
+ ? "completed"
3675
+ : "running";
3676
+ call.isError = call.status === "error";
3677
+ if (finished && call.completedAt === undefined)
3678
+ call.completedAt = Date.now();
3679
+ redrawTranscript({
3680
+ role: "assistant",
3681
+ content: currentTurnHasToolCall ? "" : assistantContent,
3682
+ reasoning: assistantReasoning || undefined,
3683
+ toolCalls: [...toolCalls],
3684
+ streaming: true,
3685
+ turnStartedAt,
3686
+ });
3687
+ }
3688
+ }
3535
3689
  else if (event.type === "todos_updated") {
3536
3690
  setTodos(event.todos);
3537
3691
  syncSidebarTodos(event.todos);
@@ -3558,9 +3712,12 @@ function OpenTuiApp(props) {
3558
3712
  bumpSidebar();
3559
3713
  const assistantMessage = {
3560
3714
  role: "assistant",
3561
- content: assistantContent,
3715
+ content: currentTurnHasToolCall ? "" : assistantContent,
3562
3716
  reasoning: assistantReasoning || undefined,
3563
3717
  toolCalls: toolCalls.length ? [...toolCalls] : undefined,
3718
+ turnStartedAt,
3719
+ turnCompletedAt: Date.now(),
3720
+ turnUsage: event.usage,
3564
3721
  };
3565
3722
  const nextMessages = hasRenderableMessage(assistantMessage)
3566
3723
  ? [...displayMessages, assistantMessage]
@@ -3570,6 +3727,7 @@ function OpenTuiApp(props) {
3570
3727
  assistantContent = "";
3571
3728
  assistantReasoning = "";
3572
3729
  toolCalls.length = 0;
3730
+ turnStartedAt = undefined;
3573
3731
  streamingDisplay = undefined;
3574
3732
  }
3575
3733
  }
@@ -3672,7 +3830,7 @@ function OpenTuiApp(props) {
3672
3830
  paddingLeft: 2,
3673
3831
  paddingRight: 2,
3674
3832
  }, [
3675
- h("box", { flexShrink: 0, flexDirection: "column" }, ...HOME_LOGO.map((line) => h("text", { fg: theme.primary }, line))),
3833
+ h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center" }, ...HOME_LOGO.map((line) => h("text", { fg: homeLogoColor(line.tone) }, line.text || " "))),
3676
3834
  h("box", { height: 1, minHeight: 0, flexShrink: 1 }),
3677
3835
  h("box", {
3678
3836
  ref: (ref) => {
@@ -4417,7 +4575,6 @@ function OpenTuiApp(props) {
4417
4575
  if (isNewHost)
4418
4576
  transcriptState.entries = [];
4419
4577
  updateTranscriptHost(ref, transcriptState, currentTranscriptMessages(streamingDisplay), transcriptOptions(), props.syntaxStyle, props.subtleSyntaxStyle);
4420
- syncThinkingSpinner();
4421
4578
  syncPromptSurfaces(isNewHost);
4422
4579
  if (isNewHost)
4423
4580
  scheduleTranscriptScrollAfterUpdate(transcriptScrollFollowing, 0);
@@ -4756,19 +4913,31 @@ function renderAssistantMessage(message, syntaxStyle, subtleSyntaxStyle, showThi
4756
4913
  borderColor: theme.messageThinkingBorder,
4757
4914
  flexDirection: "column",
4758
4915
  flexShrink: 0,
4759
- }, renderMarkdownContent(formatThinkingMarkdown(visibleReasoning), subtleSyntaxStyle, {
4916
+ }, h("text", { content: thinkingLabelContent(message.streaming === true, reasoningElapsedMs(message)), fg: theme.messageThinkingText, wrapMode: "none" }), renderMarkdownContent(formatThinkingMarkdown(visibleReasoning), subtleSyntaxStyle, {
4760
4917
  streaming: message.streaming === true,
4761
- fg: theme.messageThinkingText,
4918
+ fg: theme.messageThinkingContentText,
4762
4919
  })));
4763
4920
  }
4764
- for (const tool of message.toolCalls ?? [])
4921
+ const toolCalls = message.toolCalls ?? [];
4922
+ for (const tool of toolCalls)
4765
4923
  children.push(renderTool(tool, syntaxStyle, width));
4766
- if (message.content.trim()) {
4767
- children.push(h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, renderMarkdownContent(message.content.trim(), syntaxStyle, {
4924
+ const trimmedContent = message.content.trim();
4925
+ if (trimmedContent && toolCalls.length > 0) {
4926
+ children.push(h("box", { paddingLeft: 3, marginTop: 1, flexShrink: 0 }, h("text", { content: answerDividerStyledText(), wrapMode: "none" })));
4927
+ }
4928
+ if (trimmedContent) {
4929
+ children.push(h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, renderMarkdownContent(trimmedContent, syntaxStyle, {
4768
4930
  streaming: message.streaming === true,
4769
4931
  fg: theme.messageAssistantText,
4770
4932
  })));
4771
4933
  }
4934
+ if (message.streaming === true && trimmedContent) {
4935
+ children.push(h("box", { paddingLeft: 3, flexShrink: 0 }, h("text", { fg: theme.primary, wrapMode: "none" }, "▌")));
4936
+ }
4937
+ const summaryString = formatTurnSummary(message);
4938
+ if (summaryString) {
4939
+ children.push(h("box", { paddingLeft: 3, marginTop: 1, flexShrink: 0 }, h("text", { fg: theme.textMuted, wrapMode: "none" }, summaryString)));
4940
+ }
4772
4941
  if (!children.length)
4773
4942
  return null;
4774
4943
  return h("box", { flexDirection: "column", flexShrink: 0 }, children);
@@ -4847,12 +5016,23 @@ function updateTranscriptHost(host, state, messages, options, syntaxStyle, subtl
4847
5016
  }
4848
5017
  for (const [index, message] of visibleMessages.entries()) {
4849
5018
  const key = transcriptMessageKey(message, index);
4850
- const thinkingExpanded = state.expandedThinking.has(key);
5019
+ if (state.defaultWritesExpanded) {
5020
+ for (const tool of message.toolCalls ?? []) {
5021
+ if (isWritePreviewTool(tool)) {
5022
+ state.expandedWrites.add(writeToolKey(key, tool));
5023
+ }
5024
+ }
5025
+ }
4851
5026
  const compactionExpanded = state.expandedCompactions.has(key);
4852
- const signature = transcriptMessageSignature(message, showThinking, thinkingExpanded, compactionExpanded);
5027
+ const signature = transcriptMessageSignature(message, compactionExpanded);
4853
5028
  const previous = state.entries[index];
4854
5029
  if (previous?.key === key && previous.signature === signature) {
4855
- updateMessageEntry(previous, message, showThinking, thinkingExpanded, compactionExpanded);
5030
+ updateMessageEntry(previous, message, showThinking, compactionExpanded, {
5031
+ syntaxStyle,
5032
+ expandedWrites: state.expandedWrites,
5033
+ width: options?.width ?? 80,
5034
+ onToggleWrite: options?.onToggleWrite,
5035
+ });
4856
5036
  nextEntries.push(previous);
4857
5037
  continue;
4858
5038
  }
@@ -4860,7 +5040,7 @@ function updateTranscriptHost(host, state, messages, options, syntaxStyle, subtl
4860
5040
  host.remove(previous.node.id);
4861
5041
  previous.node.destroyRecursively();
4862
5042
  }
4863
- const entry = createMessageEntry(ctx, message, index, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking, options?.width ?? 80, thinkingExpanded, compactionExpanded, options?.onToggleThinking, options?.onToggleCompaction);
5043
+ const entry = createMessageEntry(ctx, message, index, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking, options?.width ?? 80, compactionExpanded, state.expandedWrites, options?.onToggleCompaction, options?.onToggleWrite);
4864
5044
  if (entry) {
4865
5045
  host.add(entry.node, index);
4866
5046
  nextEntries.push(entry);
@@ -4903,34 +5083,23 @@ function clearTranscriptEntries(host, state) {
4903
5083
  function transcriptMessageKey(message, index) {
4904
5084
  return `${index}:${message.role}`;
4905
5085
  }
4906
- function transcriptMessageSignature(message, showThinking = true, thinkingExpanded = false, compactionExpanded = false) {
5086
+ function transcriptMessageSignature(message, compactionExpanded = false) {
4907
5087
  if (message.role !== "assistant")
4908
5088
  return message.role;
4909
5089
  if (message.syntheticKind === "ui_compact_card") {
4910
5090
  return `compaction:${compactionExpanded ? "expanded" : "collapsed"}:${message.compactionMeta?.turns ?? 0}`;
4911
5091
  }
4912
5092
  const modelSwitch = parseModelSwitchMessage(message.content);
4913
- const tools = (message.toolCalls ?? [])
4914
- .map((tool) => `${tool.id}:${tool.name}:${tool.status ?? (tool.result === undefined ? "pending" : "completed")}:${tool.isError ? "error" : "ok"}`)
4915
- .join("|");
4916
- const visibleReasoning = showThinking && !!message.reasoning?.trim();
5093
+ const pureModelSwitch = modelSwitch && !message.reasoning?.trim() && !(message.toolCalls?.length);
5094
+ if (pureModelSwitch) {
5095
+ return `assistant:model-switch:${hashString(modelSwitch)}`;
5096
+ }
4917
5097
  return [
4918
5098
  message.role,
4919
- modelSwitch ? "model-switch" : "standard",
4920
- message.status ?? "idle",
4921
- visibleReasoning ? (thinkingExpanded ? "reasoning-expanded" : "reasoning-collapsed") : "no-reasoning",
4922
- message.content.trim() ? "content" : "no-content",
4923
- tools,
5099
+ "standard",
4924
5100
  ].join(":");
4925
5101
  }
4926
- function hashString(value) {
4927
- let hash = 5381;
4928
- for (let index = 0; index < value.length; index++) {
4929
- hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
4930
- }
4931
- return (hash >>> 0).toString(36);
4932
- }
4933
- function updateMessageEntry(entry, message, showThinking = true, thinkingExpanded = false, compactionExpanded = false) {
5102
+ function updateMessageEntry(entry, message, showThinking = true, compactionExpanded = false, assistantOptions) {
4934
5103
  if (message.role === "user") {
4935
5104
  if (entry.refs.userText)
4936
5105
  entry.refs.userText.content = message.content || " ";
@@ -4954,21 +5123,150 @@ function updateMessageEntry(entry, message, showThinking = true, thinkingExpande
4954
5123
  }
4955
5124
  return;
4956
5125
  }
5126
+ if (assistantOptions) {
5127
+ updateAssistantEntry(entry, message, showThinking, assistantOptions);
5128
+ return;
5129
+ }
5130
+ }
5131
+ function updateAssistantEntry(entry, message, showThinking, options) {
5132
+ const content = message.content.trim();
5133
+ const visibleReasoning = showThinking ? message.reasoning?.trim() ?? "" : "";
5134
+ const tools = message.toolCalls ?? [];
5135
+ const showStatus = !!message.status && !visibleReasoning && !content && tools.length === 0;
4957
5136
  if (entry.refs.statusText) {
4958
- entry.refs.statusText.content = assistantStatusLabel(message);
5137
+ entry.refs.statusText.content = showStatus ? assistantStatusLabel(message) : "";
5138
+ }
5139
+ if (entry.refs.statusBox) {
5140
+ entry.refs.statusBox.visible = showStatus;
4959
5141
  }
4960
5142
  if (entry.refs.reasoningToggleText) {
4961
- entry.refs.reasoningExpanded = thinkingExpanded;
4962
5143
  entry.refs.reasoningStreaming = message.streaming === true;
4963
- entry.refs.reasoningToggleText.content = thinkingToggleLabel(thinkingExpanded, message.streaming === true);
5144
+ entry.refs.reasoningToggleText.content = visibleReasoning
5145
+ ? thinkingLabelContent(message.streaming === true, reasoningElapsedMs(message))
5146
+ : new StyledText([fg(theme.messageThinkingText)("")]);
4964
5147
  }
4965
5148
  if (entry.refs.reasoningMarkdown) {
4966
- entry.refs.reasoningMarkdown.content = showThinking ? formatThinkingMarkdown(message.reasoning?.trim() ?? "") : "";
4967
- entry.refs.reasoningMarkdown.streaming = message.streaming === true;
5149
+ syncMarkdownRenderable(entry.refs.reasoningMarkdown, formatThinkingMarkdown(visibleReasoning), message.streaming === true);
5150
+ }
5151
+ if (entry.refs.reasoningBox) {
5152
+ entry.refs.reasoningBox.visible = !!visibleReasoning;
5153
+ }
5154
+ updateAssistantToolEntries(entry, tools, options);
5155
+ if (entry.refs.answerDividerBox) {
5156
+ const showDivider = tools.length > 0 && !!content;
5157
+ entry.refs.answerDividerBox.visible = showDivider;
5158
+ if (entry.refs.answerDividerText) {
5159
+ entry.refs.answerDividerText.content = showDivider
5160
+ ? answerDividerStyledText()
5161
+ : new StyledText([fg(theme.textMuted)("")]);
5162
+ }
4968
5163
  }
4969
5164
  if (entry.refs.contentMarkdown) {
4970
- entry.refs.contentMarkdown.content = message.content.trim();
4971
- entry.refs.contentMarkdown.streaming = message.streaming === true;
5165
+ syncMarkdownRenderable(entry.refs.contentMarkdown, content, message.streaming === true);
5166
+ }
5167
+ if (entry.refs.contentBox) {
5168
+ entry.refs.contentBox.visible = !!content;
5169
+ }
5170
+ if (entry.refs.contentCursorBox) {
5171
+ const cursorActive = message.streaming === true && !!content;
5172
+ entry.refs.contentCursorBox.visible = cursorActive;
5173
+ if (entry.refs.contentCursorText)
5174
+ entry.refs.contentCursorText.content = cursorActive ? "▌" : "";
5175
+ }
5176
+ const summaryString = formatTurnSummary(message);
5177
+ if (entry.refs.turnSummaryText) {
5178
+ entry.refs.turnSummaryText.content = summaryString ?? "";
5179
+ }
5180
+ if (entry.refs.turnSummaryBox) {
5181
+ entry.refs.turnSummaryBox.visible = !!summaryString;
5182
+ }
5183
+ }
5184
+ function syncMarkdownRenderable(markdown, content, streaming) {
5185
+ if (markdown.content === content && markdown.streaming === streaming)
5186
+ return;
5187
+ markdown.content = content;
5188
+ markdown.streaming = streaming;
5189
+ markdown.clearCache();
5190
+ }
5191
+ function updateAssistantToolEntries(entry, tools, options) {
5192
+ const toolsBox = entry.refs.toolsBox;
5193
+ if (!toolsBox)
5194
+ return;
5195
+ toolsBox.visible = tools.length > 0;
5196
+ const previousEntries = entry.refs.toolEntries ?? new Map();
5197
+ const nextEntries = new Map();
5198
+ tools.forEach((tool, index) => {
5199
+ const toolKey = writeToolKey(entry.key, tool);
5200
+ const writeExpanded = options.expandedWrites.has(toolKey);
5201
+ const signature = toolRenderableSignature(tool, writeExpanded);
5202
+ const previous = previousEntries.get(tool.id);
5203
+ if (previous?.signature === signature) {
5204
+ nextEntries.set(tool.id, previous);
5205
+ return;
5206
+ }
5207
+ if (previous) {
5208
+ toolsBox.remove(previous.node.id);
5209
+ previous.node.destroyRecursively();
5210
+ }
5211
+ const node = createToolRenderable(toolsBox.ctx, tool, options.syntaxStyle, options.width, writeExpanded, isWritePreviewTool(tool) ? () => options.onToggleWrite?.(toolKey) : undefined);
5212
+ toolsBox.add(node, index);
5213
+ nextEntries.set(tool.id, { signature, node });
5214
+ });
5215
+ for (const [id, previous] of previousEntries.entries()) {
5216
+ if (nextEntries.has(id))
5217
+ continue;
5218
+ toolsBox.remove(previous.node.id);
5219
+ previous.node.destroyRecursively();
5220
+ }
5221
+ entry.refs.toolEntries = nextEntries;
5222
+ }
5223
+ function toolRenderableSignature(tool, writeExpanded) {
5224
+ return [
5225
+ tool.id,
5226
+ tool.name,
5227
+ tool.status ?? (tool.result === undefined ? "pending" : "completed"),
5228
+ tool.isError ? "error" : "ok",
5229
+ tool.streamingArgs ? "streaming-args" : "args-complete",
5230
+ writeExpanded ? "expanded" : "collapsed",
5231
+ hashString(stableStringify(tool.args)),
5232
+ hashString(tool.rawArguments ?? ""),
5233
+ hashString(tool.result ?? ""),
5234
+ hashString(stableStringify(tool.metadata ?? null)),
5235
+ ].join(":");
5236
+ }
5237
+ function mergeToolMetadata(current, incoming) {
5238
+ if (!incoming)
5239
+ return current;
5240
+ if (current?.kind !== "subagent" || incoming.kind !== "subagent") {
5241
+ return incoming;
5242
+ }
5243
+ const currentSubagents = Array.isArray(current.subagents) ? current.subagents : [];
5244
+ const incomingSubagents = Array.isArray(incoming.subagents) ? incoming.subagents : [];
5245
+ const byId = new Map();
5246
+ for (const item of currentSubagents) {
5247
+ const subAgentId = typeof item === "object" && item !== null && "subAgentId" in item
5248
+ ? String(item.subAgentId)
5249
+ : "";
5250
+ byId.set(subAgentId || `current:${byId.size}`, item);
5251
+ }
5252
+ for (const item of incomingSubagents) {
5253
+ const subAgentId = typeof item === "object" && item !== null && "subAgentId" in item
5254
+ ? String(item.subAgentId)
5255
+ : "";
5256
+ byId.set(subAgentId || `incoming:${byId.size}`, item);
5257
+ }
5258
+ return {
5259
+ ...current,
5260
+ ...incoming,
5261
+ subagents: [...byId.values()],
5262
+ };
5263
+ }
5264
+ function stableStringify(value) {
5265
+ try {
5266
+ return JSON.stringify(value) ?? "";
5267
+ }
5268
+ catch {
5269
+ return String(value);
4972
5270
  }
4973
5271
  }
4974
5272
  function createBox(ctx, options, children = []) {
@@ -4992,6 +5290,7 @@ function createMarkdown(ctx, content, syntaxStyle, options) {
4992
5290
  content,
4993
5291
  syntaxStyle,
4994
5292
  treeSitterClient,
5293
+ renderNode: createSemanticMarkdownRenderNode(ctx, options?.fg ?? theme.messageAssistantText),
4995
5294
  streaming: options?.streaming === true,
4996
5295
  conceal: true,
4997
5296
  concealCode: false,
@@ -5011,6 +5310,87 @@ function createMarkdown(ctx, content, syntaxStyle, options) {
5011
5310
  },
5012
5311
  });
5013
5312
  }
5313
+ function createSemanticMarkdownRenderNode(ctx, defaultFg) {
5314
+ const palette = semanticMarkdownPalette(defaultFg);
5315
+ return (token, context) => {
5316
+ switch (token?.type) {
5317
+ case "hr":
5318
+ return createText(ctx, new StyledText([
5319
+ fg(theme.borderSubtle)("─".repeat(48)),
5320
+ ]), {
5321
+ fg: theme.borderSubtle,
5322
+ wrapMode: "none",
5323
+ flexShrink: 0,
5324
+ });
5325
+ case "heading":
5326
+ return createText(ctx, markdownInlineToStyledText(markdownTokenInlineTokens(token), palette, token.text ?? "", { bold: true }), {
5327
+ fg: defaultFg,
5328
+ wrapMode: "word",
5329
+ flexShrink: 0,
5330
+ });
5331
+ case "paragraph":
5332
+ return createText(ctx, markdownInlineToStyledText(markdownTokenInlineTokens(token), palette, token.text ?? ""), {
5333
+ fg: defaultFg,
5334
+ wrapMode: "word",
5335
+ flexShrink: 0,
5336
+ });
5337
+ case "list":
5338
+ return createMarkdownList(ctx, token, palette, defaultFg);
5339
+ default:
5340
+ return context.defaultRender();
5341
+ }
5342
+ };
5343
+ }
5344
+ function createMarkdownList(ctx, token, palette, defaultFg) {
5345
+ const ordered = token?.ordered === true;
5346
+ const start = typeof token?.start === "number" ? token.start : 1;
5347
+ const items = Array.isArray(token?.items) ? token.items : [];
5348
+ if (items.length === 0)
5349
+ return null;
5350
+ return createBox(ctx, {
5351
+ flexDirection: "column",
5352
+ flexShrink: 0,
5353
+ }, items.map((item, index) => {
5354
+ const marker = ordered ? `${start + index}. ` : "• ";
5355
+ return createText(ctx, new StyledText([
5356
+ fg(theme.textMuted)(marker),
5357
+ ...markdownInlineToStyledText(markdownTokenInlineTokens(item), palette, item.text ?? "").chunks,
5358
+ ]), {
5359
+ fg: defaultFg,
5360
+ wrapMode: "word",
5361
+ flexShrink: 0,
5362
+ });
5363
+ }));
5364
+ }
5365
+ function markdownTokenInlineTokens(token) {
5366
+ if (Array.isArray(token?.tokens))
5367
+ return token.tokens;
5368
+ if (typeof token?.text === "string")
5369
+ return [{ type: "text", text: token.text }];
5370
+ return undefined;
5371
+ }
5372
+ function semanticMarkdownPalette(defaultFg) {
5373
+ return {
5374
+ text: defaultFg,
5375
+ textMuted: theme.textMuted,
5376
+ success: theme.success,
5377
+ warning: theme.warning,
5378
+ secondary: theme.secondary,
5379
+ };
5380
+ }
5381
+ function markdownInlineToStyledText(tokens, palette, fallback = "", style = {}) {
5382
+ const chunks = markdownInlineSegments(tokens, fallback, style).map((segment) => {
5383
+ let chunk = fg(palette[segment.color ?? "text"])(segment.text);
5384
+ if (segment.bold)
5385
+ chunk = bold(chunk);
5386
+ if (segment.italic)
5387
+ chunk = italic(chunk);
5388
+ if (segment.dim)
5389
+ chunk = dim(chunk);
5390
+ return chunk;
5391
+ });
5392
+ return new StyledText(chunks);
5393
+ }
5014
5394
  function createDiffRenderable(ctx, diff, filePath, syntaxStyle, width = 80) {
5015
5395
  return new DiffRenderable(ctx, {
5016
5396
  diff,
@@ -5073,6 +5453,24 @@ function createCodeBlockRenderable(ctx, content, filePath, syntaxStyle) {
5073
5453
  lineNumbers.add(code);
5074
5454
  return lineNumbers;
5075
5455
  }
5456
+ function createToolRenderHelpers() {
5457
+ return {
5458
+ theme,
5459
+ createBox: (ctx, options, children) => createBox(ctx, options, children),
5460
+ createText: (ctx, content, options) => createText(ctx, content, (options ?? {})),
5461
+ createCodeBlockRenderable,
5462
+ createDiffRenderable,
5463
+ toolColor,
5464
+ displayToolName,
5465
+ toolHeader,
5466
+ toolPath,
5467
+ extractToolDiff,
5468
+ summarizeToolResult,
5469
+ isToolFinished,
5470
+ toolPreview,
5471
+ toolStateIcon,
5472
+ };
5473
+ }
5076
5474
  function renderCodeBlockContent(content, filePath, syntaxStyle) {
5077
5475
  return h("line_number", { fg: theme.textMuted, minWidth: 3, paddingRight: 1 }, h("code", {
5078
5476
  content,
@@ -5084,14 +5482,14 @@ function renderCodeBlockContent(content, filePath, syntaxStyle) {
5084
5482
  width: "100%",
5085
5483
  }));
5086
5484
  }
5087
- function createMessageEntry(ctx, message, index, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking = true, width = 80, thinkingExpanded = false, compactionExpanded = false, onToggleThinking, onToggleCompaction) {
5485
+ function createMessageEntry(ctx, message, index, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking = true, width = 80, compactionExpanded = false, expandedWrites = new Set(), onToggleCompaction, onToggleWrite) {
5088
5486
  if (message.role === "user")
5089
5487
  return createUserEntry(ctx, message, index, key, signature);
5090
5488
  if (message.role === "error")
5091
5489
  return createErrorEntry(ctx, message, key, signature);
5092
5490
  if (message.syntheticKind === "ui_compact_card")
5093
5491
  return createCompactionCardEntry(ctx, message, key, signature, compactionExpanded, onToggleCompaction);
5094
- return createAssistantEntry(ctx, message, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking, width, thinkingExpanded, onToggleThinking);
5492
+ return createAssistantEntry(ctx, message, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking, width, expandedWrites, onToggleWrite);
5095
5493
  }
5096
5494
  function createUserEntry(ctx, message, index, key, signature) {
5097
5495
  const refs = {};
@@ -5136,7 +5534,7 @@ function createErrorEntry(ctx, message, key, signature) {
5136
5534
  }, [text]);
5137
5535
  return { key, signature, node, refs };
5138
5536
  }
5139
- function createAssistantEntry(ctx, message, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking = true, width = 80, thinkingExpanded = false, onToggleThinking) {
5537
+ function createAssistantEntry(ctx, message, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking = true, width = 80, expandedWrites = new Set(), onToggleWrite) {
5140
5538
  const modelSwitch = parseModelSwitchMessage(message.content);
5141
5539
  if (modelSwitch && !message.reasoning?.trim() && !(message.toolCalls?.length)) {
5142
5540
  return createModelSwitchEntry(ctx, modelSwitch, key, signature);
@@ -5144,70 +5542,121 @@ function createAssistantEntry(ctx, message, syntaxStyle, subtleSyntaxStyle, key,
5144
5542
  const children = [];
5145
5543
  const refs = {};
5146
5544
  const visibleReasoning = showThinking ? message.reasoning?.trim() : "";
5147
- if (message.status && !visibleReasoning && !message.content.trim() && !(message.toolCalls?.length)) {
5148
- const status = createText(ctx, assistantStatusLabel(message), {
5149
- fg: theme.messageThinkingText,
5150
- });
5151
- refs.statusText = status;
5152
- children.push(createBox(ctx, {
5153
- paddingLeft: 3,
5154
- marginTop: 1,
5155
- flexShrink: 0,
5156
- }, [status]));
5157
- }
5158
- if (visibleReasoning) {
5159
- const reasoningChildren = [];
5160
- const toggleText = createText(ctx, thinkingToggleLabel(thinkingExpanded, message.streaming === true), {
5161
- fg: theme.messageThinkingText,
5162
- wrapMode: "none",
5163
- });
5164
- refs.reasoningToggleText = toggleText;
5165
- refs.reasoningExpanded = thinkingExpanded;
5166
- refs.reasoningStreaming = message.streaming === true;
5167
- reasoningChildren.push(createBox(ctx, {
5168
- flexShrink: 0,
5169
- onMouseUp: () => onToggleThinking?.(key),
5170
- }, [toggleText]));
5171
- if (thinkingExpanded) {
5172
- const markdown = createMarkdown(ctx, formatThinkingMarkdown(visibleReasoning), subtleSyntaxStyle, {
5173
- streaming: message.streaming === true,
5174
- fg: theme.messageThinkingText,
5175
- });
5176
- refs.reasoningMarkdown = markdown;
5177
- reasoningChildren.push(markdown);
5178
- }
5179
- children.push(createBox(ctx, {
5180
- paddingLeft: 2,
5181
- marginTop: 1,
5182
- border: ["left"],
5183
- borderColor: theme.messageThinkingBorder,
5184
- flexDirection: "column",
5185
- flexShrink: 0,
5186
- }, reasoningChildren));
5187
- }
5188
- for (const tool of message.toolCalls ?? [])
5189
- children.push(createToolRenderable(ctx, tool, syntaxStyle, width));
5190
- if (message.content.trim()) {
5191
- const markdown = createMarkdown(ctx, message.content.trim(), syntaxStyle, {
5192
- streaming: message.streaming === true,
5193
- fg: theme.messageAssistantText,
5194
- });
5195
- refs.contentMarkdown = markdown;
5196
- children.push(createBox(ctx, {
5197
- paddingLeft: 3,
5198
- marginTop: 1,
5199
- flexDirection: "column",
5545
+ const content = message.content.trim();
5546
+ const tools = message.toolCalls ?? [];
5547
+ const showStatus = !!message.status && !visibleReasoning && !content && tools.length === 0;
5548
+ const status = createText(ctx, assistantStatusLabel(message), {
5549
+ fg: theme.messageThinkingText,
5550
+ });
5551
+ refs.statusText = status;
5552
+ const statusBox = createBox(ctx, {
5553
+ paddingLeft: 3,
5554
+ marginTop: 1,
5555
+ flexShrink: 0,
5556
+ visible: showStatus,
5557
+ }, [status]);
5558
+ refs.statusBox = statusBox;
5559
+ children.push(statusBox);
5560
+ const labelText = createText(ctx, thinkingLabelContent(message.streaming === true, reasoningElapsedMs(message)), {
5561
+ fg: theme.messageThinkingText,
5562
+ wrapMode: "none",
5563
+ });
5564
+ refs.reasoningToggleText = labelText;
5565
+ refs.reasoningStreaming = message.streaming === true;
5566
+ const markdown = createMarkdown(ctx, formatThinkingMarkdown(visibleReasoning ?? ""), subtleSyntaxStyle, {
5567
+ streaming: message.streaming === true,
5568
+ fg: theme.messageThinkingContentText,
5569
+ });
5570
+ refs.reasoningMarkdown = markdown;
5571
+ const reasoningBox = createBox(ctx, {
5572
+ paddingLeft: 2,
5573
+ marginTop: 1,
5574
+ border: ["left"],
5575
+ borderColor: theme.messageThinkingBorder,
5576
+ flexDirection: "column",
5577
+ flexShrink: 0,
5578
+ visible: !!visibleReasoning,
5579
+ }, [
5580
+ createBox(ctx, {
5200
5581
  flexShrink: 0,
5201
- }, [markdown]));
5202
- }
5203
- if (!children.length)
5204
- return null;
5205
- return {
5582
+ }, [labelText]),
5583
+ markdown,
5584
+ ]);
5585
+ refs.reasoningBox = reasoningBox;
5586
+ children.push(reasoningBox);
5587
+ const toolsBox = createBox(ctx, {
5588
+ flexDirection: "column",
5589
+ flexShrink: 0,
5590
+ visible: tools.length > 0,
5591
+ });
5592
+ refs.toolsBox = toolsBox;
5593
+ refs.toolEntries = new Map();
5594
+ children.push(toolsBox);
5595
+ const showAnswerDivider = tools.length > 0 && !!content;
5596
+ const answerDividerText = createText(ctx, showAnswerDivider ? answerDividerStyledText() : new StyledText([fg(theme.textMuted)("")]), { wrapMode: "none" });
5597
+ refs.answerDividerText = answerDividerText;
5598
+ const answerDividerBox = createBox(ctx, {
5599
+ paddingLeft: 3,
5600
+ marginTop: 1,
5601
+ flexShrink: 0,
5602
+ visible: showAnswerDivider,
5603
+ }, [answerDividerText]);
5604
+ refs.answerDividerBox = answerDividerBox;
5605
+ children.push(answerDividerBox);
5606
+ const contentMarkdown = createMarkdown(ctx, content, syntaxStyle, {
5607
+ streaming: message.streaming === true,
5608
+ fg: theme.messageAssistantText,
5609
+ });
5610
+ refs.contentMarkdown = contentMarkdown;
5611
+ const contentBox = createBox(ctx, {
5612
+ paddingLeft: 3,
5613
+ marginTop: 1,
5614
+ flexDirection: "column",
5615
+ flexShrink: 0,
5616
+ visible: !!content,
5617
+ }, [contentMarkdown]);
5618
+ refs.contentBox = contentBox;
5619
+ children.push(contentBox);
5620
+ const cursorActive = message.streaming === true && !!content;
5621
+ const contentCursorText = createText(ctx, "▌", { fg: theme.primary, wrapMode: "none" });
5622
+ refs.contentCursorText = contentCursorText;
5623
+ const contentCursorBox = createBox(ctx, {
5624
+ paddingLeft: 3,
5625
+ flexShrink: 0,
5626
+ visible: cursorActive,
5627
+ }, [contentCursorText]);
5628
+ refs.contentCursorBox = contentCursorBox;
5629
+ children.push(contentCursorBox);
5630
+ const summaryString = formatTurnSummary(message);
5631
+ const turnSummaryText = createText(ctx, summaryString ?? "", { fg: theme.textMuted, wrapMode: "none" });
5632
+ refs.turnSummaryText = turnSummaryText;
5633
+ const turnSummaryBox = createBox(ctx, {
5634
+ paddingLeft: 3,
5635
+ marginTop: 1,
5636
+ flexShrink: 0,
5637
+ visible: !!summaryString,
5638
+ }, [turnSummaryText]);
5639
+ refs.turnSummaryBox = turnSummaryBox;
5640
+ children.push(turnSummaryBox);
5641
+ const entry = {
5206
5642
  key,
5207
5643
  signature,
5208
5644
  node: createBox(ctx, { flexDirection: "column", flexShrink: 0 }, children),
5209
5645
  refs,
5210
5646
  };
5647
+ updateAssistantToolEntries(entry, tools, {
5648
+ syntaxStyle,
5649
+ expandedWrites,
5650
+ width,
5651
+ onToggleWrite,
5652
+ });
5653
+ return entry;
5654
+ }
5655
+ function answerDividerStyledText() {
5656
+ return new StyledText([
5657
+ fg(theme.accent)("◆ "),
5658
+ fg(theme.textMuted)(italic("Answer")),
5659
+ ]);
5211
5660
  }
5212
5661
  function createCompactionCardEntry(ctx, message, key, signature, expanded, onToggle) {
5213
5662
  const refs = {};
@@ -5325,7 +5774,7 @@ function createTodoWriteRenderable(ctx, tool) {
5325
5774
  flexDirection: "column",
5326
5775
  flexShrink: 0,
5327
5776
  }, [
5328
- createText(ctx, `~ Planning tasks...`, { fg: toolColor(tool) }),
5777
+ createText(ctx, `→ Planning tasks...`, { fg: toolColor(tool) }),
5329
5778
  ]);
5330
5779
  }
5331
5780
  return createBox(ctx, {
@@ -5352,90 +5801,26 @@ function createTodoWriteRenderable(ctx, tool) {
5352
5801
  }),
5353
5802
  ]);
5354
5803
  }
5355
- function createToolRenderable(ctx, tool, syntaxStyle, width = 80) {
5804
+ function createToolRenderable(ctx, tool, syntaxStyle, width = 80, writeExpanded = false, onToggleWrite) {
5356
5805
  if (tool.name === "question") {
5357
5806
  return createQuestionToolRenderable(ctx, tool);
5358
5807
  }
5359
5808
  if (tool.name === "todo_write") {
5360
5809
  return createTodoWriteRenderable(ctx, tool);
5361
5810
  }
5362
- const icon = tool.name === "bash" ? "$" : tool.name === "edit" || tool.name === "write" ? "✎" : "●";
5363
- const color = toolColor(tool);
5364
- const header = toolHeader(tool);
5365
- const diff = extractToolDiff(tool);
5366
- if (diff && !tool.isError && tool.name === "edit") {
5367
- return createBox(ctx, {
5368
- paddingLeft: 3,
5369
- marginTop: 1,
5370
- flexDirection: "column",
5371
- flexShrink: 0,
5372
- }, [
5373
- createText(ctx, new StyledText([
5374
- fg(color)(`${icon} ${displayToolName(tool.name)}`),
5375
- fg(theme.toolText)(header ? ` ${header}` : ""),
5376
- ])),
5377
- createBox(ctx, {
5378
- paddingLeft: 1,
5379
- marginTop: 1,
5380
- border: ["left"],
5381
- borderColor: theme.borderSubtle,
5382
- flexDirection: "column",
5383
- flexShrink: 0,
5384
- }, [createDiffRenderable(ctx, diff, toolPath(tool), syntaxStyle, width)]),
5385
- ]);
5386
- }
5387
- if (!tool.isError && tool.name === "write" && typeof tool.args?.content === "string" && isToolFinished(tool)) {
5388
- return createBox(ctx, {
5389
- paddingLeft: 3,
5390
- marginTop: 1,
5391
- flexDirection: "column",
5392
- flexShrink: 0,
5393
- }, [
5394
- createText(ctx, new StyledText([
5395
- fg(color)(`${icon} ${displayToolName(tool.name)}`),
5396
- fg(theme.toolText)(header ? ` ${header}` : ""),
5397
- ])),
5398
- createBox(ctx, {
5399
- paddingLeft: 1,
5400
- marginTop: 1,
5401
- border: ["left"],
5402
- borderColor: theme.borderSubtle,
5403
- flexDirection: "column",
5404
- flexShrink: 0,
5405
- }, [createCodeBlockRenderable(ctx, tool.args.content, toolPath(tool), syntaxStyle)]),
5406
- ]);
5407
- }
5408
- const chunks = [
5409
- fg(color)(`${isToolFinished(tool) ? "" : "~ "}${icon} ${displayToolName(tool.name)}`),
5410
- ];
5411
- if (header)
5412
- chunks.push(fg(theme.toolText)(` ${header}`));
5413
- if (tool.result) {
5414
- chunks.push(fg(theme.text)("\n"));
5415
- chunks.push(fg(theme.borderSubtle)(" "));
5416
- chunks.push(fg(tool.isError ? theme.toolError : theme.textMuted)(summarizeToolResult(tool)));
5417
- const preview = toolPreview(tool);
5418
- if (preview) {
5419
- for (const line of preview.lines) {
5420
- chunks.push(fg(theme.text)("\n"));
5421
- chunks.push(fg(theme.borderSubtle)(" "));
5422
- chunks.push(fg(theme.toolText)(line));
5423
- }
5424
- if (preview.omitted > 0) {
5425
- chunks.push(fg(theme.text)("\n"));
5426
- chunks.push(fg(theme.borderSubtle)(" "));
5427
- chunks.push(fg(theme.textMuted)(`+ ${preview.omitted} more`));
5428
- }
5429
- }
5811
+ const renderer = findToolRenderer(tool);
5812
+ if (renderer) {
5813
+ return renderer.render({
5814
+ ctx,
5815
+ tool,
5816
+ syntaxStyle,
5817
+ width,
5818
+ writeExpanded,
5819
+ onToggleWrite,
5820
+ helpers: createToolRenderHelpers(),
5821
+ });
5430
5822
  }
5431
- return createBox(ctx, {
5432
- paddingLeft: 3,
5433
- marginTop: 1,
5434
- flexDirection: "column",
5435
- flexShrink: 0,
5436
- }, [
5437
- createText(ctx, new StyledText(chunks), { wrapMode: "word" }),
5438
- ]);
5823
+ throw new Error(`No renderer for tool '${tool.name}'`);
5439
5824
  }
5440
5825
  function createQuestionToolRenderable(ctx, tool) {
5441
5826
  const questions = questionToolQuestions(tool);
@@ -5448,7 +5833,7 @@ function createQuestionToolRenderable(ctx, tool) {
5448
5833
  flexDirection: "column",
5449
5834
  flexShrink: 0,
5450
5835
  }, [
5451
- createText(ctx, `${isToolFinished(tool) ? "" : "~ "}→ ${rejected ? "Asked" : "Asking"} questions...`, {
5836
+ createText(ctx, `→ ${rejected ? "Asked" : "Asking"} questions...`, {
5452
5837
  fg: rejected ? theme.textMuted : toolColor(tool),
5453
5838
  attributes: rejected ? TextAttributes.STRIKETHROUGH : undefined,
5454
5839
  }),
@@ -5494,16 +5879,22 @@ function renderTool(tool, syntaxStyle, width = 80) {
5494
5879
  if (tool.name === "question") {
5495
5880
  return renderQuestionTool(tool);
5496
5881
  }
5497
- const icon = tool.name === "bash" ? "$" : tool.name === "edit" || tool.name === "write" ? "✎" : "●";
5882
+ const icon = toolStateIcon(tool);
5498
5883
  const color = toolColor(tool);
5499
5884
  const diff = extractToolDiff(tool);
5500
5885
  if (diff && !tool.isError && tool.name === "edit") {
5501
5886
  return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", { fg: color }, `${icon} ${displayToolName(tool.name)}${toolHeader(tool) ? ` ${toolHeader(tool)}` : ""}`), h("box", { paddingLeft: 1, marginTop: 1, border: ["left"], borderColor: theme.borderSubtle, flexDirection: "column", flexShrink: 0 }, renderDiffContent(diff, toolPath(tool), syntaxStyle, width)));
5502
5887
  }
5503
- if (!tool.isError && tool.name === "write" && typeof tool.args?.content === "string" && isToolFinished(tool)) {
5504
- return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", { fg: color }, `${icon} ${displayToolName(tool.name)}${toolHeader(tool) ? ` ${toolHeader(tool)}` : ""}`), h("box", { paddingLeft: 1, marginTop: 1, border: ["left"], borderColor: theme.borderSubtle, flexDirection: "column", flexShrink: 0 }, renderCodeBlockContent(tool.args.content, toolPath(tool), syntaxStyle)));
5888
+ if (isWritePreviewTool(tool)) {
5889
+ const preview = formatWritePreview(tool.args.content, false);
5890
+ const summary = tool.result ?? `${isToolFinished(tool) ? "Prepared" : "Writing"} ${tool.args.content.split(/\r?\n/).length} lines to ${toolPath(tool) ?? "file"}`;
5891
+ return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", { fg: color }, `${icon} ${displayToolName(tool.name)}${toolHeader(tool) ? ` ${toolHeader(tool)}` : ""}`), h("box", { paddingLeft: 1, marginTop: 0, border: ["left"], borderColor: theme.borderSubtle, flexDirection: "column", flexShrink: 0 }, h("text", { fg: theme.textMuted }, `└ ${summary}`), renderCodeBlockContent(preview.content, toolPath(tool), syntaxStyle), preview.omittedLines > 0
5892
+ ? h("text", { fg: theme.textMuted }, `... +${preview.omittedLines} lines (ctrl+o to expand)`)
5893
+ : preview.omittedChars > 0
5894
+ ? h("text", { fg: theme.textMuted }, `... +${preview.omittedChars} chars (ctrl+o to expand)`)
5895
+ : null));
5505
5896
  }
5506
- return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", { fg: color }, `${isToolFinished(tool) ? "" : "~ "}${icon} ${displayToolName(tool.name)}${toolHeader(tool) ? ` ${toolHeader(tool)}` : ""}`), () => tool.result ? h("text", { fg: tool.isError ? theme.toolError : theme.textMuted, wrapMode: "word" }, toolSummaryWithPreview(tool)) : null);
5897
+ return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", { fg: color }, `${icon} ${displayToolName(tool.name)}${toolHeader(tool) ? ` ${toolHeader(tool)}` : ""}`), () => tool.result ? h("text", { fg: tool.isError ? theme.toolError : theme.textMuted, wrapMode: "word" }, toolSummaryWithPreview(tool)) : null);
5507
5898
  }
5508
5899
  function renderQuestionTool(tool) {
5509
5900
  const questions = questionToolQuestions(tool);
@@ -5513,7 +5904,7 @@ function renderQuestionTool(tool) {
5513
5904
  return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", {
5514
5905
  fg: rejected ? theme.textMuted : toolColor(tool),
5515
5906
  attributes: rejected ? TextAttributes.STRIKETHROUGH : undefined,
5516
- }, `${isToolFinished(tool) ? "" : "~ "}→ ${rejected ? "Asked" : "Asking"} questions...`));
5907
+ }, `→ ${rejected ? "Asked" : "Asking"} questions...`));
5517
5908
  }
5518
5909
  return h("box", {
5519
5910
  border: ["left"],
@@ -5744,15 +6135,49 @@ function toSelectOption(item) {
5744
6135
  value: item.value,
5745
6136
  };
5746
6137
  }
6138
+ function nextImageLabelIndex(messages) {
6139
+ let max = 0;
6140
+ for (const message of messages) {
6141
+ for (const match of message.content.matchAll(imageAttachmentLabelPattern())) {
6142
+ max = Math.max(max, Number(match[1] ?? 0));
6143
+ }
6144
+ }
6145
+ return max + 1;
6146
+ }
6147
+ function imageExtensionFromUrl(url) {
6148
+ const mediaMatch = url.match(/^data:image\/([^;,]+)/i);
6149
+ const media = mediaMatch?.[1]?.toLowerCase();
6150
+ if (media === "jpeg")
6151
+ return "jpg";
6152
+ if (media === "png" || media === "webp" || media === "gif" || media === "bmp")
6153
+ return media;
6154
+ const pathMatch = url.match(/\.([a-z0-9]+)(?:[?#].*)?$/i);
6155
+ return pathMatch?.[1]?.toLowerCase() || "png";
6156
+ }
6157
+ function formatDisplayContentParts(content, labelStart) {
6158
+ const text = content
6159
+ .filter((part) => part.type === "text")
6160
+ .map((part) => part.text)
6161
+ .join("\n")
6162
+ .trim();
6163
+ let imageIndex = labelStart;
6164
+ const imageLines = content
6165
+ .filter((part) => part.type === "image_url")
6166
+ .map((part) => `[image#${imageIndex++}.${imageExtensionFromUrl(part.image_url.url)}]`);
6167
+ return [text, ...imageLines].filter(Boolean).join("\n") || "(multimedia)";
6168
+ }
5747
6169
  function reconstructDisplayMessages(agentMessages) {
5748
6170
  const result = [];
5749
6171
  for (const message of agentMessages) {
5750
- if (message.role === "system" || message.role === "tool")
6172
+ if (message.role === "system" || message.role === "meta" || message.role === "tool")
5751
6173
  continue;
5752
6174
  if (message.role === "user") {
5753
- if (message.isMeta)
5754
- continue;
5755
- result.push({ role: "user", content: typeof message.content === "string" ? message.content : "(multimedia)" });
6175
+ result.push({
6176
+ role: "user",
6177
+ content: typeof message.content === "string"
6178
+ ? message.content
6179
+ : formatDisplayContentParts(message.content, nextImageLabelIndex(result)),
6180
+ });
5756
6181
  continue;
5757
6182
  }
5758
6183
  const toolCalls = [];
@@ -5835,7 +6260,9 @@ function formatTranscript(messages, options) {
5835
6260
  if (visibleReasoning) {
5836
6261
  appendBlank();
5837
6262
  append("│ ", theme.messageThinkingBorder);
5838
- appendLine(truncate(formatThinkingMarkdown(visibleReasoning), 500), theme.messageThinkingText);
6263
+ chunks.push(fg(theme.messageThinkingText)(italic("Thinking\n")));
6264
+ append("│ ", theme.messageThinkingBorder);
6265
+ appendLine(truncate(formatThinkingMarkdown(visibleReasoning), 500), theme.messageThinkingContentText);
5839
6266
  }
5840
6267
  if (message.status && !visibleReasoning && !message.content.trim() && !(message.toolCalls?.length)) {
5841
6268
  appendBlank();
@@ -5846,7 +6273,7 @@ function formatTranscript(messages, options) {
5846
6273
  appendBlank();
5847
6274
  const icon = tool.name === "bash" ? "$" : tool.name === "edit" || tool.name === "write" ? "✎" : "●";
5848
6275
  const color = toolColor(tool);
5849
- append(` ${isToolFinished(tool) ? "" : "~ "}${icon} `, color);
6276
+ append(` ${icon} `, color);
5850
6277
  append(displayToolName(tool.name), color);
5851
6278
  const header = toolHeader(tool);
5852
6279
  if (header)
@@ -5896,7 +6323,7 @@ function renderHomeState(input) {
5896
6323
  flexDirection: "column",
5897
6324
  alignItems: "center",
5898
6325
  justifyContent: "center",
5899
- }, h("box", { flexDirection: "column", flexShrink: 0, width: "100%" }, h("text", { fg: theme.text }, ""), h("text", { fg: theme.text }, ""), ...HOME_LOGO.map((line) => h("text", { fg: theme.primary }, centerLine(line, width))), h("text", { fg: theme.text }, ""), h("text", { fg: theme.warning }, centerLine(`● Tip ${input.tip}`, width)), cwd ? h("text", { fg: theme.textMuted }, centerLine(` ${cwd}`, width)) : null));
6326
+ }, h("box", { flexDirection: "column", flexShrink: 0, width: "100%" }, h("text", { fg: theme.text }, ""), h("text", { fg: theme.text }, ""), ...HOME_LOGO.map((line) => h("text", { fg: homeLogoColor(line.tone) }, centerLine(line.text || " ", width))), h("text", { fg: theme.text }, ""), h("text", { fg: theme.warning }, centerLine(`● Tip ${input.tip}`, width)), cwd ? h("text", { fg: theme.textMuted }, centerLine(` ${cwd}`, width)) : null));
5900
6327
  }
5901
6328
  function hasRenderableMessage(message, showThinking = true) {
5902
6329
  if (message.role === "error")
@@ -6072,6 +6499,11 @@ function displayToolName(name) {
6072
6499
  glob: "Glob",
6073
6500
  web_fetch: "WebFetch",
6074
6501
  web_search: "WebSearch",
6502
+ subagent: "Subagent",
6503
+ spawn_agent: "SpawnAgent",
6504
+ wait_agent: "WaitAgent",
6505
+ send_input: "SendInput",
6506
+ close_agent: "CloseAgent",
6075
6507
  task: "Task",
6076
6508
  todo: "Todo",
6077
6509
  question: "Questions",
@@ -6080,6 +6512,20 @@ function displayToolName(name) {
6080
6512
  }
6081
6513
  function toolHeader(tool) {
6082
6514
  const args = tool.args || {};
6515
+ if (tool.name === "subagent") {
6516
+ if (typeof args.agent === "string")
6517
+ return `(${args.agent})`;
6518
+ if (Array.isArray(args.tasks))
6519
+ return `(${args.tasks.length} tasks)`;
6520
+ }
6521
+ if (tool.name === "spawn_agent") {
6522
+ const agent = args.agent_type ?? args.agent ?? "default";
6523
+ return `(${agent})`;
6524
+ }
6525
+ if (tool.name === "wait_agent" || tool.name === "send_input" || tool.name === "close_agent") {
6526
+ const agentId = args.agent_id ?? (Array.isArray(args.agent_ids) ? `${args.agent_ids.length} agents` : undefined);
6527
+ return agentId ? `(${truncate(String(agentId), 64)})` : "";
6528
+ }
6083
6529
  const value = args.path ?? args.command ?? args.pattern ?? args.url ?? args.query;
6084
6530
  return value ? `(${truncate(String(value).replace(/\n/g, " "), 64)})` : "";
6085
6531
  }
@@ -6125,8 +6571,11 @@ function filetype(filePath) {
6125
6571
  return ext ? map[ext] : undefined;
6126
6572
  }
6127
6573
  function summarizeToolResult(tool) {
6128
- if (!isToolFinished(tool))
6129
- return tool.status === "running" ? "running" : "pending";
6574
+ if (!isToolFinished(tool)) {
6575
+ if (tool.status === "running")
6576
+ return "running";
6577
+ return tool.streamingArgs ? "preparing" : "pending";
6578
+ }
6130
6579
  if (tool.name === "question") {
6131
6580
  if (isQuestionRejected(tool))
6132
6581
  return "dismissed";
@@ -6137,14 +6586,47 @@ function summarizeToolResult(tool) {
6137
6586
  if (tool.isError)
6138
6587
  return truncate(result.split("\n").find(Boolean) || "error", 120);
6139
6588
  const lines = result.replace(/\r\n/g, "\n").split("\n").filter((line) => line.trim()).length;
6589
+ const matches = typeof tool.metadata?.matches === "number" ? tool.metadata.matches : undefined;
6590
+ if (tool.name === "read")
6591
+ return "";
6140
6592
  if (tool.name === "edit")
6141
6593
  return "patched file";
6142
6594
  if (tool.name === "write")
6143
6595
  return "wrote file";
6144
- if (tool.name === "bash")
6596
+ if (tool.name === "grep" || tool.name === "glob") {
6597
+ if (matches !== undefined)
6598
+ return `${matches} match${matches === 1 ? "" : "es"}`;
6599
+ return lines ? `${lines} line${lines === 1 ? "" : "s"}` : "no matches";
6600
+ }
6601
+ if (tool.name === "bash") {
6602
+ if (matches !== undefined)
6603
+ return `${matches} match${matches === 1 ? "" : "es"}`;
6145
6604
  return lines ? `${lines} line${lines === 1 ? "" : "s"} output` : "done";
6605
+ }
6146
6606
  return lines ? `${lines} line${lines === 1 ? "" : "s"}` : "done";
6147
6607
  }
6608
+ function toolStateIcon(tool) {
6609
+ if (tool.isError || tool.status === "error")
6610
+ return "✗";
6611
+ if (!isToolFinished(tool)) {
6612
+ if (tool.status === "running")
6613
+ return "◐";
6614
+ return "◌";
6615
+ }
6616
+ if (tool.name === "bash")
6617
+ return "$";
6618
+ if (tool.name === "edit")
6619
+ return "✎";
6620
+ if (tool.name === "write")
6621
+ return "✎";
6622
+ if (tool.name === "read")
6623
+ return "▤";
6624
+ if (tool.name === "grep" || tool.name === "glob")
6625
+ return "⌕";
6626
+ if (tool.name === "web_fetch" || tool.name === "web_search")
6627
+ return "⌖";
6628
+ return "●";
6629
+ }
6148
6630
  function toolSummaryWithPreview(tool) {
6149
6631
  const summary = ` ${summarizeToolResult(tool)}`;
6150
6632
  const preview = toolPreview(tool);
@@ -6159,7 +6641,7 @@ function toolSummaryWithPreview(tool) {
6159
6641
  function toolPreview(tool) {
6160
6642
  if (!isToolFinished(tool) || tool.isError || !tool.result)
6161
6643
  return undefined;
6162
- if (tool.name !== "read" && tool.name !== "glob")
6644
+ if (tool.name !== "glob")
6163
6645
  return undefined;
6164
6646
  const lines = tool.result
6165
6647
  .replace(/\r\n/g, "\n")
@@ -6215,7 +6697,10 @@ function isToolFinished(tool) {
6215
6697
  function assistantStatusLabel(message) {
6216
6698
  if (message.status === "responding")
6217
6699
  return "Responding...";
6218
- return message.streaming ? "Thinking..." : "Thinking";
6700
+ const elapsed = formatDuration(reasoningElapsedMs(message));
6701
+ if (message.streaming)
6702
+ return elapsed ? `Thinking ${elapsed}...` : "Thinking...";
6703
+ return elapsed ? `Thought for ${elapsed}` : "Thinking";
6219
6704
  }
6220
6705
  function buildContextGauge(percent, barWidth) {
6221
6706
  const clamped = Math.max(0, Math.min(100, percent));
@@ -6246,9 +6731,69 @@ function formatContextRemaining(value) {
6246
6731
  return `${(value / 1_000).toFixed(1)}K`;
6247
6732
  return String(value);
6248
6733
  }
6249
- function thinkingToggleLabel(expanded, streaming = false, spinnerFrame = "") {
6250
- const arrow = expanded ? "▼" : "▶";
6251
- return streaming && spinnerFrame ? `${spinnerFrame} ${arrow} Thinking` : `${arrow} Thinking`;
6734
+ function thinkingLabelContent(streaming = false, elapsedMs) {
6735
+ const elapsed = formatDuration(elapsedMs);
6736
+ const label = streaming
6737
+ ? (elapsed ? `Thinking ${elapsed}...` : "Thinking...")
6738
+ : (elapsed ? `Thought for ${elapsed}` : "Thought");
6739
+ return new StyledText([
6740
+ fg(theme.messageThinkingText)(italic(label)),
6741
+ ]);
6742
+ }
6743
+ function formatDuration(ms) {
6744
+ if (ms === undefined || !Number.isFinite(ms) || ms <= 0)
6745
+ return "";
6746
+ if (ms < 1000)
6747
+ return `${Math.max(1, Math.round(ms))}ms`;
6748
+ const seconds = ms / 1000;
6749
+ if (seconds < 10)
6750
+ return `${seconds.toFixed(1)}s`;
6751
+ if (seconds < 60)
6752
+ return `${Math.round(seconds)}s`;
6753
+ const minutes = Math.floor(seconds / 60);
6754
+ const remSec = Math.round(seconds - minutes * 60);
6755
+ return remSec === 0 ? `${minutes}m` : `${minutes}m${remSec}s`;
6756
+ }
6757
+ function reasoningElapsedMs(message) {
6758
+ if (message.turnStartedAt === undefined)
6759
+ return undefined;
6760
+ const end = !message.streaming ? (message.turnCompletedAt ?? Date.now()) : Date.now();
6761
+ const diff = end - message.turnStartedAt;
6762
+ return diff > 0 ? diff : undefined;
6763
+ }
6764
+ function toolElapsedMs(tool) {
6765
+ if (tool.startedAt === undefined)
6766
+ return undefined;
6767
+ const end = tool.completedAt ?? (tool.status === "completed" || tool.status === "error" ? Date.now() : undefined);
6768
+ if (end === undefined)
6769
+ return undefined;
6770
+ const diff = end - tool.startedAt;
6771
+ return diff > 0 ? diff : undefined;
6772
+ }
6773
+ function turnElapsedMs(message) {
6774
+ if (message.turnStartedAt === undefined)
6775
+ return undefined;
6776
+ const end = message.turnCompletedAt ?? Date.now();
6777
+ const diff = end - message.turnStartedAt;
6778
+ return diff > 0 ? diff : undefined;
6779
+ }
6780
+ function formatTurnSummary(message) {
6781
+ const parts = [];
6782
+ const elapsed = turnElapsedMs(message);
6783
+ if (elapsed !== undefined)
6784
+ parts.push(formatDuration(elapsed));
6785
+ const usage = message.turnUsage;
6786
+ if (usage) {
6787
+ if (usage.promptTokens)
6788
+ parts.push(`${formatCompactNumber(usage.promptTokens)}↑`);
6789
+ if (usage.completionTokens)
6790
+ parts.push(`${formatCompactNumber(usage.completionTokens)}↓`);
6791
+ if (usage.reasoningTokens)
6792
+ parts.push(`${formatCompactNumber(usage.reasoningTokens)}◇`);
6793
+ }
6794
+ if (!parts.length)
6795
+ return undefined;
6796
+ return `· ${parts.join(" · ")}`;
6252
6797
  }
6253
6798
  function truncate(value, max) {
6254
6799
  return value.length > max ? value.slice(0, Math.max(1, max - 1)).trimEnd() + "…" : value;