mixdog 0.9.0 → 0.9.2

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 (240) hide show
  1. package/package.json +10 -3
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -11,6 +11,8 @@
11
11
  import React, { useEffect, useState } from 'react';
12
12
  import { Box, Text } from 'ink';
13
13
  import stringWidth from 'string-width';
14
+ import stripAnsi from 'strip-ansi';
15
+ import { displayWidth } from '../display-width.mjs';
14
16
  import { theme, TURN_MARKER, RESULT_GUTTER, RESULT_GUTTER_CONT } from '../theme.mjs';
15
17
  import { formatElapsed } from '../time-format.mjs';
16
18
  import { BULLET_OPERATOR } from '../figures.mjs';
@@ -28,6 +30,11 @@ import { backgroundTaskFailureStatusLabel, isBackgroundErrorOnlyBody } from '../
28
30
  import { formatExpandedResult, wrapExpandedResultLines } from './tool-output-format.mjs';
29
31
 
30
32
  const MIN_RESULT_LINE_CHARS = 24;
33
+ // Hard cap for the collapsed result detail row (the second line under the ⎿
34
+ // gutter). Independent of terminal width so a wide terminal never lets a long
35
+ // line (e.g. an agent response brief) stretch the whole row — anything past
36
+ // this is truncated with an ellipsis. ctrl+o expand still shows the full body.
37
+ const RESULT_LINE_HARD_MAX = 80;
31
38
  // Hard cap for the parenthesized header arg summary so a long path/query does
32
39
  // not eat the whole header line; anything longer is truncated with an ellipsis.
33
40
  const SUMMARY_MAX_CHARS = 48;
@@ -42,6 +49,21 @@ const TOOL_BLINK_LIMIT_MS = 3000;
42
49
  const TOOL_PENDING_SHOW_DELAY_MS = 1000;
43
50
  // Read `theme.subtle` at use-time (not captured here) so a live `/theme`
44
51
  // switch re-tones the tool hints. `theme` is mutated in-place on switch.
52
+ // Collapsed tool headers/details are laid out as single terminal rows. Never let
53
+ // raw C0/control bytes (CR, tabs, cursor escapes, etc.) reach those rows: a
54
+ // terminal can apply them after Ink has already clipped/measured the row, which
55
+ // makes a scrolled tool card appear to write through the prompt/statusline.
56
+ const INLINE_CONTROL_RE = /[\u0000-\u001F\u007F]/g;
57
+
58
+ function safeInlineText(value) {
59
+ return stripAnsi(String(value ?? ''))
60
+ .replace(/\r\n?/g, '\n')
61
+ .replace(/\t/g, ' ')
62
+ .replace(/\n+/g, ' ')
63
+ .replace(INLINE_CONTROL_RE, ' ')
64
+ .replace(/\s+/g, ' ')
65
+ .trim();
66
+ }
45
67
 
