@bubblebrain-ai/bubble 0.0.4 → 0.0.6

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 (91) hide show
  1. package/dist/agent/budget-ledger.d.ts +20 -0
  2. package/dist/agent/budget-ledger.js +51 -0
  3. package/dist/agent/execution-governor.js +1 -1
  4. package/dist/agent/profiles.d.ts +59 -0
  5. package/dist/agent/profiles.js +460 -0
  6. package/dist/agent/subagent-control.d.ts +52 -0
  7. package/dist/agent/subagent-control.js +38 -0
  8. package/dist/agent/task-size.d.ts +9 -0
  9. package/dist/agent/task-size.js +33 -0
  10. package/dist/agent/tool-intent.d.ts +1 -0
  11. package/dist/agent/tool-intent.js +1 -1
  12. package/dist/agent.d.ts +60 -1
  13. package/dist/agent.js +648 -55
  14. package/dist/context/budget.js +1 -0
  15. package/dist/context/compact-llm.js +7 -6
  16. package/dist/context/compact.js +6 -6
  17. package/dist/context/projector.d.ts +3 -3
  18. package/dist/context/projector.js +32 -18
  19. package/dist/context/prune.d.ts +2 -2
  20. package/dist/context/prune.js +1 -4
  21. package/dist/main.js +12 -5
  22. package/dist/mcp/manager.js +1 -0
  23. package/dist/orchestrator/default-hooks.js +85 -35
  24. package/dist/orchestrator/hooks.d.ts +5 -3
  25. package/dist/prompt/compose.d.ts +1 -0
  26. package/dist/prompt/compose.js +11 -1
  27. package/dist/prompt/environment.js +23 -2
  28. package/dist/prompt/provider-prompts/deepseek.js +1 -2
  29. package/dist/prompt/provider-prompts/kimi.js +1 -2
  30. package/dist/prompt/reminders.d.ts +21 -2
  31. package/dist/prompt/reminders.js +53 -8
  32. package/dist/prompt/runtime.d.ts +1 -1
  33. package/dist/prompt/runtime.js +17 -23
  34. package/dist/provider-artifacts.d.ts +7 -0
  35. package/dist/provider-artifacts.js +60 -0
  36. package/dist/provider.d.ts +16 -8
  37. package/dist/provider.js +149 -34
  38. package/dist/session-log.js +3 -1
  39. package/dist/system-prompt.d.ts +2 -0
  40. package/dist/tools/agent-lifecycle.d.ts +6 -0
  41. package/dist/tools/agent-lifecycle.js +355 -0
  42. package/dist/tools/bash.d.ts +2 -1
  43. package/dist/tools/bash.js +3 -1
  44. package/dist/tools/edit-apply.d.ts +25 -0
  45. package/dist/tools/edit-apply.js +228 -0
  46. package/dist/tools/edit.d.ts +2 -1
  47. package/dist/tools/edit.js +75 -56
  48. package/dist/tools/exit-plan-mode.js +3 -1
  49. package/dist/tools/file-mutation-queue.d.ts +1 -0
  50. package/dist/tools/file-mutation-queue.js +32 -0
  51. package/dist/tools/file-state.d.ts +25 -0
  52. package/dist/tools/file-state.js +52 -0
  53. package/dist/tools/glob.js +1 -0
  54. package/dist/tools/grep.js +1 -0
  55. package/dist/tools/index.d.ts +3 -1
  56. package/dist/tools/index.js +9 -7
  57. package/dist/tools/lsp.js +2 -0
  58. package/dist/tools/memory.js +2 -0
  59. package/dist/tools/question.js +2 -0
  60. package/dist/tools/read.d.ts +2 -1
  61. package/dist/tools/read.js +6 -1
  62. package/dist/tools/skill.js +1 -0
  63. package/dist/tools/task.js +1 -0
  64. package/dist/tools/todo.js +1 -0
  65. package/dist/tools/tool-search.js +2 -1
  66. package/dist/tools/web-fetch.js +1 -0
  67. package/dist/tools/web-search.js +1 -0
  68. package/dist/tools/write.d.ts +4 -3
  69. package/dist/tools/write.js +135 -54
  70. package/dist/tui/display-history.d.ts +10 -1
  71. package/dist/tui/markdown-inline.d.ts +22 -0
  72. package/dist/tui/markdown-inline.js +68 -0
  73. package/dist/tui/render-signature.d.ts +1 -0
  74. package/dist/tui/render-signature.js +7 -0
  75. package/dist/tui/run.js +811 -274
  76. package/dist/tui/streaming-tool-args.d.ts +15 -0
  77. package/dist/tui/streaming-tool-args.js +30 -0
  78. package/dist/tui/tool-renderers/fallback.d.ts +2 -0
  79. package/dist/tui/tool-renderers/fallback.js +75 -0
  80. package/dist/tui/tool-renderers/registry.d.ts +3 -0
  81. package/dist/tui/tool-renderers/registry.js +11 -0
  82. package/dist/tui/tool-renderers/subagent.d.ts +2 -0
  83. package/dist/tui/tool-renderers/subagent.js +114 -0
  84. package/dist/tui/tool-renderers/types.d.ts +36 -0
  85. package/dist/tui/tool-renderers/types.js +1 -0
  86. package/dist/tui/tool-renderers/write-preview.d.ts +12 -0
  87. package/dist/tui/tool-renderers/write-preview.js +30 -0
  88. package/dist/tui/tool-renderers/write.d.ts +6 -0
  89. package/dist/tui/tool-renderers/write.js +88 -0
  90. package/dist/types.d.ts +105 -10
  91. package/package.json +1 -1
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,12 @@ 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";
21
+ import { extractStreamingArgsHint } from "./streaming-tool-args.js";
16
22
  import { getNextPermissionMode, PERMISSION_MODE_INFO } from "../permission/mode.js";
17
23
  import { getContextBudget } from "../context/budget.js";
18
24
  import { getLspService } from "../lsp/index.js";
@@ -55,6 +61,7 @@ const DEFAULT_THEME = {
55
61
  messageAssistantText: "#eeeeee",
56
62
  messageAssistantAccent: "#fab283",
57
63
  messageThinkingText: "#8b949e",
64
+ messageThinkingContentText: "#6e7681",
58
65
  messageThinkingBorder: "#282828",
59
66
  toolText: "#a6acb8",
60
67
  toolPending: "#fab283",
@@ -103,10 +110,31 @@ const QUESTION_MAX_OPTIONS = 10;
103
110
  const QUESTION_MAX_CONFIRM_ROWS = 3;
104
111
  const QUESTION_PANEL_MIN_HEIGHT = 9;
105
112
  const HOME_LOGO = [
106
- " /\\_/\\ █▀▀▄ █ █ █▀▀▄ █▀▀▄ █ █▀▀",
107
- "( o.o ) █▀▀▄ █ █ █▀▀▄ █▀▀▄ █ █▀▀",
108
- " > ^ < ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀▀ ▀▀▀▀",
113
+ { text: " /\\___/\\ ", tone: "primary" },
114
+ { text: "( ◕ ◕ )", tone: "primary" },
115
+ { text: "( ω )", tone: "warning" },
116
+ { text: " (\")_(\") ", tone: "warning" },
117
+ { text: "", tone: "primary" },
118
+ { text: "· ◌ ○ ◯ ·", tone: "textMuted" },
119
+ { text: "", tone: "primary" },
120
+ { text: "██████╗ ██╗ ██╗██████╗ ██████╗ ██╗ ███████╗", tone: "primary" },
121
+ { text: "██╔══██╗██║ ██║██╔══██╗██╔══██╗██║ ██╔════╝", tone: "primary" },
122
+ { text: "██████╔╝██║ ██║██████╔╝██████╔╝██║ █████╗ ", tone: "warning" },
123
+ { text: "██╔══██╗██║ ██║██╔══██╗██╔══██╗██║ ██╔══╝ ", tone: "warning" },
124
+ { text: "██████╔╝╚██████╔╝██████╔╝██████╔╝███████╗███████╗", tone: "accent" },
125
+ { text: "╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝", tone: "accent" },
126
+ { text: "", tone: "primary" },
127
+ { text: "── your bubble coding companion ──", tone: "secondary" },
109
128
  ];
129
+ function homeLogoColor(tone) {
130
+ switch (tone) {
131
+ case "primary": return theme.primary;
132
+ case "warning": return theme.warning;
133
+ case "accent": return theme.accent;
134
+ case "secondary": return theme.secondary;
135
+ case "textMuted": return theme.textMuted;
136
+ }
137
+ }
110
138
  const HOME_TIPS = [
111
139
  "Type @ followed by a filename to attach file context",
112
140
  "Press Shift+Tab to cycle Build, Plan, and Bypass modes",
@@ -318,20 +346,16 @@ function OpenTuiApp(props) {
318
346
  let rootBox;
319
347
  let sidebarShell;
320
348
  let transcriptHost;
321
- const transcriptState = { entries: [], expandedThinking: new Set(), expandedCompactions: new Set() };
349
+ const transcriptState = {
350
+ entries: [],
351
+ expandedCompactions: new Set(),
352
+ expandedWrites: new Set(),
353
+ defaultWritesExpanded: false,
354
+ };
322
355
  let dock;
323
356
  let homeComposerShell;
324
357
  let sessionComposerShell;
325
358
  const promptScannerSyncs = new Set();
326
- const thinkingSpinnerFrames = createFrames({
327
- width: 4,
328
- color: theme.primary,
329
- style: "blocks",
330
- inactiveFactor: 0.45,
331
- minAlpha: 0.25,
332
- });
333
- let thinkingSpinnerFrameIndex = 0;
334
- let thinkingSpinnerTimer;
335
359
  let approvalRoot;
336
360
  let approvalHeaderTitle;
337
361
  let approvalMetaIcon;
@@ -1524,7 +1548,6 @@ function OpenTuiApp(props) {
1524
1548
  for (const timer of questionSyncTimers)
1525
1549
  clearTimeout(timer);
1526
1550
  questionSyncTimers.clear();
1527
- stopThinkingSpinner();
1528
1551
  if (props.options.planHandlerRef)
1529
1552
  props.options.planHandlerRef.current = undefined;
1530
1553
  if (props.options.approvalHandlerRef)
@@ -1668,6 +1691,16 @@ function OpenTuiApp(props) {
1668
1691
  event.preventDefault?.();
1669
1692
  return true;
1670
1693
  }
1694
+ if (event.ctrl && name === "t" && !picker) {
1695
+ toggleThinkingVisibility();
1696
+ event.preventDefault?.();
1697
+ return true;
1698
+ }
1699
+ if (event.ctrl && name === "o" && !picker) {
1700
+ toggleVisibleWriteBlocks();
1701
+ event.preventDefault?.();
1702
+ return true;
1703
+ }
1671
1704
  if (routeModalKey(event))
1672
1705
  return true;
1673
1706
  if (cycleModeFromKey(event))
@@ -1688,12 +1721,12 @@ function OpenTuiApp(props) {
1688
1721
  plan: pendingPlan()?.plan,
1689
1722
  selectedOption: approvalOptionIdx(),
1690
1723
  showThinking: showThinking(),
1691
- onToggleThinking: (key) => {
1692
- if (transcriptState.expandedThinking.has(key)) {
1693
- transcriptState.expandedThinking.delete(key);
1724
+ onToggleWrite: (key) => {
1725
+ if (transcriptState.expandedWrites.has(key)) {
1726
+ transcriptState.expandedWrites.delete(key);
1694
1727
  }
1695
1728
  else {
1696
- transcriptState.expandedThinking.add(key);
1729
+ transcriptState.expandedWrites.add(key);
1697
1730
  }
1698
1731
  syncSessionMessages();
1699
1732
  },
@@ -1708,52 +1741,61 @@ function OpenTuiApp(props) {
1708
1741
  },
1709
1742
  };
1710
1743
  }
1711
- function syncSessionMessages(messages = currentTranscriptMessages(streamingDisplay)) {
1712
- if (!transcriptHost)
1744
+ function toggleThinkingVisibility() {
1745
+ if (!currentTranscriptMessages(streamingDisplay).some((message) => !!message.reasoning?.trim())) {
1746
+ setNotice("No thinking blocks to toggle");
1713
1747
  return;
1714
- updateTranscriptHost(transcriptHost, transcriptState, messages, transcriptOptions(), props.syntaxStyle, props.subtleSyntaxStyle);
1715
- syncThinkingSpinner();
1716
- syncPromptSurfaces();
1717
- }
1718
- function renderThinkingSpinnerFrame() {
1719
- const frame = thinkingSpinnerFrames[thinkingSpinnerFrameIndex % thinkingSpinnerFrames.length] ?? "";
1720
- let rendered = false;
1721
- for (const entry of transcriptState.entries) {
1722
- const ref = entry.refs.reasoningToggleText;
1723
- if (!ref || !entry.refs.reasoningStreaming)
1724
- continue;
1725
- ref.content = thinkingToggleLabel(entry.refs.reasoningExpanded === true, true, frame);
1726
- ref.requestRender();
1727
- rendered = true;
1728
- }
1729
- if (rendered) {
1730
- transcriptHost?.requestRender();
1731
- rootBox?.requestRender();
1732
1748
  }
1749
+ setShowThinking((prev) => {
1750
+ const next = !prev;
1751
+ setNotice(next ? "Thinking blocks visible" : "Thinking blocks hidden");
1752
+ return next;
1753
+ });
1754
+ redrawTranscript();
1733
1755
  }
1734
- function stopThinkingSpinner() {
1735
- if (thinkingSpinnerTimer)
1736
- clearInterval(thinkingSpinnerTimer);
1737
- thinkingSpinnerTimer = undefined;
1738
- thinkingSpinnerFrameIndex = 0;
1739
- }
1740
- function syncThinkingSpinner() {
1741
- const hasStreamingThinking = transcriptState.entries.some((entry) => !!entry.refs.reasoningToggleText && entry.refs.reasoningStreaming === true);
1742
- if (!hasStreamingThinking) {
1743
- stopThinkingSpinner();
1756
+ function toggleVisibleWriteBlocks() {
1757
+ const keys = collectVisibleWriteKeys();
1758
+ if (!keys.length) {
1759
+ setNotice("No write previews to toggle");
1744
1760
  return;
1745
1761
  }
1746
- renderThinkingSpinnerFrame();
1747
- if (thinkingSpinnerTimer)
1762
+ const shouldExpand = keys.some((key) => !transcriptState.expandedWrites.has(key));
1763
+ transcriptState.defaultWritesExpanded = shouldExpand;
1764
+ for (const key of keys) {
1765
+ if (shouldExpand)
1766
+ transcriptState.expandedWrites.add(key);
1767
+ else
1768
+ transcriptState.expandedWrites.delete(key);
1769
+ }
1770
+ setNotice(shouldExpand ? "Write previews expanded" : "Write previews collapsed");
1771
+ syncSessionMessages();
1772
+ }
1773
+ function collectVisibleWriteKeys() {
1774
+ const messages = currentTranscriptMessages(streamingDisplay)
1775
+ .filter((message) => hasRenderableMessage(message, showThinking()));
1776
+ const keys = [];
1777
+ for (const [index, message] of messages.entries()) {
1778
+ const messageKey = transcriptMessageKey(message, index);
1779
+ for (const tool of message.toolCalls ?? []) {
1780
+ if (isWritePreviewTool(tool)) {
1781
+ keys.push(writeToolKey(messageKey, tool));
1782
+ }
1783
+ }
1784
+ }
1785
+ return keys;
1786
+ }
1787
+ function syncSessionMessages(messages = currentTranscriptMessages(streamingDisplay)) {
1788
+ if (!transcriptHost)
1748
1789
  return;
1749
- thinkingSpinnerTimer = setInterval(() => {
1750
- thinkingSpinnerFrameIndex = (thinkingSpinnerFrameIndex + 1) % thinkingSpinnerFrames.length;
1751
- renderThinkingSpinnerFrame();
1752
- }, PROMPT_SCANNER_INTERVAL_MS);
1790
+ updateTranscriptHost(transcriptHost, transcriptState, messages, transcriptOptions(), props.syntaxStyle, props.subtleSyntaxStyle);
1791
+ syncPromptSurfaces();
1753
1792
  }
1754
1793
  function redrawTranscript(extra, baseMessages = displayMessages) {
1755
- const shouldFollow = shouldFollowTranscriptBeforeUpdate();
1756
1794
  streamingDisplay = extra;
1795
+ renderTranscriptNow(streamingDisplay, baseMessages);
1796
+ }
1797
+ function renderTranscriptNow(extra, baseMessages = displayMessages) {
1798
+ const shouldFollow = shouldFollowTranscriptBeforeUpdate();
1757
1799
  const nextMessages = compactDisplayMessages(extra ? [...baseMessages, extra] : baseMessages);
1758
1800
  syncSessionMessages(nextMessages);
1759
1801
  rootBox?.requestRender();
@@ -3078,12 +3120,7 @@ function OpenTuiApp(props) {
3078
3120
  }
3079
3121
  async function executeSlash(input) {
3080
3122
  if (/^\/(?:thinking|toggle-thinking)(?:\s|$)/.test(input.trim())) {
3081
- setShowThinking((prev) => {
3082
- const next = !prev;
3083
- setNotice(next ? "Thinking blocks visible" : "Thinking blocks hidden");
3084
- return next;
3085
- });
3086
- redrawTranscript();
3123
+ toggleThinkingVisibility();
3087
3124
  return true;
3088
3125
  }
3089
3126
  const wasHomeSurfaceActive = isHomeSurfaceActive();
@@ -3525,54 +3562,128 @@ function OpenTuiApp(props) {
3525
3562
  let assistantContent = "";
3526
3563
  let assistantReasoning = "";
3527
3564
  const toolCalls = [];
3565
+ let currentTurnHasToolCall = false;
3566
+ let turnStartedAt;
3528
3567
  let runError;
3529
3568
  let runCancelled = false;
3569
+ // Throttle redraws driven by per-token streaming events (reasoning_delta
3570
+ // and tool_call_delta). Both can fire hundreds of times per second on a
3571
+ // long reply; coalescing into ~16fps keeps the transcript alive without
3572
+ // thrashing OpenTUI's layout or re-parsing markdown per token.
3573
+ let pendingStreamingRedrawTimer;
3574
+ const STREAMING_REDRAW_INTERVAL_MS = 60;
3575
+ const buildStreamingDisplay = (status) => ({
3576
+ role: "assistant",
3577
+ content: "",
3578
+ reasoning: assistantReasoning || undefined,
3579
+ toolCalls: toolCalls.length ? [...toolCalls] : undefined,
3580
+ status,
3581
+ streaming: true,
3582
+ turnStartedAt,
3583
+ });
3584
+ const flushStreamingRedraw = () => {
3585
+ if (pendingStreamingRedrawTimer === undefined)
3586
+ return;
3587
+ clearTimeout(pendingStreamingRedrawTimer);
3588
+ pendingStreamingRedrawTimer = undefined;
3589
+ redrawTranscript(buildStreamingDisplay(toolCalls.length ? undefined : "thinking"));
3590
+ };
3591
+ const scheduleStreamingRedraw = () => {
3592
+ if (pendingStreamingRedrawTimer !== undefined)
3593
+ return;
3594
+ pendingStreamingRedrawTimer = setTimeout(flushStreamingRedraw, STREAMING_REDRAW_INTERVAL_MS);
3595
+ };
3530
3596
  try {
3531
3597
  for await (const event of props.agent.run(actualInput, props.args.cwd, { abortSignal: run.abortController.signal })) {
3532
3598
  if (event.type === "turn_start") {
3533
3599
  assistantContent = "";
3534
3600
  assistantReasoning = "";
3535
3601
  toolCalls.length = 0;
3602
+ currentTurnHasToolCall = false;
3603
+ turnStartedAt = Date.now();
3536
3604
  redrawTranscript({
3537
3605
  role: "assistant",
3538
3606
  content: "",
3539
3607
  status: "thinking",
3540
3608
  streaming: true,
3609
+ turnStartedAt,
3541
3610
  });
3542
3611
  }
3543
3612
  else if (event.type === "text_delta") {
3544
3613
  assistantContent += event.content;
3545
- redrawTranscript({
3546
- role: "assistant",
3547
- content: assistantContent,
3548
- reasoning: assistantReasoning || undefined,
3549
- toolCalls: toolCalls.length ? [...toolCalls] : undefined,
3550
- status: "responding",
3551
- streaming: true,
3552
- });
3553
3614
  }
3554
3615
  else if (event.type === "reasoning_delta") {
3555
3616
  assistantReasoning += event.content;
3617
+ scheduleStreamingRedraw();
3618
+ }
3619
+ else if (event.type === "tool_call_start") {
3620
+ currentTurnHasToolCall = true;
3621
+ // Insert a streaming placeholder so the user sees feedback the moment
3622
+ // the model commits to a tool call, instead of waiting for the args
3623
+ // JSON to fully stream + parse.
3624
+ if (!toolCalls.find((item) => item.id === event.id)) {
3625
+ toolCalls.push({
3626
+ id: event.id,
3627
+ name: event.name,
3628
+ args: {},
3629
+ rawArguments: "",
3630
+ streamingArgs: true,
3631
+ status: "pending",
3632
+ });
3633
+ }
3556
3634
  redrawTranscript({
3557
3635
  role: "assistant",
3558
- content: assistantContent,
3636
+ content: "",
3559
3637
  reasoning: assistantReasoning || undefined,
3560
- toolCalls: toolCalls.length ? [...toolCalls] : undefined,
3561
- status: "thinking",
3638
+ toolCalls: [...toolCalls],
3562
3639
  streaming: true,
3640
+ turnStartedAt,
3563
3641
  });
3564
3642
  }
3643
+ else if (event.type === "tool_call_delta") {
3644
+ currentTurnHasToolCall = true;
3645
+ const existing = toolCalls.find((item) => item.id === event.id);
3646
+ if (existing) {
3647
+ existing.name = event.name || existing.name;
3648
+ existing.rawArguments = event.arguments;
3649
+ existing.streamingArgs = true;
3650
+ const hint = extractStreamingArgsHint(event.arguments);
3651
+ if (hint.path && existing.args.path !== hint.path) {
3652
+ existing.args = { ...existing.args, path: hint.path };
3653
+ }
3654
+ existing.streamingNewlineCount = hint.newlineCount;
3655
+ scheduleStreamingRedraw();
3656
+ }
3657
+ }
3658
+ else if (event.type === "tool_call_end") {
3659
+ currentTurnHasToolCall = true;
3660
+ }
3565
3661
  else if (event.type === "tool_start") {
3566
- toolCalls.push({ id: event.id, name: event.name, args: event.args, status: "running" });
3662
+ currentTurnHasToolCall = true;
3663
+ flushStreamingRedraw();
3664
+ const now = Date.now();
3665
+ const existing = toolCalls.find((item) => item.id === event.id);
3666
+ if (existing) {
3667
+ existing.args = event.args;
3668
+ existing.streamingArgs = false;
3669
+ existing.streamingNewlineCount = undefined;
3670
+ existing.rawArguments = undefined;
3671
+ existing.status = "running";
3672
+ existing.startedAt = existing.startedAt ?? now;
3673
+ }
3674
+ else {
3675
+ toolCalls.push({ id: event.id, name: event.name, args: event.args, status: "running", startedAt: now });
3676
+ }
3567
3677
  if (event.name === "question") {
3568
3678
  scheduleQuestionSync();
3569
3679
  }
3570
3680
  redrawTranscript({
3571
3681
  role: "assistant",
3572
- content: assistantContent,
3682
+ content: "",
3573
3683
  reasoning: assistantReasoning || undefined,
3574
3684
  toolCalls: [...toolCalls],
3575
3685
  streaming: true,
3686
+ turnStartedAt,
3576
3687
  });
3577
3688
  }
3578
3689
  else if (event.type === "tool_end") {
@@ -3582,12 +3693,14 @@ function OpenTuiApp(props) {
3582
3693
  call.isError = event.result.isError;
3583
3694
  call.metadata = event.result.metadata;
3584
3695
  call.status = event.result.isError ? "error" : "completed";
3696
+ call.completedAt = Date.now();
3585
3697
  redrawTranscript({
3586
3698
  role: "assistant",
3587
- content: assistantContent,
3699
+ content: currentTurnHasToolCall ? "" : assistantContent,
3588
3700
  reasoning: assistantReasoning || undefined,
3589
3701
  toolCalls: [...toolCalls],
3590
3702
  streaming: true,
3703
+ turnStartedAt,
3591
3704
  });
3592
3705
  }
3593
3706
  if (event.name === "question") {
@@ -3596,6 +3709,30 @@ function OpenTuiApp(props) {
3596
3709
  refreshGitSidebar();
3597
3710
  syncSidebarLsp();
3598
3711
  }
3712
+ else if (event.type === "tool_update") {
3713
+ const call = toolCalls.find((item) => item.id === event.id);
3714
+ if (call) {
3715
+ call.metadata = mergeToolMetadata(call.metadata, event.update.metadata);
3716
+ call.result = event.update.message ?? call.result;
3717
+ const finished = event.update.status === "failed" || event.update.status === "blocked" || event.update.status === "cancelled" || event.update.status === "completed";
3718
+ call.status = event.update.status === "failed" || event.update.status === "blocked" || event.update.status === "cancelled"
3719
+ ? "error"
3720
+ : event.update.status === "completed"
3721
+ ? "completed"
3722
+ : "running";
3723
+ call.isError = call.status === "error";
3724
+ if (finished && call.completedAt === undefined)
3725
+ call.completedAt = Date.now();
3726
+ redrawTranscript({
3727
+ role: "assistant",
3728
+ content: currentTurnHasToolCall ? "" : assistantContent,
3729
+ reasoning: assistantReasoning || undefined,
3730
+ toolCalls: [...toolCalls],
3731
+ streaming: true,
3732
+ turnStartedAt,
3733
+ });
3734
+ }
3735
+ }
3599
3736
  else if (event.type === "todos_updated") {
3600
3737
  setTodos(event.todos);
3601
3738
  syncSidebarTodos(event.todos);
@@ -3607,6 +3744,10 @@ function OpenTuiApp(props) {
3607
3744
  bumpSidebar();
3608
3745
  }
3609
3746
  else if (event.type === "turn_end") {
3747
+ if (pendingStreamingRedrawTimer !== undefined) {
3748
+ clearTimeout(pendingStreamingRedrawTimer);
3749
+ pendingStreamingRedrawTimer = undefined;
3750
+ }
3610
3751
  if (event.usage) {
3611
3752
  setSidebarUsage((current) => ({
3612
3753
  contextTokens: event.usage.promptTokens || current.contextTokens,
@@ -3622,9 +3763,12 @@ function OpenTuiApp(props) {
3622
3763
  bumpSidebar();
3623
3764
  const assistantMessage = {
3624
3765
  role: "assistant",
3625
- content: assistantContent,
3766
+ content: currentTurnHasToolCall ? "" : assistantContent,
3626
3767
  reasoning: assistantReasoning || undefined,
3627
3768
  toolCalls: toolCalls.length ? [...toolCalls] : undefined,
3769
+ turnStartedAt,
3770
+ turnCompletedAt: Date.now(),
3771
+ turnUsage: event.usage,
3628
3772
  };
3629
3773
  const nextMessages = hasRenderableMessage(assistantMessage)
3630
3774
  ? [...displayMessages, assistantMessage]
@@ -3634,6 +3778,7 @@ function OpenTuiApp(props) {
3634
3778
  assistantContent = "";
3635
3779
  assistantReasoning = "";
3636
3780
  toolCalls.length = 0;
3781
+ turnStartedAt = undefined;
3637
3782
  streamingDisplay = undefined;
3638
3783
  }
3639
3784
  }
@@ -3645,6 +3790,10 @@ function OpenTuiApp(props) {
3645
3790
  }
3646
3791
  }
3647
3792
  finally {
3793
+ if (pendingStreamingRedrawTimer !== undefined) {
3794
+ clearTimeout(pendingStreamingRedrawTimer);
3795
+ pendingStreamingRedrawTimer = undefined;
3796
+ }
3648
3797
  pendingApprovalRef = undefined;
3649
3798
  setPendingApproval(undefined);
3650
3799
  setApprovalOptionIdx(0);
@@ -3736,7 +3885,7 @@ function OpenTuiApp(props) {
3736
3885
  paddingLeft: 2,
3737
3886
  paddingRight: 2,
3738
3887
  }, [
3739
- h("box", { flexShrink: 0, flexDirection: "column" }, ...HOME_LOGO.map((line) => h("text", { fg: theme.primary }, line))),
3888
+ h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center" }, ...HOME_LOGO.map((line) => h("text", { fg: homeLogoColor(line.tone) }, line.text || " "))),
3740
3889
  h("box", { height: 1, minHeight: 0, flexShrink: 1 }),
3741
3890
  h("box", {
3742
3891
  ref: (ref) => {
@@ -4481,7 +4630,6 @@ function OpenTuiApp(props) {
4481
4630
  if (isNewHost)
4482
4631
  transcriptState.entries = [];
4483
4632
  updateTranscriptHost(ref, transcriptState, currentTranscriptMessages(streamingDisplay), transcriptOptions(), props.syntaxStyle, props.subtleSyntaxStyle);
4484
- syncThinkingSpinner();
4485
4633
  syncPromptSurfaces(isNewHost);
4486
4634
  if (isNewHost)
4487
4635
  scheduleTranscriptScrollAfterUpdate(transcriptScrollFollowing, 0);
@@ -4820,19 +4968,31 @@ function renderAssistantMessage(message, syntaxStyle, subtleSyntaxStyle, showThi
4820
4968
  borderColor: theme.messageThinkingBorder,
4821
4969
  flexDirection: "column",
4822
4970
  flexShrink: 0,
4823
- }, renderMarkdownContent(formatThinkingMarkdown(visibleReasoning), subtleSyntaxStyle, {
4971
+ }, h("text", { content: thinkingLabelContent(message.streaming === true, reasoningElapsedMs(message)), fg: theme.messageThinkingText, wrapMode: "none" }), renderMarkdownContent(formatThinkingMarkdown(visibleReasoning), subtleSyntaxStyle, {
4824
4972
  streaming: message.streaming === true,
4825
- fg: theme.messageThinkingText,
4973
+ fg: theme.messageThinkingContentText,
4826
4974
  })));
4827
4975
  }
4828
- for (const tool of message.toolCalls ?? [])
4976
+ const toolCalls = message.toolCalls ?? [];
4977
+ for (const tool of toolCalls)
4829
4978
  children.push(renderTool(tool, syntaxStyle, width));
4830
- if (message.content.trim()) {
4831
- children.push(h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, renderMarkdownContent(message.content.trim(), syntaxStyle, {
4979
+ const trimmedContent = message.content.trim();
4980
+ if (trimmedContent && toolCalls.length > 0) {
4981
+ children.push(h("box", { paddingLeft: 3, marginTop: 1, flexShrink: 0 }, h("text", { content: answerDividerStyledText(), wrapMode: "none" })));
4982
+ }
4983
+ if (trimmedContent) {
4984
+ children.push(h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, renderMarkdownContent(trimmedContent, syntaxStyle, {
4832
4985
  streaming: message.streaming === true,
4833
4986
  fg: theme.messageAssistantText,
4834
4987
  })));
4835
4988
  }
4989
+ if (message.streaming === true && trimmedContent) {
4990
+ children.push(h("box", { paddingLeft: 3, flexShrink: 0 }, h("text", { fg: theme.primary, wrapMode: "none" }, "▌")));
4991
+ }
4992
+ const summaryString = formatTurnSummary(message);
4993
+ if (summaryString) {
4994
+ children.push(h("box", { paddingLeft: 3, marginTop: 1, flexShrink: 0 }, h("text", { fg: theme.textMuted, wrapMode: "none" }, summaryString)));
4995
+ }
4836
4996
  if (!children.length)
4837
4997
  return null;
4838
4998
  return h("box", { flexDirection: "column", flexShrink: 0 }, children);
@@ -4911,12 +5071,23 @@ function updateTranscriptHost(host, state, messages, options, syntaxStyle, subtl
4911
5071
  }
4912
5072
  for (const [index, message] of visibleMessages.entries()) {
4913
5073
  const key = transcriptMessageKey(message, index);
4914
- const thinkingExpanded = state.expandedThinking.has(key);
5074
+ if (state.defaultWritesExpanded) {
5075
+ for (const tool of message.toolCalls ?? []) {
5076
+ if (isWritePreviewTool(tool)) {
5077
+ state.expandedWrites.add(writeToolKey(key, tool));
5078
+ }
5079
+ }
5080
+ }
4915
5081
  const compactionExpanded = state.expandedCompactions.has(key);
4916
- const signature = transcriptMessageSignature(message, showThinking, thinkingExpanded, compactionExpanded);
5082
+ const signature = transcriptMessageSignature(message, compactionExpanded);
4917
5083
  const previous = state.entries[index];
4918
5084
  if (previous?.key === key && previous.signature === signature) {
4919
- updateMessageEntry(previous, message, showThinking, thinkingExpanded, compactionExpanded);
5085
+ updateMessageEntry(previous, message, showThinking, compactionExpanded, {
5086
+ syntaxStyle,
5087
+ expandedWrites: state.expandedWrites,
5088
+ width: options?.width ?? 80,
5089
+ onToggleWrite: options?.onToggleWrite,
5090
+ });
4920
5091
  nextEntries.push(previous);
4921
5092
  continue;
4922
5093
  }
@@ -4924,7 +5095,7 @@ function updateTranscriptHost(host, state, messages, options, syntaxStyle, subtl
4924
5095
  host.remove(previous.node.id);
4925
5096
  previous.node.destroyRecursively();
4926
5097
  }
4927
- const entry = createMessageEntry(ctx, message, index, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking, options?.width ?? 80, thinkingExpanded, compactionExpanded, options?.onToggleThinking, options?.onToggleCompaction);
5098
+ const entry = createMessageEntry(ctx, message, index, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking, options?.width ?? 80, compactionExpanded, state.expandedWrites, options?.onToggleCompaction, options?.onToggleWrite);
4928
5099
  if (entry) {
4929
5100
  host.add(entry.node, index);
4930
5101
  nextEntries.push(entry);
@@ -4967,34 +5138,23 @@ function clearTranscriptEntries(host, state) {
4967
5138
  function transcriptMessageKey(message, index) {
4968
5139
  return `${index}:${message.role}`;
4969
5140
  }
4970
- function transcriptMessageSignature(message, showThinking = true, thinkingExpanded = false, compactionExpanded = false) {
5141
+ function transcriptMessageSignature(message, compactionExpanded = false) {
4971
5142
  if (message.role !== "assistant")
4972
5143
  return message.role;
4973
5144
  if (message.syntheticKind === "ui_compact_card") {
4974
5145
  return `compaction:${compactionExpanded ? "expanded" : "collapsed"}:${message.compactionMeta?.turns ?? 0}`;
4975
5146
  }
4976
5147
  const modelSwitch = parseModelSwitchMessage(message.content);
4977
- const tools = (message.toolCalls ?? [])
4978
- .map((tool) => `${tool.id}:${tool.name}:${tool.status ?? (tool.result === undefined ? "pending" : "completed")}:${tool.isError ? "error" : "ok"}`)
4979
- .join("|");
4980
- const visibleReasoning = showThinking && !!message.reasoning?.trim();
5148
+ const pureModelSwitch = modelSwitch && !message.reasoning?.trim() && !(message.toolCalls?.length);
5149
+ if (pureModelSwitch) {
5150
+ return `assistant:model-switch:${hashString(modelSwitch)}`;
5151
+ }
4981
5152
  return [
4982
5153
  message.role,
4983
- modelSwitch ? "model-switch" : "standard",
4984
- message.status ?? "idle",
4985
- visibleReasoning ? (thinkingExpanded ? "reasoning-expanded" : "reasoning-collapsed") : "no-reasoning",
4986
- message.content.trim() ? "content" : "no-content",
4987
- tools,
5154
+ "standard",
4988
5155
  ].join(":");
4989
5156
  }
4990
- function hashString(value) {
4991
- let hash = 5381;
4992
- for (let index = 0; index < value.length; index++) {
4993
- hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
4994
- }
4995
- return (hash >>> 0).toString(36);
4996
- }
4997
- function updateMessageEntry(entry, message, showThinking = true, thinkingExpanded = false, compactionExpanded = false) {
5157
+ function updateMessageEntry(entry, message, showThinking = true, compactionExpanded = false, assistantOptions) {
4998
5158
  if (message.role === "user") {
4999
5159
  if (entry.refs.userText)
5000
5160
  entry.refs.userText.content = message.content || " ";
@@ -5018,21 +5178,163 @@ function updateMessageEntry(entry, message, showThinking = true, thinkingExpande
5018
5178
  }
5019
5179
  return;
5020
5180
  }
5181
+ if (assistantOptions) {
5182
+ updateAssistantEntry(entry, message, showThinking, assistantOptions);
5183
+ return;
5184
+ }
5185
+ }
5186
+ function updateAssistantEntry(entry, message, showThinking, options) {
5187
+ const content = message.content.trim();
5188
+ const visibleReasoning = showThinking ? message.reasoning?.trim() ?? "" : "";
5189
+ const tools = message.toolCalls ?? [];
5190
+ const showStatus = !!message.status && !visibleReasoning && !content && tools.length === 0;
5021
5191
  if (entry.refs.statusText) {
5022
- entry.refs.statusText.content = assistantStatusLabel(message);
5192
+ entry.refs.statusText.content = showStatus ? assistantStatusLabel(message) : "";
5193
+ }
5194
+ if (entry.refs.statusBox) {
5195
+ entry.refs.statusBox.visible = showStatus;
5023
5196
  }
5197
+ const streamingReasoning = message.streaming === true;
5024
5198
  if (entry.refs.reasoningToggleText) {
5025
- entry.refs.reasoningExpanded = thinkingExpanded;
5026
- entry.refs.reasoningStreaming = message.streaming === true;
5027
- entry.refs.reasoningToggleText.content = thinkingToggleLabel(thinkingExpanded, message.streaming === true);
5199
+ entry.refs.reasoningStreaming = streamingReasoning;
5200
+ entry.refs.reasoningToggleText.content = visibleReasoning
5201
+ ? thinkingLabelContent(streamingReasoning, reasoningElapsedMs(message))
5202
+ : new StyledText([fg(theme.messageThinkingText)("")]);
5203
+ }
5204
+ // During streaming we update only the plain text node — cheap per-delta. The
5205
+ // markdown node stays hidden + stale. Once streaming ends (turn_end), we
5206
+ // pay the parse cost exactly once and swap visibility.
5207
+ if (entry.refs.reasoningPlainText) {
5208
+ if (streamingReasoning) {
5209
+ entry.refs.reasoningPlainText.content = formatThinkingMarkdown(visibleReasoning);
5210
+ }
5211
+ entry.refs.reasoningPlainText.visible = streamingReasoning && !!visibleReasoning;
5028
5212
  }
5029
5213
  if (entry.refs.reasoningMarkdown) {
5030
- entry.refs.reasoningMarkdown.content = showThinking ? formatThinkingMarkdown(message.reasoning?.trim() ?? "") : "";
5031
- entry.refs.reasoningMarkdown.streaming = message.streaming === true;
5214
+ if (!streamingReasoning) {
5215
+ syncMarkdownRenderable(entry.refs.reasoningMarkdown, formatThinkingMarkdown(visibleReasoning), false);
5216
+ }
5217
+ entry.refs.reasoningMarkdown.visible = !streamingReasoning && !!visibleReasoning;
5218
+ }
5219
+ if (entry.refs.reasoningBox) {
5220
+ entry.refs.reasoningBox.visible = !!visibleReasoning;
5221
+ }
5222
+ updateAssistantToolEntries(entry, tools, options);
5223
+ if (entry.refs.answerDividerBox) {
5224
+ const showDivider = tools.length > 0 && !!content;
5225
+ entry.refs.answerDividerBox.visible = showDivider;
5226
+ if (entry.refs.answerDividerText) {
5227
+ entry.refs.answerDividerText.content = showDivider
5228
+ ? answerDividerStyledText()
5229
+ : new StyledText([fg(theme.textMuted)("")]);
5230
+ }
5032
5231
  }
5033
5232
  if (entry.refs.contentMarkdown) {
5034
- entry.refs.contentMarkdown.content = message.content.trim();
5035
- entry.refs.contentMarkdown.streaming = message.streaming === true;
5233
+ syncMarkdownRenderable(entry.refs.contentMarkdown, content, message.streaming === true);
5234
+ }
5235
+ if (entry.refs.contentBox) {
5236
+ entry.refs.contentBox.visible = !!content;
5237
+ }
5238
+ if (entry.refs.contentCursorBox) {
5239
+ const cursorActive = message.streaming === true && !!content;
5240
+ entry.refs.contentCursorBox.visible = cursorActive;
5241
+ if (entry.refs.contentCursorText)
5242
+ entry.refs.contentCursorText.content = cursorActive ? "▌" : "";
5243
+ }
5244
+ const summaryString = formatTurnSummary(message);
5245
+ if (entry.refs.turnSummaryText) {
5246
+ entry.refs.turnSummaryText.content = summaryString ?? "";
5247
+ }
5248
+ if (entry.refs.turnSummaryBox) {
5249
+ entry.refs.turnSummaryBox.visible = !!summaryString;
5250
+ }
5251
+ }
5252
+ function syncMarkdownRenderable(markdown, content, streaming) {
5253
+ if (markdown.content === content && markdown.streaming === streaming)
5254
+ return;
5255
+ markdown.content = content;
5256
+ markdown.streaming = streaming;
5257
+ markdown.clearCache();
5258
+ }
5259
+ function updateAssistantToolEntries(entry, tools, options) {
5260
+ const toolsBox = entry.refs.toolsBox;
5261
+ if (!toolsBox)
5262
+ return;
5263
+ toolsBox.visible = tools.length > 0;
5264
+ const previousEntries = entry.refs.toolEntries ?? new Map();
5265
+ const nextEntries = new Map();
5266
+ tools.forEach((tool, index) => {
5267
+ const toolKey = writeToolKey(entry.key, tool);
5268
+ const writeExpanded = options.expandedWrites.has(toolKey);
5269
+ const signature = toolRenderableSignature(tool, writeExpanded);
5270
+ const previous = previousEntries.get(tool.id);
5271
+ if (previous?.signature === signature) {
5272
+ nextEntries.set(tool.id, previous);
5273
+ return;
5274
+ }
5275
+ if (previous) {
5276
+ toolsBox.remove(previous.node.id);
5277
+ previous.node.destroyRecursively();
5278
+ }
5279
+ const node = createToolRenderable(toolsBox.ctx, tool, options.syntaxStyle, options.width, writeExpanded, isWritePreviewTool(tool) ? () => options.onToggleWrite?.(toolKey) : undefined);
5280
+ toolsBox.add(node, index);
5281
+ nextEntries.set(tool.id, { signature, node });
5282
+ });
5283
+ for (const [id, previous] of previousEntries.entries()) {
5284
+ if (nextEntries.has(id))
5285
+ continue;
5286
+ toolsBox.remove(previous.node.id);
5287
+ previous.node.destroyRecursively();
5288
+ }
5289
+ entry.refs.toolEntries = nextEntries;
5290
+ }
5291
+ function toolRenderableSignature(tool, writeExpanded) {
5292
+ return [
5293
+ tool.id,
5294
+ tool.name,
5295
+ tool.status ?? (tool.result === undefined ? "pending" : "completed"),
5296
+ tool.isError ? "error" : "ok",
5297
+ tool.streamingArgs ? "streaming-args" : "args-complete",
5298
+ writeExpanded ? "expanded" : "collapsed",
5299
+ hashString(stableStringify(tool.args)),
5300
+ hashString(tool.rawArguments ?? ""),
5301
+ hashString(tool.result ?? ""),
5302
+ hashString(stableStringify(tool.metadata ?? null)),
5303
+ ].join(":");
5304
+ }
5305
+ function mergeToolMetadata(current, incoming) {
5306
+ if (!incoming)
5307
+ return current;
5308
+ if (current?.kind !== "subagent" || incoming.kind !== "subagent") {
5309
+ return incoming;
5310
+ }
5311
+ const currentSubagents = Array.isArray(current.subagents) ? current.subagents : [];
5312
+ const incomingSubagents = Array.isArray(incoming.subagents) ? incoming.subagents : [];
5313
+ const byId = new Map();
5314
+ for (const item of currentSubagents) {
5315
+ const subAgentId = typeof item === "object" && item !== null && "subAgentId" in item
5316
+ ? String(item.subAgentId)
5317
+ : "";
5318
+ byId.set(subAgentId || `current:${byId.size}`, item);
5319
+ }
5320
+ for (const item of incomingSubagents) {
5321
+ const subAgentId = typeof item === "object" && item !== null && "subAgentId" in item
5322
+ ? String(item.subAgentId)
5323
+ : "";
5324
+ byId.set(subAgentId || `incoming:${byId.size}`, item);
5325
+ }
5326
+ return {
5327
+ ...current,
5328
+ ...incoming,
5329
+ subagents: [...byId.values()],
5330
+ };
5331
+ }
5332
+ function stableStringify(value) {
5333
+ try {
5334
+ return JSON.stringify(value) ?? "";
5335
+ }
5336
+ catch {
5337
+ return String(value);
5036
5338
  }
5037
5339
  }
5038
5340
  function createBox(ctx, options, children = []) {
@@ -5056,6 +5358,7 @@ function createMarkdown(ctx, content, syntaxStyle, options) {
5056
5358
  content,
5057
5359
  syntaxStyle,
5058
5360
  treeSitterClient,
5361
+ renderNode: createSemanticMarkdownRenderNode(ctx, options?.fg ?? theme.messageAssistantText),
5059
5362
  streaming: options?.streaming === true,
5060
5363
  conceal: true,
5061
5364
  concealCode: false,
@@ -5075,6 +5378,87 @@ function createMarkdown(ctx, content, syntaxStyle, options) {
5075
5378
  },
5076
5379
  });
5077
5380
  }
5381
+ function createSemanticMarkdownRenderNode(ctx, defaultFg) {
5382
+ const palette = semanticMarkdownPalette(defaultFg);
5383
+ return (token, context) => {
5384
+ switch (token?.type) {
5385
+ case "hr":
5386
+ return createText(ctx, new StyledText([
5387
+ fg(theme.borderSubtle)("─".repeat(48)),
5388
+ ]), {
5389
+ fg: theme.borderSubtle,
5390
+ wrapMode: "none",
5391
+ flexShrink: 0,
5392
+ });
5393
+ case "heading":
5394
+ return createText(ctx, markdownInlineToStyledText(markdownTokenInlineTokens(token), palette, token.text ?? "", { bold: true }), {
5395
+ fg: defaultFg,
5396
+ wrapMode: "word",
5397
+ flexShrink: 0,
5398
+ });
5399
+ case "paragraph":
5400
+ return createText(ctx, markdownInlineToStyledText(markdownTokenInlineTokens(token), palette, token.text ?? ""), {
5401
+ fg: defaultFg,
5402
+ wrapMode: "word",
5403
+ flexShrink: 0,
5404
+ });
5405
+ case "list":
5406
+ return createMarkdownList(ctx, token, palette, defaultFg);
5407
+ default:
5408
+ return context.defaultRender();
5409
+ }
5410
+ };
5411
+ }
5412
+ function createMarkdownList(ctx, token, palette, defaultFg) {
5413
+ const ordered = token?.ordered === true;
5414
+ const start = typeof token?.start === "number" ? token.start : 1;
5415
+ const items = Array.isArray(token?.items) ? token.items : [];
5416
+ if (items.length === 0)
5417
+ return null;
5418
+ return createBox(ctx, {
5419
+ flexDirection: "column",
5420
+ flexShrink: 0,
5421
+ }, items.map((item, index) => {
5422
+ const marker = ordered ? `${start + index}. ` : "• ";
5423
+ return createText(ctx, new StyledText([
5424
+ fg(theme.textMuted)(marker),
5425
+ ...markdownInlineToStyledText(markdownTokenInlineTokens(item), palette, item.text ?? "").chunks,
5426
+ ]), {
5427
+ fg: defaultFg,
5428
+ wrapMode: "word",
5429
+ flexShrink: 0,
5430
+ });
5431
+ }));
5432
+ }
5433
+ function markdownTokenInlineTokens(token) {
5434
+ if (Array.isArray(token?.tokens))
5435
+ return token.tokens;
5436
+ if (typeof token?.text === "string")
5437
+ return [{ type: "text", text: token.text }];
5438
+ return undefined;
5439
+ }
5440
+ function semanticMarkdownPalette(defaultFg) {
5441
+ return {
5442
+ text: defaultFg,
5443
+ textMuted: theme.textMuted,
5444
+ success: theme.success,
5445
+ warning: theme.warning,
5446
+ secondary: theme.secondary,
5447
+ };
5448
+ }
5449
+ function markdownInlineToStyledText(tokens, palette, fallback = "", style = {}) {
5450
+ const chunks = markdownInlineSegments(tokens, fallback, style).map((segment) => {
5451
+ let chunk = fg(palette[segment.color ?? "text"])(segment.text);
5452
+ if (segment.bold)
5453
+ chunk = bold(chunk);
5454
+ if (segment.italic)
5455
+ chunk = italic(chunk);
5456
+ if (segment.dim)
5457
+ chunk = dim(chunk);
5458
+ return chunk;
5459
+ });
5460
+ return new StyledText(chunks);
5461
+ }
5078
5462
  function createDiffRenderable(ctx, diff, filePath, syntaxStyle, width = 80) {
5079
5463
  return new DiffRenderable(ctx, {
5080
5464
  diff,
@@ -5137,6 +5521,24 @@ function createCodeBlockRenderable(ctx, content, filePath, syntaxStyle) {
5137
5521
  lineNumbers.add(code);
5138
5522
  return lineNumbers;
5139
5523
  }
5524
+ function createToolRenderHelpers() {
5525
+ return {
5526
+ theme,
5527
+ createBox: (ctx, options, children) => createBox(ctx, options, children),
5528
+ createText: (ctx, content, options) => createText(ctx, content, (options ?? {})),
5529
+ createCodeBlockRenderable,
5530
+ createDiffRenderable,
5531
+ toolColor,
5532
+ displayToolName,
5533
+ toolHeader,
5534
+ toolPath,
5535
+ extractToolDiff,
5536
+ summarizeToolResult,
5537
+ isToolFinished,
5538
+ toolPreview,
5539
+ toolStateIcon,
5540
+ };
5541
+ }
5140
5542
  function renderCodeBlockContent(content, filePath, syntaxStyle) {
5141
5543
  return h("line_number", { fg: theme.textMuted, minWidth: 3, paddingRight: 1 }, h("code", {
5142
5544
  content,
@@ -5148,14 +5550,14 @@ function renderCodeBlockContent(content, filePath, syntaxStyle) {
5148
5550
  width: "100%",
5149
5551
  }));
5150
5552
  }
5151
- function createMessageEntry(ctx, message, index, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking = true, width = 80, thinkingExpanded = false, compactionExpanded = false, onToggleThinking, onToggleCompaction) {
5553
+ function createMessageEntry(ctx, message, index, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking = true, width = 80, compactionExpanded = false, expandedWrites = new Set(), onToggleCompaction, onToggleWrite) {
5152
5554
  if (message.role === "user")
5153
5555
  return createUserEntry(ctx, message, index, key, signature);
5154
5556
  if (message.role === "error")
5155
5557
  return createErrorEntry(ctx, message, key, signature);
5156
5558
  if (message.syntheticKind === "ui_compact_card")
5157
5559
  return createCompactionCardEntry(ctx, message, key, signature, compactionExpanded, onToggleCompaction);
5158
- return createAssistantEntry(ctx, message, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking, width, thinkingExpanded, onToggleThinking);
5560
+ return createAssistantEntry(ctx, message, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking, width, expandedWrites, onToggleWrite);
5159
5561
  }
5160
5562
  function createUserEntry(ctx, message, index, key, signature) {
5161
5563
  const refs = {};
@@ -5200,7 +5602,7 @@ function createErrorEntry(ctx, message, key, signature) {
5200
5602
  }, [text]);
5201
5603
  return { key, signature, node, refs };
5202
5604
  }
5203
- function createAssistantEntry(ctx, message, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking = true, width = 80, thinkingExpanded = false, onToggleThinking) {
5605
+ function createAssistantEntry(ctx, message, syntaxStyle, subtleSyntaxStyle, key, signature, showThinking = true, width = 80, expandedWrites = new Set(), onToggleWrite) {
5204
5606
  const modelSwitch = parseModelSwitchMessage(message.content);
5205
5607
  if (modelSwitch && !message.reasoning?.trim() && !(message.toolCalls?.length)) {
5206
5608
  return createModelSwitchEntry(ctx, modelSwitch, key, signature);
@@ -5208,70 +5610,135 @@ function createAssistantEntry(ctx, message, syntaxStyle, subtleSyntaxStyle, key,
5208
5610
  const children = [];
5209
5611
  const refs = {};
5210
5612
  const visibleReasoning = showThinking ? message.reasoning?.trim() : "";
5211
- if (message.status && !visibleReasoning && !message.content.trim() && !(message.toolCalls?.length)) {
5212
- const status = createText(ctx, assistantStatusLabel(message), {
5213
- fg: theme.messageThinkingText,
5214
- });
5215
- refs.statusText = status;
5216
- children.push(createBox(ctx, {
5217
- paddingLeft: 3,
5218
- marginTop: 1,
5219
- flexShrink: 0,
5220
- }, [status]));
5221
- }
5222
- if (visibleReasoning) {
5223
- const reasoningChildren = [];
5224
- const toggleText = createText(ctx, thinkingToggleLabel(thinkingExpanded, message.streaming === true), {
5225
- fg: theme.messageThinkingText,
5226
- wrapMode: "none",
5227
- });
5228
- refs.reasoningToggleText = toggleText;
5229
- refs.reasoningExpanded = thinkingExpanded;
5230
- refs.reasoningStreaming = message.streaming === true;
5231
- reasoningChildren.push(createBox(ctx, {
5232
- flexShrink: 0,
5233
- onMouseUp: () => onToggleThinking?.(key),
5234
- }, [toggleText]));
5235
- if (thinkingExpanded) {
5236
- const markdown = createMarkdown(ctx, formatThinkingMarkdown(visibleReasoning), subtleSyntaxStyle, {
5237
- streaming: message.streaming === true,
5238
- fg: theme.messageThinkingText,
5239
- });
5240
- refs.reasoningMarkdown = markdown;
5241
- reasoningChildren.push(markdown);
5242
- }
5243
- children.push(createBox(ctx, {
5244
- paddingLeft: 2,
5245
- marginTop: 1,
5246
- border: ["left"],
5247
- borderColor: theme.messageThinkingBorder,
5248
- flexDirection: "column",
5249
- flexShrink: 0,
5250
- }, reasoningChildren));
5251
- }
5252
- for (const tool of message.toolCalls ?? [])
5253
- children.push(createToolRenderable(ctx, tool, syntaxStyle, width));
5254
- if (message.content.trim()) {
5255
- const markdown = createMarkdown(ctx, message.content.trim(), syntaxStyle, {
5256
- streaming: message.streaming === true,
5257
- fg: theme.messageAssistantText,
5258
- });
5259
- refs.contentMarkdown = markdown;
5260
- children.push(createBox(ctx, {
5261
- paddingLeft: 3,
5262
- marginTop: 1,
5263
- flexDirection: "column",
5613
+ const content = message.content.trim();
5614
+ const tools = message.toolCalls ?? [];
5615
+ const showStatus = !!message.status && !visibleReasoning && !content && tools.length === 0;
5616
+ const status = createText(ctx, assistantStatusLabel(message), {
5617
+ fg: theme.messageThinkingText,
5618
+ });
5619
+ refs.statusText = status;
5620
+ const statusBox = createBox(ctx, {
5621
+ paddingLeft: 3,
5622
+ marginTop: 1,
5623
+ flexShrink: 0,
5624
+ visible: showStatus,
5625
+ }, [status]);
5626
+ refs.statusBox = statusBox;
5627
+ children.push(statusBox);
5628
+ const labelText = createText(ctx, thinkingLabelContent(message.streaming === true, reasoningElapsedMs(message)), {
5629
+ fg: theme.messageThinkingText,
5630
+ wrapMode: "none",
5631
+ });
5632
+ refs.reasoningToggleText = labelText;
5633
+ const streamingReasoning = message.streaming === true;
5634
+ refs.reasoningStreaming = streamingReasoning;
5635
+ // While the model is still streaming we render reasoning as plain text — a
5636
+ // single TextRenderable.content update is cheap, whereas re-parsing markdown
5637
+ // (treesitter + cache clear) per token grows to O(N²) and freezes the TUI.
5638
+ // The markdown variant is parsed once at turn_end and only then becomes
5639
+ // visible.
5640
+ const plainText = createText(ctx, formatThinkingMarkdown(visibleReasoning ?? ""), {
5641
+ fg: theme.messageThinkingContentText,
5642
+ wrapMode: "word",
5643
+ visible: streamingReasoning && !!visibleReasoning,
5644
+ });
5645
+ refs.reasoningPlainText = plainText;
5646
+ const markdown = createMarkdown(ctx, streamingReasoning ? "" : formatThinkingMarkdown(visibleReasoning ?? ""), subtleSyntaxStyle, {
5647
+ streaming: false,
5648
+ fg: theme.messageThinkingContentText,
5649
+ });
5650
+ markdown.visible = !streamingReasoning && !!visibleReasoning;
5651
+ refs.reasoningMarkdown = markdown;
5652
+ const reasoningBox = createBox(ctx, {
5653
+ paddingLeft: 2,
5654
+ marginTop: 1,
5655
+ border: ["left"],
5656
+ borderColor: theme.messageThinkingBorder,
5657
+ flexDirection: "column",
5658
+ flexShrink: 0,
5659
+ visible: !!visibleReasoning,
5660
+ }, [
5661
+ createBox(ctx, {
5264
5662
  flexShrink: 0,
5265
- }, [markdown]));
5266
- }
5267
- if (!children.length)
5268
- return null;
5269
- return {
5663
+ }, [labelText]),
5664
+ plainText,
5665
+ markdown,
5666
+ ]);
5667
+ refs.reasoningBox = reasoningBox;
5668
+ children.push(reasoningBox);
5669
+ const toolsBox = createBox(ctx, {
5670
+ flexDirection: "column",
5671
+ flexShrink: 0,
5672
+ visible: tools.length > 0,
5673
+ });
5674
+ refs.toolsBox = toolsBox;
5675
+ refs.toolEntries = new Map();
5676
+ children.push(toolsBox);
5677
+ const showAnswerDivider = tools.length > 0 && !!content;
5678
+ const answerDividerText = createText(ctx, showAnswerDivider ? answerDividerStyledText() : new StyledText([fg(theme.textMuted)("")]), { wrapMode: "none" });
5679
+ refs.answerDividerText = answerDividerText;
5680
+ const answerDividerBox = createBox(ctx, {
5681
+ paddingLeft: 3,
5682
+ marginTop: 1,
5683
+ flexShrink: 0,
5684
+ visible: showAnswerDivider,
5685
+ }, [answerDividerText]);
5686
+ refs.answerDividerBox = answerDividerBox;
5687
+ children.push(answerDividerBox);
5688
+ const contentMarkdown = createMarkdown(ctx, content, syntaxStyle, {
5689
+ streaming: message.streaming === true,
5690
+ fg: theme.messageAssistantText,
5691
+ });
5692
+ refs.contentMarkdown = contentMarkdown;
5693
+ const contentBox = createBox(ctx, {
5694
+ paddingLeft: 3,
5695
+ marginTop: 1,
5696
+ flexDirection: "column",
5697
+ flexShrink: 0,
5698
+ visible: !!content,
5699
+ }, [contentMarkdown]);
5700
+ refs.contentBox = contentBox;
5701
+ children.push(contentBox);
5702
+ const cursorActive = message.streaming === true && !!content;
5703
+ const contentCursorText = createText(ctx, "▌", { fg: theme.primary, wrapMode: "none" });
5704
+ refs.contentCursorText = contentCursorText;
5705
+ const contentCursorBox = createBox(ctx, {
5706
+ paddingLeft: 3,
5707
+ flexShrink: 0,
5708
+ visible: cursorActive,
5709
+ }, [contentCursorText]);
5710
+ refs.contentCursorBox = contentCursorBox;
5711
+ children.push(contentCursorBox);
5712
+ const summaryString = formatTurnSummary(message);
5713
+ const turnSummaryText = createText(ctx, summaryString ?? "", { fg: theme.textMuted, wrapMode: "none" });
5714
+ refs.turnSummaryText = turnSummaryText;
5715
+ const turnSummaryBox = createBox(ctx, {
5716
+ paddingLeft: 3,
5717
+ marginTop: 1,
5718
+ flexShrink: 0,
5719
+ visible: !!summaryString,
5720
+ }, [turnSummaryText]);
5721
+ refs.turnSummaryBox = turnSummaryBox;
5722
+ children.push(turnSummaryBox);
5723
+ const entry = {
5270
5724
  key,
5271
5725
  signature,
5272
5726
  node: createBox(ctx, { flexDirection: "column", flexShrink: 0 }, children),
5273
5727
  refs,
5274
5728
  };
5729
+ updateAssistantToolEntries(entry, tools, {
5730
+ syntaxStyle,
5731
+ expandedWrites,
5732
+ width,
5733
+ onToggleWrite,
5734
+ });
5735
+ return entry;
5736
+ }
5737
+ function answerDividerStyledText() {
5738
+ return new StyledText([
5739
+ fg(theme.accent)("◆ "),
5740
+ fg(theme.textMuted)(italic("Answer")),
5741
+ ]);
5275
5742
  }
5276
5743
  function createCompactionCardEntry(ctx, message, key, signature, expanded, onToggle) {
5277
5744
  const refs = {};
@@ -5389,7 +5856,7 @@ function createTodoWriteRenderable(ctx, tool) {
5389
5856
  flexDirection: "column",
5390
5857
  flexShrink: 0,
5391
5858
  }, [
5392
- createText(ctx, `~ Planning tasks...`, { fg: toolColor(tool) }),
5859
+ createText(ctx, `→ Planning tasks...`, { fg: toolColor(tool) }),
5393
5860
  ]);
5394
5861
  }
5395
5862
  return createBox(ctx, {
@@ -5416,90 +5883,26 @@ function createTodoWriteRenderable(ctx, tool) {
5416
5883
  }),
5417
5884
  ]);
5418
5885
  }
5419
- function createToolRenderable(ctx, tool, syntaxStyle, width = 80) {
5886
+ function createToolRenderable(ctx, tool, syntaxStyle, width = 80, writeExpanded = false, onToggleWrite) {
5420
5887
  if (tool.name === "question") {
5421
5888
  return createQuestionToolRenderable(ctx, tool);
5422
5889
  }
5423
5890
  if (tool.name === "todo_write") {
5424
5891
  return createTodoWriteRenderable(ctx, tool);
5425
5892
  }
5426
- const icon = tool.name === "bash" ? "$" : tool.name === "edit" || tool.name === "write" ? "✎" : "●";
5427
- const color = toolColor(tool);
5428
- const header = toolHeader(tool);
5429
- const diff = extractToolDiff(tool);
5430
- if (diff && !tool.isError && tool.name === "edit") {
5431
- return createBox(ctx, {
5432
- paddingLeft: 3,
5433
- marginTop: 1,
5434
- flexDirection: "column",
5435
- flexShrink: 0,
5436
- }, [
5437
- createText(ctx, new StyledText([
5438
- fg(color)(`${icon} ${displayToolName(tool.name)}`),
5439
- fg(theme.toolText)(header ? ` ${header}` : ""),
5440
- ])),
5441
- createBox(ctx, {
5442
- paddingLeft: 1,
5443
- marginTop: 1,
5444
- border: ["left"],
5445
- borderColor: theme.borderSubtle,
5446
- flexDirection: "column",
5447
- flexShrink: 0,
5448
- }, [createDiffRenderable(ctx, diff, toolPath(tool), syntaxStyle, width)]),
5449
- ]);
5450
- }
5451
- if (!tool.isError && tool.name === "write" && typeof tool.args?.content === "string" && isToolFinished(tool)) {
5452
- return createBox(ctx, {
5453
- paddingLeft: 3,
5454
- marginTop: 1,
5455
- flexDirection: "column",
5456
- flexShrink: 0,
5457
- }, [
5458
- createText(ctx, new StyledText([
5459
- fg(color)(`${icon} ${displayToolName(tool.name)}`),
5460
- fg(theme.toolText)(header ? ` ${header}` : ""),
5461
- ])),
5462
- createBox(ctx, {
5463
- paddingLeft: 1,
5464
- marginTop: 1,
5465
- border: ["left"],
5466
- borderColor: theme.borderSubtle,
5467
- flexDirection: "column",
5468
- flexShrink: 0,
5469
- }, [createCodeBlockRenderable(ctx, tool.args.content, toolPath(tool), syntaxStyle)]),
5470
- ]);
5471
- }
5472
- const chunks = [
5473
- fg(color)(`${isToolFinished(tool) ? "" : "~ "}${icon} ${displayToolName(tool.name)}`),
5474
- ];
5475
- if (header)
5476
- chunks.push(fg(theme.toolText)(` ${header}`));
5477
- if (tool.result) {
5478
- chunks.push(fg(theme.text)("\n"));
5479
- chunks.push(fg(theme.borderSubtle)(" "));
5480
- chunks.push(fg(tool.isError ? theme.toolError : theme.textMuted)(summarizeToolResult(tool)));
5481
- const preview = toolPreview(tool);
5482
- if (preview) {
5483
- for (const line of preview.lines) {
5484
- chunks.push(fg(theme.text)("\n"));
5485
- chunks.push(fg(theme.borderSubtle)(" "));
5486
- chunks.push(fg(theme.toolText)(line));
5487
- }
5488
- if (preview.omitted > 0) {
5489
- chunks.push(fg(theme.text)("\n"));
5490
- chunks.push(fg(theme.borderSubtle)(" "));
5491
- chunks.push(fg(theme.textMuted)(`+ ${preview.omitted} more`));
5492
- }
5493
- }
5893
+ const renderer = findToolRenderer(tool);
5894
+ if (renderer) {
5895
+ return renderer.render({
5896
+ ctx,
5897
+ tool,
5898
+ syntaxStyle,
5899
+ width,
5900
+ writeExpanded,
5901
+ onToggleWrite,
5902
+ helpers: createToolRenderHelpers(),
5903
+ });
5494
5904
  }
5495
- return createBox(ctx, {
5496
- paddingLeft: 3,
5497
- marginTop: 1,
5498
- flexDirection: "column",
5499
- flexShrink: 0,
5500
- }, [
5501
- createText(ctx, new StyledText(chunks), { wrapMode: "word" }),
5502
- ]);
5905
+ throw new Error(`No renderer for tool '${tool.name}'`);
5503
5906
  }
5504
5907
  function createQuestionToolRenderable(ctx, tool) {
5505
5908
  const questions = questionToolQuestions(tool);
@@ -5512,7 +5915,7 @@ function createQuestionToolRenderable(ctx, tool) {
5512
5915
  flexDirection: "column",
5513
5916
  flexShrink: 0,
5514
5917
  }, [
5515
- createText(ctx, `${isToolFinished(tool) ? "" : "~ "}→ ${rejected ? "Asked" : "Asking"} questions...`, {
5918
+ createText(ctx, `→ ${rejected ? "Asked" : "Asking"} questions...`, {
5516
5919
  fg: rejected ? theme.textMuted : toolColor(tool),
5517
5920
  attributes: rejected ? TextAttributes.STRIKETHROUGH : undefined,
5518
5921
  }),
@@ -5558,16 +5961,27 @@ function renderTool(tool, syntaxStyle, width = 80) {
5558
5961
  if (tool.name === "question") {
5559
5962
  return renderQuestionTool(tool);
5560
5963
  }
5561
- const icon = tool.name === "bash" ? "$" : tool.name === "edit" || tool.name === "write" ? "✎" : "●";
5964
+ const icon = toolStateIcon(tool);
5562
5965
  const color = toolColor(tool);
5563
5966
  const diff = extractToolDiff(tool);
5564
5967
  if (diff && !tool.isError && tool.name === "edit") {
5565
5968
  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)));
5566
5969
  }
5567
- if (!tool.isError && tool.name === "write" && typeof tool.args?.content === "string" && isToolFinished(tool)) {
5568
- 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)));
5569
- }
5570
- 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);
5970
+ if (isWritePreviewTool(tool)) {
5971
+ const hasContent = typeof tool.args.content === "string";
5972
+ const contentStr = hasContent ? String(tool.args.content) : "";
5973
+ const preview = hasContent ? formatWritePreview(contentStr, false) : null;
5974
+ const lineCount = hasContent
5975
+ ? contentStr.split(/\r?\n/).length
5976
+ : (tool.streamingNewlineCount ?? 0) + 1;
5977
+ const summary = tool.result ?? `${isToolFinished(tool) ? "Prepared" : "Writing"} ${lineCount} lines to ${toolPath(tool) ?? "file"}`;
5978
+ 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}`), preview ? renderCodeBlockContent(preview.content, toolPath(tool), syntaxStyle) : null, preview && preview.omittedLines > 0
5979
+ ? h("text", { fg: theme.textMuted }, `... +${preview.omittedLines} lines (ctrl+o to expand)`)
5980
+ : preview && preview.omittedChars > 0
5981
+ ? h("text", { fg: theme.textMuted }, `... +${preview.omittedChars} chars (ctrl+o to expand)`)
5982
+ : null));
5983
+ }
5984
+ 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);
5571
5985
  }
5572
5986
  function renderQuestionTool(tool) {
5573
5987
  const questions = questionToolQuestions(tool);
@@ -5577,7 +5991,7 @@ function renderQuestionTool(tool) {
5577
5991
  return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", {
5578
5992
  fg: rejected ? theme.textMuted : toolColor(tool),
5579
5993
  attributes: rejected ? TextAttributes.STRIKETHROUGH : undefined,
5580
- }, `${isToolFinished(tool) ? "" : "~ "}→ ${rejected ? "Asked" : "Asking"} questions...`));
5994
+ }, `→ ${rejected ? "Asked" : "Asking"} questions...`));
5581
5995
  }
5582
5996
  return h("box", {
5583
5997
  border: ["left"],
@@ -5842,11 +6256,9 @@ function formatDisplayContentParts(content, labelStart) {
5842
6256
  function reconstructDisplayMessages(agentMessages) {
5843
6257
  const result = [];
5844
6258
  for (const message of agentMessages) {
5845
- if (message.role === "system" || message.role === "tool")
6259
+ if (message.role === "system" || message.role === "meta" || message.role === "tool")
5846
6260
  continue;
5847
6261
  if (message.role === "user") {
5848
- if (message.isMeta)
5849
- continue;
5850
6262
  result.push({
5851
6263
  role: "user",
5852
6264
  content: typeof message.content === "string"
@@ -5935,7 +6347,9 @@ function formatTranscript(messages, options) {
5935
6347
  if (visibleReasoning) {
5936
6348
  appendBlank();
5937
6349
  append("│ ", theme.messageThinkingBorder);
5938
- appendLine(truncate(formatThinkingMarkdown(visibleReasoning), 500), theme.messageThinkingText);
6350
+ chunks.push(fg(theme.messageThinkingText)(italic("Thinking\n")));
6351
+ append("│ ", theme.messageThinkingBorder);
6352
+ appendLine(truncate(formatThinkingMarkdown(visibleReasoning), 500), theme.messageThinkingContentText);
5939
6353
  }
5940
6354
  if (message.status && !visibleReasoning && !message.content.trim() && !(message.toolCalls?.length)) {
5941
6355
  appendBlank();
@@ -5946,7 +6360,7 @@ function formatTranscript(messages, options) {
5946
6360
  appendBlank();
5947
6361
  const icon = tool.name === "bash" ? "$" : tool.name === "edit" || tool.name === "write" ? "✎" : "●";
5948
6362
  const color = toolColor(tool);
5949
- append(` ${isToolFinished(tool) ? "" : "~ "}${icon} `, color);
6363
+ append(` ${icon} `, color);
5950
6364
  append(displayToolName(tool.name), color);
5951
6365
  const header = toolHeader(tool);
5952
6366
  if (header)
@@ -5996,7 +6410,7 @@ function renderHomeState(input) {
5996
6410
  flexDirection: "column",
5997
6411
  alignItems: "center",
5998
6412
  justifyContent: "center",
5999
- }, 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));
6413
+ }, 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));
6000
6414
  }
6001
6415
  function hasRenderableMessage(message, showThinking = true) {
6002
6416
  if (message.role === "error")
@@ -6172,6 +6586,11 @@ function displayToolName(name) {
6172
6586
  glob: "Glob",
6173
6587
  web_fetch: "WebFetch",
6174
6588
  web_search: "WebSearch",
6589
+ subagent: "Subagent",
6590
+ spawn_agent: "SpawnAgent",
6591
+ wait_agent: "WaitAgent",
6592
+ send_input: "SendInput",
6593
+ close_agent: "CloseAgent",
6175
6594
  task: "Task",
6176
6595
  todo: "Todo",
6177
6596
  question: "Questions",
@@ -6180,6 +6599,20 @@ function displayToolName(name) {
6180
6599
  }
6181
6600
  function toolHeader(tool) {
6182
6601
  const args = tool.args || {};
6602
+ if (tool.name === "subagent") {
6603
+ if (typeof args.agent === "string")
6604
+ return `(${args.agent})`;
6605
+ if (Array.isArray(args.tasks))
6606
+ return `(${args.tasks.length} tasks)`;
6607
+ }
6608
+ if (tool.name === "spawn_agent") {
6609
+ const agent = args.agent_type ?? args.agent ?? "default";
6610
+ return `(${agent})`;
6611
+ }
6612
+ if (tool.name === "wait_agent" || tool.name === "send_input" || tool.name === "close_agent") {
6613
+ const agentId = args.agent_id ?? (Array.isArray(args.agent_ids) ? `${args.agent_ids.length} agents` : undefined);
6614
+ return agentId ? `(${truncate(String(agentId), 64)})` : "";
6615
+ }
6183
6616
  const value = args.path ?? args.command ?? args.pattern ?? args.url ?? args.query;
6184
6617
  return value ? `(${truncate(String(value).replace(/\n/g, " "), 64)})` : "";
6185
6618
  }
@@ -6225,8 +6658,11 @@ function filetype(filePath) {
6225
6658
  return ext ? map[ext] : undefined;
6226
6659
  }
6227
6660
  function summarizeToolResult(tool) {
6228
- if (!isToolFinished(tool))
6229
- return tool.status === "running" ? "running" : "pending";
6661
+ if (!isToolFinished(tool)) {
6662
+ if (tool.status === "running")
6663
+ return "running";
6664
+ return tool.streamingArgs ? "preparing" : "pending";
6665
+ }
6230
6666
  if (tool.name === "question") {
6231
6667
  if (isQuestionRejected(tool))
6232
6668
  return "dismissed";
@@ -6237,14 +6673,47 @@ function summarizeToolResult(tool) {
6237
6673
  if (tool.isError)
6238
6674
  return truncate(result.split("\n").find(Boolean) || "error", 120);
6239
6675
  const lines = result.replace(/\r\n/g, "\n").split("\n").filter((line) => line.trim()).length;
6676
+ const matches = typeof tool.metadata?.matches === "number" ? tool.metadata.matches : undefined;
6677
+ if (tool.name === "read")
6678
+ return "";
6240
6679
  if (tool.name === "edit")
6241
6680
  return "patched file";
6242
6681
  if (tool.name === "write")
6243
6682
  return "wrote file";
6244
- if (tool.name === "bash")
6683
+ if (tool.name === "grep" || tool.name === "glob") {
6684
+ if (matches !== undefined)
6685
+ return `${matches} match${matches === 1 ? "" : "es"}`;
6686
+ return lines ? `${lines} line${lines === 1 ? "" : "s"}` : "no matches";
6687
+ }
6688
+ if (tool.name === "bash") {
6689
+ if (matches !== undefined)
6690
+ return `${matches} match${matches === 1 ? "" : "es"}`;
6245
6691
  return lines ? `${lines} line${lines === 1 ? "" : "s"} output` : "done";
6692
+ }
6246
6693
  return lines ? `${lines} line${lines === 1 ? "" : "s"}` : "done";
6247
6694
  }
6695
+ function toolStateIcon(tool) {
6696
+ if (tool.isError || tool.status === "error")
6697
+ return "✗";
6698
+ if (!isToolFinished(tool)) {
6699
+ if (tool.status === "running")
6700
+ return "◐";
6701
+ return "◌";
6702
+ }
6703
+ if (tool.name === "bash")
6704
+ return "$";
6705
+ if (tool.name === "edit")
6706
+ return "✎";
6707
+ if (tool.name === "write")
6708
+ return "✎";
6709
+ if (tool.name === "read")
6710
+ return "▤";
6711
+ if (tool.name === "grep" || tool.name === "glob")
6712
+ return "⌕";
6713
+ if (tool.name === "web_fetch" || tool.name === "web_search")
6714
+ return "⌖";
6715
+ return "●";
6716
+ }
6248
6717
  function toolSummaryWithPreview(tool) {
6249
6718
  const summary = ` ${summarizeToolResult(tool)}`;
6250
6719
  const preview = toolPreview(tool);
@@ -6259,7 +6728,7 @@ function toolSummaryWithPreview(tool) {
6259
6728
  function toolPreview(tool) {
6260
6729
  if (!isToolFinished(tool) || tool.isError || !tool.result)
6261
6730
  return undefined;
6262
- if (tool.name !== "read" && tool.name !== "glob")
6731
+ if (tool.name !== "glob")
6263
6732
  return undefined;
6264
6733
  const lines = tool.result
6265
6734
  .replace(/\r\n/g, "\n")
@@ -6315,7 +6784,10 @@ function isToolFinished(tool) {
6315
6784
  function assistantStatusLabel(message) {
6316
6785
  if (message.status === "responding")
6317
6786
  return "Responding...";
6318
- return message.streaming ? "Thinking..." : "Thinking";
6787
+ const elapsed = formatDuration(reasoningElapsedMs(message));
6788
+ if (message.streaming)
6789
+ return elapsed ? `Thinking ${elapsed}...` : "Thinking...";
6790
+ return elapsed ? `Thought for ${elapsed}` : "Thinking";
6319
6791
  }
6320
6792
  function buildContextGauge(percent, barWidth) {
6321
6793
  const clamped = Math.max(0, Math.min(100, percent));
@@ -6346,9 +6818,74 @@ function formatContextRemaining(value) {
6346
6818
  return `${(value / 1_000).toFixed(1)}K`;
6347
6819
  return String(value);
6348
6820
  }
6349
- function thinkingToggleLabel(expanded, streaming = false, spinnerFrame = "") {
6350
- const arrow = expanded ? "▼" : "▶";
6351
- return streaming && spinnerFrame ? `${spinnerFrame} ${arrow} Thinking` : `${arrow} Thinking`;
6821
+ function thinkingLabelContent(streaming = false, elapsedMs) {
6822
+ const elapsed = formatDuration(elapsedMs);
6823
+ const label = streaming
6824
+ ? (elapsed ? `Thinking ${elapsed}...` : "Thinking...")
6825
+ : (elapsed ? `Thought for ${elapsed}` : "Thought");
6826
+ return new StyledText([
6827
+ fg(theme.messageThinkingText)(italic(label)),
6828
+ ]);
6829
+ }
6830
+ function formatDuration(ms) {
6831
+ if (ms === undefined || !Number.isFinite(ms) || ms <= 0)
6832
+ return "";
6833
+ if (ms < 1000)
6834
+ return `${Math.max(1, Math.round(ms))}ms`;
6835
+ const seconds = ms / 1000;
6836
+ if (seconds < 10)
6837
+ return `${seconds.toFixed(1)}s`;
6838
+ if (seconds < 60)
6839
+ return `${Math.round(seconds)}s`;
6840
+ let minutes = Math.floor(seconds / 60);
6841
+ let remSec = Math.round(seconds - minutes * 60);
6842
+ // Math.round can lift remSec to exactly 60 (e.g. 239.6s → 3m60s). Carry into minutes.
6843
+ if (remSec >= 60) {
6844
+ minutes += Math.floor(remSec / 60);
6845
+ remSec = remSec % 60;
6846
+ }
6847
+ return remSec === 0 ? `${minutes}m` : `${minutes}m${remSec}s`;
6848
+ }
6849
+ function reasoningElapsedMs(message) {
6850
+ if (message.turnStartedAt === undefined)
6851
+ return undefined;
6852
+ const end = !message.streaming ? (message.turnCompletedAt ?? Date.now()) : Date.now();
6853
+ const diff = end - message.turnStartedAt;
6854
+ return diff > 0 ? diff : undefined;
6855
+ }
6856
+ function toolElapsedMs(tool) {
6857
+ if (tool.startedAt === undefined)
6858
+ return undefined;
6859
+ const end = tool.completedAt ?? (tool.status === "completed" || tool.status === "error" ? Date.now() : undefined);
6860
+ if (end === undefined)
6861
+ return undefined;
6862
+ const diff = end - tool.startedAt;
6863
+ return diff > 0 ? diff : undefined;
6864
+ }
6865
+ function turnElapsedMs(message) {
6866
+ if (message.turnStartedAt === undefined)
6867
+ return undefined;
6868
+ const end = message.turnCompletedAt ?? Date.now();
6869
+ const diff = end - message.turnStartedAt;
6870
+ return diff > 0 ? diff : undefined;
6871
+ }
6872
+ function formatTurnSummary(message) {
6873
+ const parts = [];
6874
+ const elapsed = turnElapsedMs(message);
6875
+ if (elapsed !== undefined)
6876
+ parts.push(formatDuration(elapsed));
6877
+ const usage = message.turnUsage;
6878
+ if (usage) {
6879
+ if (usage.promptTokens)
6880
+ parts.push(`${formatCompactNumber(usage.promptTokens)}↑`);
6881
+ if (usage.completionTokens)
6882
+ parts.push(`${formatCompactNumber(usage.completionTokens)}↓`);
6883
+ if (usage.reasoningTokens)
6884
+ parts.push(`${formatCompactNumber(usage.reasoningTokens)}◇`);
6885
+ }
6886
+ if (!parts.length)
6887
+ return undefined;
6888
+ return `· ${parts.join(" · ")}`;
6352
6889
  }
6353
6890
  function truncate(value, max) {
6354
6891
  return value.length > max ? value.slice(0, Math.max(1, max - 1)).trimEnd() + "…" : value;