46
68
  function normalizeCount(value) {
47
69
  const n = Number(value || 0);
@@ -98,10 +120,15 @@ function renderDeltaText(text) {
98
120
  // renderDeltaText.
99
121
  // - EXPANDED (raw=true): formatExpandedResult then wrapExpandedResultLines so
100
122
  // each physical row fits the body width before render (rail rows stay 1:1;
101
- // ink does not re-wrap).
123
+ // ink does not re-wrap). Physical row mount cap: MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES
124
+ // (default 600; 0 disables). Shell/script bodies keep the newest tail when capped.
102
125
  function ResultBody({ lines, rawText, pathArg = '', isShell = false, columns, color, raw }) {
103
126
  const renderLines = raw
104
- ? wrapExpandedResultLines(formatExpandedResult(rawText, { pathArg, isShell }), columns)
127
+ ? wrapExpandedResultLines(
128
+ formatExpandedResult(rawText, { pathArg, isShell }),
129
+ columns,
130
+ { isShell },
131
+ )
105
132
  : (lines || []);
106
133
  if (!renderLines || renderLines.length === 0) return null;
107
134
  return (
@@ -164,10 +191,27 @@ function resultTerminalStatus(value) {
164
191
  if (tagged) return normalizeTerminalStatus(tagged);
165
192
  const bracketed = text.match(/^\[status:\s*([^\]]*)\]/mi)?.[1]?.trim();
166
193
  if (bracketed) return normalizeTerminalStatus(bracketed);
194
+ // Loose inline `status: x` / `state: x` matches are a last-resort fallback —
195
+ // prefer the engine-controlled `<status>` tag or `[status: …]` marker above.
196
+ // A loose match can false-positive on prose that happens to start with
197
+ // "status:" (rare, but shellResultStatus below already owns real shell
198
+ // output parsing; this fallback stays narrow and unchanged in behavior).
167
199
  const inline = text.match(/^(?:status|state):\s*([^\s·,;]+)/mi)?.[1]?.trim();
168
200
  return normalizeTerminalStatus(inline);
169
201
  }
170
202
 
203
+ const LEADING_STATUS_MARKER_LINE_RE = /^\[status:\s*[^\]]*\]\s*$/i;
204
+
205
+ function stripLeadingStatusMarkerLines(lines) {
206
+ const out = Array.isArray(lines) ? lines.slice() : [];
207
+ if (out.length > 0 && LEADING_STATUS_MARKER_LINE_RE.test(String(out[0] ?? '').trim())) out.shift();
208
+ return out;
209
+ }
210
+
211
+ function stripLeadingStatusMarkerFromText(text) {
212
+ return stripLeadingStatusMarkerLines(String(text || '').split('\n')).join('\n');
213
+ }
214
+
171
215
  function shellDisplayStatus({ pending = false, failedCount = 0, isError = false, result = '' } = {}) {
172
216
  const status = shellResultStatus(result);
173
217
  if (pending || /^(running|pending|queued)$/.test(status)) return 'running';
@@ -183,11 +227,6 @@ function shellHeader(status, count = 1) {
183
227
  return `Ran ${object}`;
184
228
  }
185
229
 
186
- function shellDetail(status, elapsed = '') {
187
- const label = displayTerminalStatus(status) || status;
188
- return elapsed ? `${elapsed} · ${label}` : label;
189
- }
190
-
191
230
  function shellResultElapsed(value) {
192
231
  const match = String(value || '').match(/^\[elapsed:\s*(\d+)\s*ms\]/mi);
193
232
  if (!match) return '';
@@ -206,20 +245,20 @@ function statusCopy(name, label, count, doneCount, pending, isError, args = {})
206
245
  }
207
246
 
208
247
  function fitResultLine(line, columns) {
209
- const max = Math.max(MIN_RESULT_LINE_CHARS, Number(columns || 80) - 7);
210
- const text = String(line ?? '');
211
- return stringWidth(text) > max ? truncateToWidth(text, max) : text;
248
+ const max = Math.min(RESULT_LINE_HARD_MAX, Math.max(MIN_RESULT_LINE_CHARS, Number(columns || 80) - 7));
249
+ const text = safeInlineText(line);
250
+ return displayWidth(text) > max ? truncateToWidth(text, max) : text;
212
251
  }
213
252
 
214
253
  /** Trim text from the end (by display width) so it fits maxWidth, appending '…'. */
215
254
  function truncateToWidth(text, maxWidth) {
216
- const str = String(text ?? '');
255
+ const str = safeInlineText(text);
217
256
  if (maxWidth < 1) return '';
218
- if (stringWidth(str) <= maxWidth) return str;
257
+ if (displayWidth(str) <= maxWidth) return str;
219
258
  const chars = Array.from(str);
220
259
  let out = '';
221
260
  for (const ch of chars) {
222
- if (stringWidth(out + ch + '…') > maxWidth) break;
261
+ if (displayWidth(out + ch + '…') > maxWidth) break;
223
262
  out += ch;
224
263
  }
225
264
  return `${out}…`;
@@ -267,11 +306,6 @@ function agentModelLabel(args) {
267
306
  return displayModelName(model, provider, displayHint);
268
307
  }
269
308
 
270
- function withModel(label, args) {
271
- const model = agentModelLabel(args);
272
- return model ? `${label} (${model})` : label;
273
- }
274
-
275
309
  function agentTagLabel(args) {
276
310
  // The real spawn tag (engine fills parsedArgs.tag from the envelope target).
277
311
  // Never fall back to task_id — only the human-meaningful spawn tag belongs in
@@ -286,43 +320,46 @@ function withModelAndTag(label, args) {
286
320
  return inner ? `${label} (${inner})` : label;
287
321
  }
288
322
 
289
- // Append a role name to a base action word without leaving a trailing space
290
- // when the role is unknown (no generic "Agent" fallback).
291
- function joinActionRole(action, role) {
292
- return role ? `${action} ${role}` : action;
323
+ // Append an agent name to a base action word without leaving a trailing space
324
+ // when the agent is unknown (no generic "Agent" fallback).
325
+ function joinActionAgent(action, agent) {
326
+ return agent ? `${action} ${agent}` : action;
293
327
  }
294
328
 
295
329
  function agentResponseTitle(args) {
296
- const name = titleizeAgentName(args?.agent || args?.role || args?.subagent_type || args?.name || '');
297
- // The agent role + model identify the responder; the response summary itself
330
+ const name = titleizeAgentName(args?.agent || args?.subagent_type || args?.name || '');
331
+ // The agent + model identify the responder; the response summary itself
298
332
  // is hidden in the collapsed card (ctrl+o expand still shows the full body).
299
- // No generic "Agent" fallback — render just "Response" when the role is empty.
300
- return withModelAndTag(joinActionRole('Response', name), args);
333
+ // No generic "Agent" fallback — render just "Response" when the agent is empty.
334
+ return withModelAndTag(joinActionAgent('Response', name), args);
301
335
  }
302
336
 
303
337
  function agentActionTitle(args) {
304
- const name = titleizeAgentName(args?.agent || args?.role || args?.subagent_type || args?.name || '');
305
- const action = String(args?.type || args?.action || '').toLowerCase();
338
+ const name = titleizeAgentName(args?.agent || args?.subagent_type || args?.name || '');
339
+ // Runtime treats an omitted type/action as "spawn" (see agent-tool.mjs default),
340
+ // so mirror that contract here instead of falling through to the generic
341
+ // "Called agent" status copy.
342
+ const action = String(args?.type || args?.action || 'spawn').toLowerCase();
306
343
  // Fixed action verbs regardless of running/completed status. No generic
307
- // "Agent" fallback for the role: when the role is unknown render the action
344
+ // "Agent" fallback for the agent: when the agent is unknown render the action
308
345
  // word alone ("Spawn") instead of "Spawn Agent".
309
- if (action === 'spawn') return withModelAndTag(joinActionRole('Spawn', name), args);
310
- if (action === 'send') return withModelAndTag(joinActionRole('Send', name), args);
346
+ if (action === 'spawn') return withModelAndTag(joinActionAgent('Spawn', name), args);
347
+ if (action === 'send') return withModelAndTag(joinActionAgent('Send', name), args);
311
348
  if (action === 'list') return 'Agent status';
312
- if (action === 'cancel') return withModelAndTag(joinActionRole('Cancel', name), args);
313
- if (action === 'close') return withModelAndTag(joinActionRole('Close', name), args);
314
- if (action === 'cleanup') return withModelAndTag(joinActionRole('Cleanup', name), args);
315
- if (action === 'read' || action === 'status') return withModelAndTag(joinActionRole('Status', name), args);
349
+ if (action === 'cancel') return withModelAndTag(joinActionAgent('Cancel', name), args);
350
+ if (action === 'close') return withModelAndTag(joinActionAgent('Close', name), args);
351
+ if (action === 'cleanup') return withModelAndTag(joinActionAgent('Cleanup', name), args);
352
+ if (action === 'read' || action === 'status') return withModelAndTag(joinActionAgent('Status', name), args);
316
353
  return '';
317
354
  }
318
355
 
319
356
  function agentActionSummary(args, summary) {
320
357
  const text = String(summary || '').trim();
321
358
  if (!text) return '';
322
- const name = titleizeAgentName(args?.agent || args?.role || args?.subagent_type || args?.name || '');
359
+ const name = titleizeAgentName(args?.agent || args?.subagent_type || args?.name || '');
323
360
  if (name && text === name) return '';
324
361
  let rest = name && text.startsWith(`${name} · `) ? text.slice(name.length + 3).trim() : text;
325
- // The role/model/tag surface summary ("Heavy Worker · Opus 4.8") is now folded
362
+ // The agent/model/tag surface summary ("Heavy Worker · Opus 4.8") is now folded
326
363
  // into the header label itself ("Spawn Heavy Worker (Opus 4.8, tag)"), so drop
327
364
  // the model and tag tokens from the parenthesized summary to avoid showing
328
365
  // them twice.
@@ -353,7 +390,7 @@ function hasAgentResponseResult(value) {
353
390
  if (/^agent result\b/i.test(trimmed)) continue;
354
391
  if (/^(?:undefined|null)$/i.test(trimmed)) continue;
355
392
  if (/^<\/?(?:final-answer|task-notification|task-id|tool-use-id|output-file|result|status|summary|usage|total_tokens|tool_uses|duration_ms|worktree|worktreePath|worktreeBranch)[^>]*>$/i.test(trimmed)) continue;
356
- if (!sawBlank && /^(?:agent task|background task|agent message queued\b|agent close:|task_id|surface|operation|label|status|type|target|role|agent|preset|model|effort|fast|limits|started|finished|error|notification|queueDepth):?\s*/i.test(trimmed)) continue;
393
+ if (!sawBlank && /^(?:agent task|background task|agent message queued\b|agent close:|task_id|surface|operation|label|status|type|target|agent|preset|model|effort|fast|limits|started|finished|error|notification|queueDepth):?\s*/i.test(trimmed)) continue;
357
394
  if (!sawBlank && /^(?:agents|tasks):\s*/i.test(trimmed)) continue;
358
395
  if (/^\(no agents or tasks\)$/i.test(trimmed)) continue;
359
396
  if (!sawBlank && /^-\s+\S+/i.test(trimmed)) continue;
@@ -435,7 +472,10 @@ function prefixElapsed(detail, elapsed = '') {
435
472
  const text = String(detail || '').trim();
436
473
  const time = String(elapsed || '').trim();
437
474
  if (!time) return text;
438
- return text ? `${time} · ${text}` : time;
475
+ // Unified convention: the elapsed time ALWAYS goes at the END, ` · ` separated.
476
+ // Guard against a double-append when the text already ends with the same time.
477
+ if (text && text.endsWith(`· ${time}`)) return text;
478
+ return text ? `${text} · ${time}` : time;
439
479
  }
440
480
 
441
481
  function mergeTerminalDetail(status, detail = '') {
@@ -513,10 +553,6 @@ function isOutputDetailTool(normalizedName, label) {
513
553
  ]).has(n) || l === 'read' || l === 'search' || l === 'web search' || l === 'run';
514
554
  }
515
555
 
516
- function progressDetail({ normalizedName, label, elapsed }) {
517
- return elapsed ? `${elapsed} elapsed` : '';
518
- }
519
-
520
556
  function genericCompletedDetail({ normalizedName, label, hasResult, firstResultLine, isError }) {
521
557
  const n = String(normalizedName || '').toLowerCase();
522
558
  const l = String(label || '').toLowerCase();
@@ -561,7 +597,8 @@ function agentTerminalDetail(status, isError, elapsed, error = '') {
561
597
  : /done|success|complete|closed/.test(s)
562
598
  ? 'Finished'
563
599
  : '';
564
- return word ? `${word}${elapsed ? ` after ${elapsed}` : ''}` : '';
600
+ // Unified ` · <time>` convention (previously "Finished after 12s").
601
+ return word ? `${word}${elapsed ? ` · ${elapsed}` : ''}` : '';
565
602
  }
566
603
 
567
604
  function clampFailureCount(errorCount, groupCount, isError) {
@@ -570,17 +607,26 @@ function clampFailureCount(errorCount, groupCount, isError) {
570
607
  return isError ? groupCount : 0;
571
608
  }
572
609
 
610
+ // Single source of truth for the tool-card dot (●) color. Both the aggregate
611
+ // and normal (single-tool) render paths must call this with a resolved
612
+ // `terminalStatus` — do not recompute color inline elsewhere.
613
+ // running/pending -> mixdogOrange || warning (blink handled by caller)
614
+ // success -> theme.success
615
+ // partial failure -> mixdogOrange (some, not all, of the group failed)
616
+ // all failed -> theme.error
617
+ // cancelled -> theme.warning
573
618
  function toolStatusColor({ pending, groupCount, failedCount, terminalStatus = '' }) {
574
- if (pending) return theme.success;
619
+ if (pending) return theme.mixdogOrange || theme.warning;
575
620
  const status = normalizeTerminalStatus(terminalStatus);
621
+ if (status === 'cancelled') return theme.warning;
576
622
  if (status === 'failed') return theme.error;
577
- if (status === 'cancelled') return theme.warning || theme.mixdogOrange || theme.subtle;
578
623
  if (failedCount <= 0) return theme.success;
579
624
  if (groupCount > 1 && failedCount < groupCount) return theme.mixdogOrange || theme.warning;
580
625
  return theme.error;
581
626
  }
582
627
 
583
- export function ToolExecution({ name, args, result, rawResult, isError, errorCount, expanded, globalExpanded = false, columns = 80, attached = false, count = 1, completedCount = 0, startedAt = 0, completedAt = 0, aggregate = false, categories = {}, headerFinalized = true, deferredDisplayReady = false }) {
628
+ export function ToolExecution({ name, args, result, rawResult, isError, errorCount, expanded, columns = 80, attached = false, count = 1, completedCount = 0, startedAt = 0, completedAt = 0, aggregate = false, categories = {}, headerFinalized = true, deferredDisplayReady = false }) {
629
+ const rowWidth = Math.max(1, Number(columns || 80));
584
630
  const [blinkOn, setBlinkOn] = useState(true);
585
631
  const [blinkExpired, setBlinkExpired] = useState(false);
586
632
  const [pendingDelayElapsed, setPendingDelayElapsed] = useState(false);
@@ -614,7 +660,6 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
614
660
  const elapsedMs = startedAtMs ? Math.max(0, (pending ? Date.now() : (completedAtMs || Date.now())) - startedAtMs) : 0;
615
661
  const elapsed = elapsedMs >= 1000 ? formatElapsed(elapsedMs) : '';
616
662
  const failedCount = clampFailureCount(errorCount, groupCount, isError);
617
- const statusColor = toolStatusColor({ pending, groupCount, failedCount });
618
663
  const displayGroupCount = groupCount;
619
664
  const displayCategories = normalizeCountMap(categories || {});
620
665
 
@@ -687,7 +732,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
687
732
  // header + one brief detail row (see estimateTranscriptItemRows).
688
733
  const placeholderSingleRow = !aggregate && SKILL_SURFACE_NAMES.has(placeholderNormalizedName);
689
734
  return (
690
- <Box flexDirection="column" marginTop={attached ? 0 : 1}>
735
+ <Box flexDirection="column" marginTop={attached ? 0 : 1} width={rowWidth} overflow="hidden">
691
736
  <Text> </Text>
692
737
  {placeholderSingleRow ? null : <Text> </Text>}
693
738
  </Box>
@@ -703,7 +748,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
703
748
  // No stableVerbWidth: see statusCopy — the padding only left a mid-header
704
749
  // gap ("Searched 1 pattern, Read 1 file") since Ink trims trailing
705
750
  // spaces and never stabilized the flip.
706
- const headerText = formatAggregateHeader(displayCategories || {}, { pending: headerPending, order: headerOrder });
751
+ const headerText = safeInlineText(formatAggregateHeader(displayCategories || {}, { pending: headerPending, order: headerOrder }));
707
752
  let detailText;
708
753
  if (hasResult) {
709
754
  // The aggregate card reserves EXACTLY ONE detail row when it is not
@@ -714,33 +759,40 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
714
759
  // "settle" taller than reserved. Collapse to a single logical line
715
760
  // (whitespace-normalized); fitResultLine below trims it to the column
716
761
  // width so it can never exceed one terminal row.
717
- detailText = String(rt).replace(/\s+/g, ' ').trim();
762
+ detailText = safeInlineText(rt);
718
763
  } else {
719
764
  detailText = '';
720
765
  }
721
766
 
722
- const dotColor = !hasResult && !pending ? theme.subtle : statusColor;
767
+ // Resolve the aggregate's terminalStatus from the collapsed detail `rt`
768
+ // (which carries a `[status: cancelled]`/`<status>` marker when the
769
+ // aggregate was cancelled) plus isError/failedCount for failures. Pending
770
+ // stays running; a clean completion stays success. toolStatusColor is the
771
+ // single source of dot color for both aggregate and normal cards.
772
+ const aggregateTerminalStatus = pending
773
+ ? 'running'
774
+ : (resultTerminalStatus(rt) || (isError || failedCount > 0 ? 'failed' : 'completed'));
775
+ const dotColor = toolStatusColor({ pending, groupCount, failedCount, terminalStatus: aggregateTerminalStatus });
723
776
  const dotText = pending && !blinkExpired && !blinkOn ? ' ' : TURN_MARKER;
724
777
  const gutter = 2;
725
778
  const showHeaderExpandHint = hasRawResult;
726
779
  const hintLabel = `ctrl+o ${expanded ? 'collapse' : 'expand'}`;
727
780
  const hintText = ` ${BULLET_OPERATOR} ${hintLabel}`;
728
- const headerMetaText = pending && elapsed ? ` (${elapsed} elapsed)` : '';
729
- // Reserve the expand-hint slot for the card's whole lifecycle so the header
730
- // body never reflows when the hint appears on completion. During pending the
731
- // elapsed meta shares this same right region; reserving the wider of the two
732
- // keeps `avail` (and the header clip point) fixed, so nothing "appears then
733
- // shrinks back" on the right edge as counts/results land.
734
- const rightReserve = Math.max(stringWidth(hintText), stringWidth(headerMetaText));
781
+ // The header right-side trailing slot only ever shows the ctrl+o hint. The
782
+ // pending elapsed meta was removed from the header it lives on the detail
783
+ // row now (`Running · 12s`) so a per-second digit change never reflows the
784
+ // header. Still reserve the hint slot for the whole lifecycle so the body
785
+ // clip point stays fixed when the hint appears on completion.
786
+ const rightReserve = stringWidth(hintText);
735
787
  const avail = Math.max(1, (Number(columns) || 80) - 1 - gutter - rightReserve);
736
- const trailingText = headerMetaText || (showHeaderExpandHint ? hintText : '');
788
+ const trailingText = showHeaderExpandHint ? hintText : '';
737
789
  const trailingColor = theme.subtle;
738
790
  const clippedHeader = stringWidth(headerText) > avail
739
791
  ? truncateToWidth(headerText, avail)
740
792
  : headerText;
741
- // Trailing content (elapsed while pending, ctrl+o hint when done) always
742
- // sits immediately after the header body — no fixed right-edge pin — so it
743
- // never jumps to the right edge and snaps back on the pending→done flip.
793
+ // Trailing content (ctrl+o hint only; pending elapsed lives on the detail
794
+ // row) sits immediately after the header body — no fixed right-edge pin — so
795
+ // it never jumps to the right edge and snaps back on the pending→done flip.
744
796
  // Keep the aggregate card at a fixed height (header + one detail row) for
745
797
  // its whole lifecycle. Pending cards have no result yet, so reserve the
746
798
  // detail row up front instead of growing from 1→2 rows when the summary
@@ -754,13 +806,19 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
754
806
  // color; the status placeholder is rendered dim.
755
807
  const isPlaceholderDetail = !(expanded && hasRawResult) && !detailText;
756
808
  const showRawAggregate = expanded && hasRawResult;
809
+ // Aggregate cards intentionally omit elapsed time once grouped. A brief
810
+ // `Running · 1s` tick during the grouped→finished handoff reads as visual
811
+ // noise, and the grouped header already communicates that work is active.
812
+ const pendingPlaceholder = headerPending
813
+ ? 'Running'
814
+ : 'Finished';
757
815
  const detailLines = showRawAggregate
758
816
  ? rawRt.split('\n')
759
- : (detailText ? [detailText] : [headerPending ? 'Running' : 'Finished']);
817
+ : (detailText ? [detailText] : [pendingPlaceholder]);
760
818
  const aggregateDetailColor = isPlaceholderDetail ? theme.subtle : theme.text;
761
819
  return (
762
- <Box flexDirection="column" marginTop={attached ? 0 : 1}>
763
- <Box flexDirection="row">
820
+ <Box flexDirection="column" marginTop={attached ? 0 : 1} width={rowWidth} overflow="hidden">
821
+ <Box flexDirection="row" width={rowWidth} overflow="hidden">
764
822
  <Box flexShrink={0} minWidth={2}>
765
823
  <Text color={dotColor}>{dotText}</Text>
766
824
  </Box>
@@ -792,30 +850,35 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
792
850
  const backgroundResultText = backgroundMeta?.hasResponse ? backgroundMeta.body : '';
793
851
  const displayedResultText = backgroundResultText || (errorOnlyResult ? '' : (rt || ''));
794
852
  const hasDisplayResult = Boolean(String(displayedResultText || '').trim());
795
- const lines = displayedResultText ? displayedResultText.split('\n') : [];
853
+ const displayedResultBodyText = stripLeadingStatusMarkerFromText(displayedResultText);
854
+ const hasDisplayBody = Boolean(String(displayedResultBodyText || '').trim());
855
+ const lines = displayedResultBodyText ? displayedResultBodyText.split('\n') : [];
796
856
  const totalLines = lines.length;
797
857
  // Semantic one-line summary derived purely from name/args/result text.
798
858
  // Shown in the collapsed, non-error view in place of the raw result block.
799
859
  // Grouped cards ("Searched N files" / "Read N files") get the same treatment
800
860
  // as single calls: a one-line semantic summary stands in for the raw block.
801
- const resultSummary = !pending && hasDisplayResult
802
- ? surfaceSummarizeToolResult(name, args, displayedResultText, isError)
861
+ const resultSummary = !pending && hasDisplayBody
862
+ ? surfaceSummarizeToolResult(name, args, displayedResultBodyText, isError)
803
863
  : null;
804
864
  // Same fit budget fitResultLine() uses, to detect a line that will be clipped.
805
- const maxResultChars = Math.max(MIN_RESULT_LINE_CHARS, Number(columns || 80) - 7);
865
+ const maxResultChars = Math.min(RESULT_LINE_HARD_MAX, Math.max(MIN_RESULT_LINE_CHARS, Number(columns || 80) - 7));
806
866
  const resultColor = theme.text;
807
867
  const firstResultLine = hasDisplayResult ? String(lines[0] ?? '') : '';
808
- const firstResultLineClipped = hasDisplayResult && stringWidth(firstResultLine) > maxResultChars;
809
- const hasHiddenDetail = !pending && hasDisplayResult && (totalLines > 1 || firstResultLineClipped || Boolean(resultSummary));
868
+ const firstResultLineClipped = hasDisplayBody && stringWidth(firstResultLine) > maxResultChars;
869
+ const hasHiddenDetail = !pending && hasDisplayBody && (totalLines > 1 || firstResultLineClipped || Boolean(resultSummary));
810
870
  const shellStatus = isShellSurface ? shellDisplayStatus({ pending, failedCount, isError, result: displayedResultText }) : '';
811
871
  const shellElapsed = isShellSurface ? (shellResultElapsed(displayedResultText) || elapsed) : '';
812
- const shellStatusDetail = isShellSurface ? shellDetail(shellStatus, shellElapsed) : '';
813
872
  const backgroundElapsed = backgroundMeta
814
873
  ? backgroundTaskElapsed(backgroundMeta, elapsed)
815
874
  : (isBackgroundTaskTool(normalizedName) ? backgroundTaskElapsed(parsedArgs, elapsed) : '');
816
875
 
817
876
  const toolArgPath = parsedArgs?.path ?? parsedArgs?.file_path ?? parsedArgs?.file ?? '';
818
- const imageDetail = normalizedName === 'view_image' && toolArgPath ? String(toolArgPath) : '';
877
+ // Audit HIGH: on a FAILED view_image the path detail used to win over the
878
+ // error cause (nonShellDetail order puts imageDetail before genericDetail),
879
+ // so the card showed the filename instead of why it failed. Suppress the
880
+ // path detail on error; the error-cause summary/first line takes the row.
881
+ const imageDetail = normalizedName === 'view_image' && toolArgPath && !isError ? String(toolArgPath) : '';
819
882
  const isBackgroundResult = !pending && isBackgroundTaskTool(normalizedName) && Boolean(backgroundMeta);
820
883
  const isBackgroundResponse = isBackgroundResult && (backgroundMeta?.hasResponse || isBackgroundTaskResponseArgs(normalizedName, parsedArgs));
821
884
  const isBackgroundMetadataResult = isBackgroundResult && !isBackgroundResponse && Boolean(backgroundMeta);
@@ -863,20 +926,22 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
863
926
  // the final summary just fills in place. Skill surfaces collapse to a single
864
927
  // row in BOTH the estimate and the render (visibleDetailLines drops the row
865
928
  // for isSkillSurface below), so they get no placeholder.
866
- const pendingDetailPlaceholder = pending && !isSkillSurface ? 'Running' : '';
929
+ const pendingDetailPlaceholder = pending && !isSkillSurface
930
+ ? (elapsed ? `Running · ${elapsed}` : 'Running')
931
+ : '';
867
932
  const shellCollapsedSummary = isShellSurface && !pending && hasDisplayResult
868
933
  ? (resultSummary || truncateToWidth(firstResultLine, Math.min(120, maxResultChars)))
869
934
  : resultSummary;
870
935
  const collapsedDetail = pending
871
936
  ? pendingDetailPlaceholder
872
937
  : isShellSurface
873
- ? mergeTerminalDetail(shellStatus, shellCollapsedSummary)
938
+ ? prefixElapsed(mergeTerminalDetail(shellStatus, shellCollapsedSummary), shellElapsed)
874
939
  : mergeTerminalDetail(terminalStatus, nonShellDetail);
875
940
  const backgroundMetadataExpandable = isBackgroundMetadataResult && hasRawResult && !pending;
876
- const showRawResult = expanded && (hasDisplayResult || hasRawResult)
941
+ const showRawResult = expanded && (hasDisplayBody || hasRawResult)
877
942
  && (!isBackgroundMetadataResult || hasRawResult);
878
943
  const detailLines = showRawResult
879
- ? (hasDisplayResult ? lines : (rawRt ? rawRt.split('\n') : []))
944
+ ? (hasDisplayBody ? lines : (rawRt ? stripLeadingStatusMarkerLines(rawRt.split('\n')) : []))
880
945
  : (collapsedDetail ? [collapsedDetail] : []);
881
946
  const isPendingPlaceholderDetail = !showRawResult && Boolean(pendingDetailPlaceholder);
882
947
  const detailColor = isPendingPlaceholderDetail ? theme.subtle : theme.text;
@@ -898,7 +963,10 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
898
963
  // collapsed; ctrl+o expand still surfaces the full body.
899
964
  let visibleDetailLines = detailLines;
900
965
  if (isSkillSurface && !showRawResult) {
901
- visibleDetailLines = [];
966
+ // Audit HIGH: a FAILED skill load used to drop its detail row with the
967
+ // success path, hiding the one-line cause entirely. Keep the detail row
968
+ // when the call errored; only the redundant success repeat is dropped.
969
+ visibleDetailLines = isError && collapsedDetail ? [collapsedDetail] : [];
902
970
  } else if (isBackgroundMetadataResult && backgroundMetadataHeaderFailure && !showRawResult) {
903
971
  visibleDetailLines = [];
904
972
  } else if (isAgentSurfaceCard && !showRawResult) {
@@ -917,14 +985,25 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
917
985
  else if (isBackgroundMetadataResult) labelText = backgroundTaskActionTitle(normalizedName, backgroundMeta);
918
986
  else if (isShellSurface) labelText = shellHeader(shellStatus, displayGroupCount);
919
987
  else labelText = (isAgentTool(normalizedName) ? agentActionTitle(parsedArgs) : '') || statusCopy(name, label, displayGroupCount, doneCount, headerPending, isError, parsedArgs);
988
+ labelText = safeInlineText(labelText);
920
989
  // Show the parenthesized arg summary for grouped cards too, matching single
921
990
  // calls so the header carries the same context.
922
991
  const toolSearchSummary = !pending && normalizedName === 'tool_search' && hasResult
923
992
  ? toolSearchLoadedSummary(displayedResultText)
924
993
  : '';
925
- const summaryText = isAgentResponse || isBackgroundResponse
994
+ const rawSummaryText = safeInlineText(isAgentResponse || isBackgroundResponse
926
995
  ? ''
927
- : toolSearchSummary || (isAgentTool(normalizedName) ? agentActionSummary(parsedArgs, summary) : summary);
996
+ : toolSearchSummary || (isAgentTool(normalizedName) ? agentActionSummary(parsedArgs, summary) : summary));
997
+ // Drop the parenthesized arg summary when it is a bare "<n> <unit>" count
998
+ // that the header verb already spells out (e.g. header "Searching 6 patterns"
999
+ // + summary "6 patterns"). Multi-arg array calls hit this; single calls keep
1000
+ // their descriptive summary ("pattern: \"foo\"") since it never matches the
1001
+ // header tail. Channel surfaces are unaffected — they build the summary from
1002
+ // summarizeToolArgs directly and never render this header verb.
1003
+ const summaryIsHeaderCount = rawSummaryText
1004
+ && /^\d+\s+\S+$/.test(rawSummaryText)
1005
+ && labelText.endsWith(rawSummaryText);
1006
+ const summaryText = summaryIsHeaderCount ? '' : rawSummaryText;
928
1007
  // Agent cards hide their collapsed body but still expose ctrl+o expand only
929
1008
  // when expanding would actually reveal something: an agent response body, or a
930
1009
  // multiline / clipped raw result (e.g. the "agents: N …" worker list). A
@@ -936,6 +1015,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
936
1015
  // hasHiddenDetail, which goes true for any single-line resultSummary and would
937
1016
  // wrongly show ctrl+o on a status-only one-liner that has nothing to expand.
938
1017
  const shellHasExpandableBody = isShellSurface && !pending && hasDisplayResult
1018
+ && hasDisplayBody
939
1019
  && (totalLines > 1 || firstResultLineClipped || Boolean(shellCollapsedSummary && shellCollapsedSummary !== firstResultLine));
940
1020
  const showHeaderExpandHint = (isShellSurface ? shellHasExpandableBody : (isAgentSurfaceCard ? agentHasExpandableBody : (hasHiddenDetail || backgroundMetadataExpandable)))
941
1021
  && normalizedName !== 'tool_search';
@@ -949,25 +1029,22 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
949
1029
  const gutter = 2;
950
1030
  const hintLabel = showHeaderExpandHint ? `ctrl+o ${expanded ? 'collapse' : 'expand'}` : '';
951
1031
  const hintText = hintLabel ? ` ${BULLET_OPERATOR} ${hintLabel}` : '';
952
- const headerMetaText = pending && elapsed ? ` (${elapsed} elapsed)` : '';
953
- // The expand hint (post-completion) and the elapsed meta (pending) occupy the
954
- // SAME trailing region but never at the same time. Subtracting both widths
955
- // made `avail` shrink/grow across the pending→completed transition, so the
956
- // label/summary clip point shifted and the header text "jumped out then
957
- // snapped back" right as a tool finished or cards merged. Reserve the wider of
958
- // the two for the whole lifecycle (matching the aggregate card's rightReserve)
959
- // so `avail` stays fixed and nothing reflows. The hint slot is reserved even
960
- // while pending so its later appearance does not push the body either.
1032
+ // The header right-side trailing slot only ever shows the ctrl+o hint. The
1033
+ // pending elapsed meta was removed from the header it lives on the detail
1034
+ // row now (`Running · 12s`) so a per-second digit change (9s→10s) or the
1035
+ // pending→done swap never reflows the header. The hint slot is reserved for
1036
+ // the whole lifecycle (even while pending) so its later appearance on
1037
+ // completion does not push the body clip point.
961
1038
  const hintReserveLabel = `ctrl+o ${expanded ? 'collapse' : 'expand'}`;
962
1039
  const hintReserveText = ` ${BULLET_OPERATOR} ${hintReserveLabel}`;
963
1040
  const headerFailureText = headerFailureStatus
964
1041
  ? truncateToWidth(headerFailureStatus, HEADER_FAILURE_STATUS_MAX)
965
1042
  : '';
966
1043
  const inlineFailureText = headerFailureText ? ` ${BULLET_OPERATOR} ${headerFailureText}` : '';
967
- const rightReserve = Math.max(stringWidth(hintReserveText), stringWidth(headerMetaText)) + stringWidth(inlineFailureText);
1044
+ const rightReserve = stringWidth(hintReserveText) + stringWidth(inlineFailureText);
968
1045
  const avail = Math.max(1, (Number(columns) || 80) - 1 - gutter - rightReserve);
969
- const trailingText = headerMetaText || (showHeaderExpandHint ? hintText : '');
970
- const trailingColor = headerMetaText ? theme.subtle : expandHintColor;
1046
+ const trailingText = showHeaderExpandHint ? hintText : '';
1047
+ const trailingColor = expandHintColor;
971
1048
  let labelOut;
972
1049
  let summaryOut;
973
1050
  if (stringWidth(labelText) >= avail) {
@@ -984,13 +1061,13 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
984
1061
  : '';
985
1062
  summaryOut = truncatedSummary ? ` (${truncatedSummary})` : '';
986
1063
  }
987
- // Keep trailing content (pending elapsed / completed ctrl+o hint) attached
988
- // directly after the body for the whole lifecycle. The fixed-column pin
989
- // previously used for elapsed is what made the trailing text jump to the right
990
- // edge and snap back on the pending→done flip, so there is no pad. `avail`
991
- // stays reserved (rightReserve) so the body clip point never reflows.
1064
+ // Keep trailing content (ctrl+o hint only; pending elapsed lives on the detail
1065
+ // row) attached directly after the body for the whole lifecycle. The
1066
+ // fixed-column pin previously used for elapsed is what made the trailing text
1067
+ // jump to the right edge and snap back on the pending→done flip, so there is no
1068
+ // pad. `avail` stays reserved (rightReserve) so the body clip point never reflows.
992
1069
  return (
993
- <Box flexDirection="column" marginTop={attached ? 0 : 1}>
1070
+ <Box flexDirection="column" marginTop={attached ? 0 : 1} width={rowWidth} overflow="hidden">
994
1071
  <Box flexDirection="row" width="100%">
995
1072
  <Box flexShrink={1} flexGrow={1} overflow="hidden" minWidth={0}>
996
1073
  <Box flexDirection="row">
@@ -1009,7 +1086,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
1009
1086
 
1010
1087
  <ResultBody
1011
1088
  lines={visibleDetailLines}
1012
- rawText={hasDisplayResult ? displayedResultText : (rawRt || '')}
1089
+ rawText={hasDisplayBody ? displayedResultBodyText : stripLeadingStatusMarkerFromText(rawRt || '')}
1013
1090
  pathArg={toolArgPath}
1014
1091
  isShell={isShellSurface}
1015
1092
  columns={columns}
@@ -26,7 +26,7 @@ function cleanRightMessage(value) {
26
26
  return String(value || '').replace(/\s+/g, ' ').trim();
27
27
  }
28
28
 
29
- export function TurnDone({ elapsedMs = 0, status = 'done', verb = 'Thought', rightMessage = '', rightTone = 'info', rightMessageWidth = 24 }) {
29
+ export function TurnDone({ elapsedMs = 0, status = 'done', verb = 'Thought', rightMessage = '', rightTone = 'info', rightMessageWidth = 24, marginTop = 1 }) {
30
30
  const elapsed = formatDuration(elapsedMs);
31
31
  const cancelled = status === 'cancelled';
32
32
  const doneVerb = String(verb || 'Thought').trim() || 'Thought';
@@ -37,7 +37,7 @@ export function TurnDone({ elapsedMs = 0, status = 'done', verb = 'Thought', rig
37
37
  const rightWidth = Math.max(1, Number(rightMessageWidth) || 24);
38
38
 
39
39
  return (
40
- <Box marginTop={1} flexDirection="row" width="100%">
40
+ <Box marginTop={marginTop} flexDirection="row" width="100%">
41
41
  <Box flexGrow={1} flexShrink={1} overflow="hidden">
42
42
  <Text wrap="truncate">
43
43
  <Text color={theme.spinnerGlyph}>{TURN_DONE_MARKER} </Text>
@@ -53,14 +53,14 @@ export function TurnDone({ elapsedMs = 0, status = 'done', verb = 'Thought', rig
53
53
  );
54
54
  }
55
55
 
56
- export function StatusDone({ label = 'Complete', detail = '', rightMessage = '', rightTone = 'info', rightMessageWidth = 24 }) {
56
+ export function StatusDone({ label = 'Complete', detail = '', rightMessage = '', rightTone = 'info', rightMessageWidth = 24, marginTop = 1 }) {
57
57
  const copy = String(label || 'Complete').trim() || 'Complete';
58
58
  const suffix = String(detail || '').trim();
59
59
  const rightText = cleanRightMessage(rightMessage);
60
60
  const rightWidth = Math.max(1, Number(rightMessageWidth) || 24);
61
61
 
62
62
  return (
63
- <Box marginTop={1} flexDirection="row" width="100%">
63
+ <Box marginTop={marginTop} flexDirection="row" width="100%">
64
64
  <Box flexGrow={1} flexShrink={1} overflow="hidden">
65
65
  <Text wrap="truncate">
66
66
  <Text color={theme.spinnerGlyph}>{TURN_DONE_MARKER} </Text>
@@ -12,7 +12,7 @@ const STATUS_SEPARATOR = ' │ ';
12
12
 
13
13
  function money(value) {
14
14
  const n = Number(value);
15
- if (!Number.isFinite(n)) return 'n/a';
15
+ if (!Number.isFinite(n)) return 'N/A';
16
16
  if (n === 0) return '$0';
17
17
  if (n >= 10) return `$${n.toFixed(0)}`;
18
18
  if (n >= 1) return `$${n.toFixed(2)}`;