mixdog 0.9.1 → 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 (214) hide show
  1. package/package.json +8 -1
  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/task-bench.mjs +207 -0
  23. package/scripts/tool-failures.mjs +6 -6
  24. package/scripts/tool-smoke.mjs +306 -66
  25. package/scripts/toolcall-args-test.mjs +81 -0
  26. package/src/agents/debugger/AGENT.md +4 -4
  27. package/src/agents/heavy-worker/AGENT.md +4 -2
  28. package/src/agents/reviewer/AGENT.md +4 -4
  29. package/src/agents/worker/AGENT.md +4 -2
  30. package/src/app.mjs +10 -6
  31. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  32. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  33. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  34. package/src/headless-role.mjs +14 -14
  35. package/src/help.mjs +1 -0
  36. package/src/lib/rules-builder.cjs +32 -54
  37. package/src/mixdog-session-runtime.mjs +710 -318
  38. package/src/output-styles/default.md +12 -7
  39. package/src/output-styles/minimal.md +25 -0
  40. package/src/output-styles/oneline.md +21 -0
  41. package/src/output-styles/simple.md +10 -9
  42. package/src/repl.mjs +12 -2
  43. package/src/rules/agent/00-common.md +7 -5
  44. package/src/rules/agent/30-explorer.md +7 -8
  45. package/src/rules/lead/01-general.md +3 -1
  46. package/src/rules/lead/lead-tool.md +7 -0
  47. package/src/rules/shared/01-tool.md +17 -12
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  50. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  51. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  52. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  53. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  54. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  55. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  56. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  57. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  58. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  59. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  61. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  62. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
  63. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
  64. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  65. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  66. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  67. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  68. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  69. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
  70. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
  71. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
  72. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  73. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  74. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  75. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
  76. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  77. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  78. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  80. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  82. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  83. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  84. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  85. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  87. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  88. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  90. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  91. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  92. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  95. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  96. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  97. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  99. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  100. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  101. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  102. package/src/runtime/channels/backends/discord.mjs +99 -9
  103. package/src/runtime/channels/backends/telegram.mjs +501 -0
  104. package/src/runtime/channels/index.mjs +441 -1224
  105. package/src/runtime/channels/lib/config.mjs +54 -2
  106. package/src/runtime/channels/lib/format.mjs +4 -2
  107. package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
  108. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  109. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  110. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  111. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  112. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  113. package/src/runtime/channels/lib/webhook.mjs +59 -31
  114. package/src/runtime/channels/tool-defs.mjs +1 -1
  115. package/src/runtime/memory/index.mjs +184 -19
  116. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  117. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  118. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  119. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  120. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  121. package/src/runtime/memory/lib/memory.mjs +101 -4
  122. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  123. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  124. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  125. package/src/runtime/memory/tool-defs.mjs +6 -3
  126. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  127. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  128. package/src/runtime/shared/config.mjs +9 -0
  129. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  130. package/src/runtime/shared/schedules-store.mjs +21 -19
  131. package/src/runtime/shared/tool-surface.mjs +98 -13
  132. package/src/runtime/shared/transcript-writer.mjs +129 -0
  133. package/src/runtime/shared/update-checker.mjs +214 -0
  134. package/src/standalone/agent-tool.mjs +255 -109
  135. package/src/standalone/channel-admin.mjs +133 -40
  136. package/src/standalone/channel-worker.mjs +8 -291
  137. package/src/standalone/explore-tool.mjs +2 -2
  138. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  139. package/src/standalone/provider-admin.mjs +11 -0
  140. package/src/standalone/usage-dashboard.mjs +1 -1
  141. package/src/tui/App.jsx +2096 -732
  142. package/src/tui/components/ConfirmBar.jsx +47 -0
  143. package/src/tui/components/ContextPanel.jsx +5 -3
  144. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  145. package/src/tui/components/Markdown.jsx +22 -98
  146. package/src/tui/components/Message.jsx +14 -35
  147. package/src/tui/components/Picker.jsx +87 -12
  148. package/src/tui/components/PromptInput.jsx +83 -7
  149. package/src/tui/components/QueuedCommands.jsx +1 -1
  150. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  151. package/src/tui/components/Spinner.jsx +7 -7
  152. package/src/tui/components/StatusLine.jsx +40 -21
  153. package/src/tui/components/TextEntryPanel.jsx +51 -7
  154. package/src/tui/components/ToolExecution.jsx +170 -98
  155. package/src/tui/components/TurnDone.jsx +4 -4
  156. package/src/tui/components/UsagePanel.jsx +1 -1
  157. package/src/tui/components/tool-output-format.mjs +159 -21
  158. package/src/tui/components/tool-output-format.test.mjs +87 -0
  159. package/src/tui/display-width.mjs +69 -0
  160. package/src/tui/display-width.test.mjs +35 -0
  161. package/src/tui/dist/index.mjs +6965 -2391
  162. package/src/tui/engine.mjs +287 -126
  163. package/src/tui/index.jsx +117 -7
  164. package/src/tui/keyboard-protocol.mjs +42 -0
  165. package/src/tui/lib/voice-recorder.mjs +453 -0
  166. package/src/tui/markdown/format-token.mjs +129 -76
  167. package/src/tui/markdown/format-token.test.mjs +61 -19
  168. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  169. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  170. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  171. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  172. package/src/tui/markdown/table-layout.mjs +9 -9
  173. package/src/tui/paste-attachments.mjs +0 -11
  174. package/src/tui/prompt-history-store.mjs +129 -0
  175. package/src/tui/prompt-history-store.test.mjs +52 -0
  176. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  177. package/src/tui/theme.mjs +41 -657
  178. package/src/tui/themes/base.mjs +86 -0
  179. package/src/tui/themes/basic.mjs +85 -0
  180. package/src/tui/themes/catppuccin.mjs +72 -0
  181. package/src/tui/themes/dracula.mjs +70 -0
  182. package/src/tui/themes/everforest.mjs +71 -0
  183. package/src/tui/themes/gruvbox.mjs +71 -0
  184. package/src/tui/themes/index.mjs +71 -0
  185. package/src/tui/themes/indigo.mjs +78 -0
  186. package/src/tui/themes/kanagawa.mjs +80 -0
  187. package/src/tui/themes/light.mjs +81 -0
  188. package/src/tui/themes/nord.mjs +72 -0
  189. package/src/tui/themes/onedark.mjs +16 -0
  190. package/src/tui/themes/rosepine.mjs +70 -0
  191. package/src/tui/themes/teal.mjs +81 -0
  192. package/src/tui/themes/tokyonight.mjs +79 -0
  193. package/src/tui/themes/utils.mjs +106 -0
  194. package/src/tui/themes/warm.mjs +79 -0
  195. package/src/tui/transcript-tool-failures.mjs +13 -2
  196. package/src/ui/markdown.mjs +1 -1
  197. package/src/ui/model-display.mjs +2 -2
  198. package/src/ui/statusline.mjs +26 -27
  199. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  200. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  201. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  202. package/src/workflows/default/WORKFLOW.md +39 -12
  203. package/src/workflows/sequential/WORKFLOW.md +46 -0
  204. package/src/workflows/solo/WORKFLOW.md +7 -0
  205. package/vendor/ink/build/display-width.js +62 -0
  206. package/vendor/ink/build/ink.js +100 -12
  207. package/vendor/ink/build/measure-text.js +4 -1
  208. package/vendor/ink/build/output.js +115 -9
  209. package/vendor/ink/build/render-node-to-output.js +4 -1
  210. package/vendor/ink/build/render.js +4 -0
  211. package/src/output-styles/extreme-simple.md +0 -20
  212. package/src/rules/lead/04-workflow.md +0 -51
  213. package/src/workflows/default/workflow.json +0 -13
  214. 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);
@@ -169,10 +191,27 @@ function resultTerminalStatus(value) {
169
191
  if (tagged) return normalizeTerminalStatus(tagged);
170
192
  const bracketed = text.match(/^\[status:\s*([^\]]*)\]/mi)?.[1]?.trim();
171
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).
172
199
  const inline = text.match(/^(?:status|state):\s*([^\s·,;]+)/mi)?.[1]?.trim();
173
200
  return normalizeTerminalStatus(inline);
174
201
  }
175
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
+
176
215
  function shellDisplayStatus({ pending = false, failedCount = 0, isError = false, result = '' } = {}) {
177
216
  const status = shellResultStatus(result);
178
217
  if (pending || /^(running|pending|queued)$/.test(status)) return 'running';
@@ -188,11 +227,6 @@ function shellHeader(status, count = 1) {
188
227
  return `Ran ${object}`;
189
228
  }
190
229
 
191
- function shellDetail(status, elapsed = '') {
192
- const label = displayTerminalStatus(status) || status;
193
- return elapsed ? `${elapsed} · ${label}` : label;
194
- }
195
-
196
230
  function shellResultElapsed(value) {
197
231
  const match = String(value || '').match(/^\[elapsed:\s*(\d+)\s*ms\]/mi);
198
232
  if (!match) return '';
@@ -211,20 +245,20 @@ function statusCopy(name, label, count, doneCount, pending, isError, args = {})
211
245
  }
212
246
 
213
247
  function fitResultLine(line, columns) {
214
- const max = Math.max(MIN_RESULT_LINE_CHARS, Number(columns || 80) - 7);
215
- const text = String(line ?? '');
216
- 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;
217
251
  }
218
252
 
219
253
  /** Trim text from the end (by display width) so it fits maxWidth, appending '…'. */
220
254
  function truncateToWidth(text, maxWidth) {
221
- const str = String(text ?? '');
255
+ const str = safeInlineText(text);
222
256
  if (maxWidth < 1) return '';
223
- if (stringWidth(str) <= maxWidth) return str;
257
+ if (displayWidth(str) <= maxWidth) return str;
224
258
  const chars = Array.from(str);
225
259
  let out = '';
226
260
  for (const ch of chars) {
227
- if (stringWidth(out + ch + '…') > maxWidth) break;
261
+ if (displayWidth(out + ch + '…') > maxWidth) break;
228
262
  out += ch;
229
263
  }
230
264
  return `${out}…`;
@@ -272,11 +306,6 @@ function agentModelLabel(args) {
272
306
  return displayModelName(model, provider, displayHint);
273
307
  }
274
308
 
275
- function withModel(label, args) {
276
- const model = agentModelLabel(args);
277
- return model ? `${label} (${model})` : label;
278
- }
279
-
280
309
  function agentTagLabel(args) {
281
310
  // The real spawn tag (engine fills parsedArgs.tag from the envelope target).
282
311
  // Never fall back to task_id — only the human-meaningful spawn tag belongs in
@@ -291,43 +320,46 @@ function withModelAndTag(label, args) {
291
320
  return inner ? `${label} (${inner})` : label;
292
321
  }
293
322
 
294
- // Append a role name to a base action word without leaving a trailing space
295
- // when the role is unknown (no generic "Agent" fallback).
296
- function joinActionRole(action, role) {
297
- 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;
298
327
  }
299
328
 
300
329
  function agentResponseTitle(args) {
301
- const name = titleizeAgentName(args?.agent || args?.role || args?.subagent_type || args?.name || '');
302
- // 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
303
332
  // is hidden in the collapsed card (ctrl+o expand still shows the full body).
304
- // No generic "Agent" fallback — render just "Response" when the role is empty.
305
- 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);
306
335
  }
307
336
 
308
337
  function agentActionTitle(args) {
309
- const name = titleizeAgentName(args?.agent || args?.role || args?.subagent_type || args?.name || '');
310
- 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();
311
343
  // Fixed action verbs regardless of running/completed status. No generic
312
- // "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
313
345
  // word alone ("Spawn") instead of "Spawn Agent".
314
- if (action === 'spawn') return withModelAndTag(joinActionRole('Spawn', name), args);
315
- 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);
316
348
  if (action === 'list') return 'Agent status';
317
- if (action === 'cancel') return withModelAndTag(joinActionRole('Cancel', name), args);
318
- if (action === 'close') return withModelAndTag(joinActionRole('Close', name), args);
319
- if (action === 'cleanup') return withModelAndTag(joinActionRole('Cleanup', name), args);
320
- 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);
321
353
  return '';
322
354
  }
323
355
 
324
356
  function agentActionSummary(args, summary) {
325
357
  const text = String(summary || '').trim();
326
358
  if (!text) return '';
327
- const name = titleizeAgentName(args?.agent || args?.role || args?.subagent_type || args?.name || '');
359
+ const name = titleizeAgentName(args?.agent || args?.subagent_type || args?.name || '');
328
360
  if (name && text === name) return '';
329
361
  let rest = name && text.startsWith(`${name} · `) ? text.slice(name.length + 3).trim() : text;
330
- // 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
331
363
  // into the header label itself ("Spawn Heavy Worker (Opus 4.8, tag)"), so drop
332
364
  // the model and tag tokens from the parenthesized summary to avoid showing
333
365
  // them twice.
@@ -358,7 +390,7 @@ function hasAgentResponseResult(value) {
358
390
  if (/^agent result\b/i.test(trimmed)) continue;
359
391
  if (/^(?:undefined|null)$/i.test(trimmed)) continue;
360
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;
361
- 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;
362
394
  if (!sawBlank && /^(?:agents|tasks):\s*/i.test(trimmed)) continue;
363
395
  if (/^\(no agents or tasks\)$/i.test(trimmed)) continue;
364
396
  if (!sawBlank && /^-\s+\S+/i.test(trimmed)) continue;
@@ -440,7 +472,10 @@ function prefixElapsed(detail, elapsed = '') {
440
472
  const text = String(detail || '').trim();
441
473
  const time = String(elapsed || '').trim();
442
474
  if (!time) return text;
443
- 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;
444
479
  }
445
480
 
446
481
  function mergeTerminalDetail(status, detail = '') {
@@ -518,10 +553,6 @@ function isOutputDetailTool(normalizedName, label) {
518
553
  ]).has(n) || l === 'read' || l === 'search' || l === 'web search' || l === 'run';
519
554
  }
520
555
 
521
- function progressDetail({ normalizedName, label, elapsed }) {
522
- return elapsed ? `${elapsed} elapsed` : '';
523
- }
524
-
525
556
  function genericCompletedDetail({ normalizedName, label, hasResult, firstResultLine, isError }) {
526
557
  const n = String(normalizedName || '').toLowerCase();
527
558
  const l = String(label || '').toLowerCase();
@@ -566,7 +597,8 @@ function agentTerminalDetail(status, isError, elapsed, error = '') {
566
597
  : /done|success|complete|closed/.test(s)
567
598
  ? 'Finished'
568
599
  : '';
569
- return word ? `${word}${elapsed ? ` after ${elapsed}` : ''}` : '';
600
+ // Unified ` · <time>` convention (previously "Finished after 12s").
601
+ return word ? `${word}${elapsed ? ` · ${elapsed}` : ''}` : '';
570
602
  }
571
603
 
572
604
  function clampFailureCount(errorCount, groupCount, isError) {
@@ -575,17 +607,26 @@ function clampFailureCount(errorCount, groupCount, isError) {
575
607
  return isError ? groupCount : 0;
576
608
  }
577
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
578
618
  function toolStatusColor({ pending, groupCount, failedCount, terminalStatus = '' }) {
579
- if (pending) return theme.success;
619
+ if (pending) return theme.mixdogOrange || theme.warning;
580
620
  const status = normalizeTerminalStatus(terminalStatus);
621
+ if (status === 'cancelled') return theme.warning;
581
622
  if (status === 'failed') return theme.error;
582
- if (status === 'cancelled') return theme.warning || theme.mixdogOrange || theme.subtle;
583
623
  if (failedCount <= 0) return theme.success;
584
624
  if (groupCount > 1 && failedCount < groupCount) return theme.mixdogOrange || theme.warning;
585
625
  return theme.error;
586
626
  }
587
627
 
588
- 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));
589
630
  const [blinkOn, setBlinkOn] = useState(true);
590
631
  const [blinkExpired, setBlinkExpired] = useState(false);
591
632
  const [pendingDelayElapsed, setPendingDelayElapsed] = useState(false);
@@ -619,7 +660,6 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
619
660
  const elapsedMs = startedAtMs ? Math.max(0, (pending ? Date.now() : (completedAtMs || Date.now())) - startedAtMs) : 0;
620
661
  const elapsed = elapsedMs >= 1000 ? formatElapsed(elapsedMs) : '';
621
662
  const failedCount = clampFailureCount(errorCount, groupCount, isError);
622
- const statusColor = toolStatusColor({ pending, groupCount, failedCount });
623
663
  const displayGroupCount = groupCount;
624
664
  const displayCategories = normalizeCountMap(categories || {});
625
665
 
@@ -692,7 +732,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
692
732
  // header + one brief detail row (see estimateTranscriptItemRows).
693
733
  const placeholderSingleRow = !aggregate && SKILL_SURFACE_NAMES.has(placeholderNormalizedName);
694
734
  return (
695
- <Box flexDirection="column" marginTop={attached ? 0 : 1}>
735
+ <Box flexDirection="column" marginTop={attached ? 0 : 1} width={rowWidth} overflow="hidden">
696
736
  <Text> </Text>
697
737
  {placeholderSingleRow ? null : <Text> </Text>}
698
738
  </Box>
@@ -708,7 +748,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
708
748
  // No stableVerbWidth: see statusCopy — the padding only left a mid-header
709
749
  // gap ("Searched 1 pattern, Read 1 file") since Ink trims trailing
710
750
  // spaces and never stabilized the flip.
711
- const headerText = formatAggregateHeader(displayCategories || {}, { pending: headerPending, order: headerOrder });
751
+ const headerText = safeInlineText(formatAggregateHeader(displayCategories || {}, { pending: headerPending, order: headerOrder }));
712
752
  let detailText;
713
753
  if (hasResult) {
714
754
  // The aggregate card reserves EXACTLY ONE detail row when it is not
@@ -719,33 +759,40 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
719
759
  // "settle" taller than reserved. Collapse to a single logical line
720
760
  // (whitespace-normalized); fitResultLine below trims it to the column
721
761
  // width so it can never exceed one terminal row.
722
- detailText = String(rt).replace(/\s+/g, ' ').trim();
762
+ detailText = safeInlineText(rt);
723
763
  } else {
724
764
  detailText = '';
725
765
  }
726
766
 
727
- 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 });
728
776
  const dotText = pending && !blinkExpired && !blinkOn ? ' ' : TURN_MARKER;
729
777
  const gutter = 2;
730
778
  const showHeaderExpandHint = hasRawResult;
731
779
  const hintLabel = `ctrl+o ${expanded ? 'collapse' : 'expand'}`;
732
780
  const hintText = ` ${BULLET_OPERATOR} ${hintLabel}`;
733
- const headerMetaText = pending && elapsed ? ` (${elapsed} elapsed)` : '';
734
- // Reserve the expand-hint slot for the card's whole lifecycle so the header
735
- // body never reflows when the hint appears on completion. During pending the
736
- // elapsed meta shares this same right region; reserving the wider of the two
737
- // keeps `avail` (and the header clip point) fixed, so nothing "appears then
738
- // shrinks back" on the right edge as counts/results land.
739
- 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);
740
787
  const avail = Math.max(1, (Number(columns) || 80) - 1 - gutter - rightReserve);
741
- const trailingText = headerMetaText || (showHeaderExpandHint ? hintText : '');
788
+ const trailingText = showHeaderExpandHint ? hintText : '';
742
789
  const trailingColor = theme.subtle;
743
790
  const clippedHeader = stringWidth(headerText) > avail
744
791
  ? truncateToWidth(headerText, avail)
745
792
  : headerText;
746
- // Trailing content (elapsed while pending, ctrl+o hint when done) always
747
- // sits immediately after the header body — no fixed right-edge pin — so it
748
- // 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.
749
796
  // Keep the aggregate card at a fixed height (header + one detail row) for
750
797
  // its whole lifecycle. Pending cards have no result yet, so reserve the
751
798
  // detail row up front instead of growing from 1→2 rows when the summary
@@ -759,13 +806,19 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
759
806
  // color; the status placeholder is rendered dim.
760
807
  const isPlaceholderDetail = !(expanded && hasRawResult) && !detailText;
761
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';
762
815
  const detailLines = showRawAggregate
763
816
  ? rawRt.split('\n')
764
- : (detailText ? [detailText] : [headerPending ? 'Running' : 'Finished']);
817
+ : (detailText ? [detailText] : [pendingPlaceholder]);
765
818
  const aggregateDetailColor = isPlaceholderDetail ? theme.subtle : theme.text;
766
819
  return (
767
- <Box flexDirection="column" marginTop={attached ? 0 : 1}>
768
- <Box flexDirection="row">
820
+ <Box flexDirection="column" marginTop={attached ? 0 : 1} width={rowWidth} overflow="hidden">
821
+ <Box flexDirection="row" width={rowWidth} overflow="hidden">
769
822
  <Box flexShrink={0} minWidth={2}>
770
823
  <Text color={dotColor}>{dotText}</Text>
771
824
  </Box>
@@ -797,30 +850,35 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
797
850
  const backgroundResultText = backgroundMeta?.hasResponse ? backgroundMeta.body : '';
798
851
  const displayedResultText = backgroundResultText || (errorOnlyResult ? '' : (rt || ''));
799
852
  const hasDisplayResult = Boolean(String(displayedResultText || '').trim());
800
- 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') : [];
801
856
  const totalLines = lines.length;
802
857
  // Semantic one-line summary derived purely from name/args/result text.
803
858
  // Shown in the collapsed, non-error view in place of the raw result block.
804
859
  // Grouped cards ("Searched N files" / "Read N files") get the same treatment
805
860
  // as single calls: a one-line semantic summary stands in for the raw block.
806
- const resultSummary = !pending && hasDisplayResult
807
- ? surfaceSummarizeToolResult(name, args, displayedResultText, isError)
861
+ const resultSummary = !pending && hasDisplayBody
862
+ ? surfaceSummarizeToolResult(name, args, displayedResultBodyText, isError)
808
863
  : null;
809
864
  // Same fit budget fitResultLine() uses, to detect a line that will be clipped.
810
- 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));
811
866
  const resultColor = theme.text;
812
867
  const firstResultLine = hasDisplayResult ? String(lines[0] ?? '') : '';
813
- const firstResultLineClipped = hasDisplayResult && stringWidth(firstResultLine) > maxResultChars;
814
- 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));
815
870
  const shellStatus = isShellSurface ? shellDisplayStatus({ pending, failedCount, isError, result: displayedResultText }) : '';
816
871
  const shellElapsed = isShellSurface ? (shellResultElapsed(displayedResultText) || elapsed) : '';
817
- const shellStatusDetail = isShellSurface ? shellDetail(shellStatus, shellElapsed) : '';
818
872
  const backgroundElapsed = backgroundMeta
819
873
  ? backgroundTaskElapsed(backgroundMeta, elapsed)
820
874
  : (isBackgroundTaskTool(normalizedName) ? backgroundTaskElapsed(parsedArgs, elapsed) : '');
821
875
 
822
876
  const toolArgPath = parsedArgs?.path ?? parsedArgs?.file_path ?? parsedArgs?.file ?? '';
823
- 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) : '';
824
882
  const isBackgroundResult = !pending && isBackgroundTaskTool(normalizedName) && Boolean(backgroundMeta);
825
883
  const isBackgroundResponse = isBackgroundResult && (backgroundMeta?.hasResponse || isBackgroundTaskResponseArgs(normalizedName, parsedArgs));
826
884
  const isBackgroundMetadataResult = isBackgroundResult && !isBackgroundResponse && Boolean(backgroundMeta);
@@ -868,20 +926,22 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
868
926
  // the final summary just fills in place. Skill surfaces collapse to a single
869
927
  // row in BOTH the estimate and the render (visibleDetailLines drops the row
870
928
  // for isSkillSurface below), so they get no placeholder.
871
- const pendingDetailPlaceholder = pending && !isSkillSurface ? 'Running' : '';
929
+ const pendingDetailPlaceholder = pending && !isSkillSurface
930
+ ? (elapsed ? `Running · ${elapsed}` : 'Running')
931
+ : '';
872
932
  const shellCollapsedSummary = isShellSurface && !pending && hasDisplayResult
873
933
  ? (resultSummary || truncateToWidth(firstResultLine, Math.min(120, maxResultChars)))
874
934
  : resultSummary;
875
935
  const collapsedDetail = pending
876
936
  ? pendingDetailPlaceholder
877
937
  : isShellSurface
878
- ? mergeTerminalDetail(shellStatus, shellCollapsedSummary)
938
+ ? prefixElapsed(mergeTerminalDetail(shellStatus, shellCollapsedSummary), shellElapsed)
879
939
  : mergeTerminalDetail(terminalStatus, nonShellDetail);
880
940
  const backgroundMetadataExpandable = isBackgroundMetadataResult && hasRawResult && !pending;
881
- const showRawResult = expanded && (hasDisplayResult || hasRawResult)
941
+ const showRawResult = expanded && (hasDisplayBody || hasRawResult)
882
942
  && (!isBackgroundMetadataResult || hasRawResult);
883
943
  const detailLines = showRawResult
884
- ? (hasDisplayResult ? lines : (rawRt ? rawRt.split('\n') : []))
944
+ ? (hasDisplayBody ? lines : (rawRt ? stripLeadingStatusMarkerLines(rawRt.split('\n')) : []))
885
945
  : (collapsedDetail ? [collapsedDetail] : []);
886
946
  const isPendingPlaceholderDetail = !showRawResult && Boolean(pendingDetailPlaceholder);
887
947
  const detailColor = isPendingPlaceholderDetail ? theme.subtle : theme.text;
@@ -903,7 +963,10 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
903
963
  // collapsed; ctrl+o expand still surfaces the full body.
904
964
  let visibleDetailLines = detailLines;
905
965
  if (isSkillSurface && !showRawResult) {
906
- 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] : [];
907
970
  } else if (isBackgroundMetadataResult && backgroundMetadataHeaderFailure && !showRawResult) {
908
971
  visibleDetailLines = [];
909
972
  } else if (isAgentSurfaceCard && !showRawResult) {
@@ -922,14 +985,25 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
922
985
  else if (isBackgroundMetadataResult) labelText = backgroundTaskActionTitle(normalizedName, backgroundMeta);
923
986
  else if (isShellSurface) labelText = shellHeader(shellStatus, displayGroupCount);
924
987
  else labelText = (isAgentTool(normalizedName) ? agentActionTitle(parsedArgs) : '') || statusCopy(name, label, displayGroupCount, doneCount, headerPending, isError, parsedArgs);
988
+ labelText = safeInlineText(labelText);
925
989
  // Show the parenthesized arg summary for grouped cards too, matching single
926
990
  // calls so the header carries the same context.
927
991
  const toolSearchSummary = !pending && normalizedName === 'tool_search' && hasResult
928
992
  ? toolSearchLoadedSummary(displayedResultText)
929
993
  : '';
930
- const summaryText = isAgentResponse || isBackgroundResponse
994
+ const rawSummaryText = safeInlineText(isAgentResponse || isBackgroundResponse
931
995
  ? ''
932
- : 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;
933
1007
  // Agent cards hide their collapsed body but still expose ctrl+o expand only
934
1008
  // when expanding would actually reveal something: an agent response body, or a
935
1009
  // multiline / clipped raw result (e.g. the "agents: N …" worker list). A
@@ -941,6 +1015,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
941
1015
  // hasHiddenDetail, which goes true for any single-line resultSummary and would
942
1016
  // wrongly show ctrl+o on a status-only one-liner that has nothing to expand.
943
1017
  const shellHasExpandableBody = isShellSurface && !pending && hasDisplayResult
1018
+ && hasDisplayBody
944
1019
  && (totalLines > 1 || firstResultLineClipped || Boolean(shellCollapsedSummary && shellCollapsedSummary !== firstResultLine));
945
1020
  const showHeaderExpandHint = (isShellSurface ? shellHasExpandableBody : (isAgentSurfaceCard ? agentHasExpandableBody : (hasHiddenDetail || backgroundMetadataExpandable)))
946
1021
  && normalizedName !== 'tool_search';
@@ -954,25 +1029,22 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
954
1029
  const gutter = 2;
955
1030
  const hintLabel = showHeaderExpandHint ? `ctrl+o ${expanded ? 'collapse' : 'expand'}` : '';
956
1031
  const hintText = hintLabel ? ` ${BULLET_OPERATOR} ${hintLabel}` : '';
957
- const headerMetaText = pending && elapsed ? ` (${elapsed} elapsed)` : '';
958
- // The expand hint (post-completion) and the elapsed meta (pending) occupy the
959
- // SAME trailing region but never at the same time. Subtracting both widths
960
- // made `avail` shrink/grow across the pending→completed transition, so the
961
- // label/summary clip point shifted and the header text "jumped out then
962
- // snapped back" right as a tool finished or cards merged. Reserve the wider of
963
- // the two for the whole lifecycle (matching the aggregate card's rightReserve)
964
- // so `avail` stays fixed and nothing reflows. The hint slot is reserved even
965
- // 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.
966
1038
  const hintReserveLabel = `ctrl+o ${expanded ? 'collapse' : 'expand'}`;
967
1039
  const hintReserveText = ` ${BULLET_OPERATOR} ${hintReserveLabel}`;
968
1040
  const headerFailureText = headerFailureStatus
969
1041
  ? truncateToWidth(headerFailureStatus, HEADER_FAILURE_STATUS_MAX)
970
1042
  : '';
971
1043
  const inlineFailureText = headerFailureText ? ` ${BULLET_OPERATOR} ${headerFailureText}` : '';
972
- const rightReserve = Math.max(stringWidth(hintReserveText), stringWidth(headerMetaText)) + stringWidth(inlineFailureText);
1044
+ const rightReserve = stringWidth(hintReserveText) + stringWidth(inlineFailureText);
973
1045
  const avail = Math.max(1, (Number(columns) || 80) - 1 - gutter - rightReserve);
974
- const trailingText = headerMetaText || (showHeaderExpandHint ? hintText : '');
975
- const trailingColor = headerMetaText ? theme.subtle : expandHintColor;
1046
+ const trailingText = showHeaderExpandHint ? hintText : '';
1047
+ const trailingColor = expandHintColor;
976
1048
  let labelOut;
977
1049
  let summaryOut;
978
1050
  if (stringWidth(labelText) >= avail) {
@@ -989,13 +1061,13 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
989
1061
  : '';
990
1062
  summaryOut = truncatedSummary ? ` (${truncatedSummary})` : '';
991
1063
  }
992
- // Keep trailing content (pending elapsed / completed ctrl+o hint) attached
993
- // directly after the body for the whole lifecycle. The fixed-column pin
994
- // previously used for elapsed is what made the trailing text jump to the right
995
- // edge and snap back on the pending→done flip, so there is no pad. `avail`
996
- // 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.
997
1069
  return (
998
- <Box flexDirection="column" marginTop={attached ? 0 : 1}>
1070
+ <Box flexDirection="column" marginTop={attached ? 0 : 1} width={rowWidth} overflow="hidden">
999
1071
  <Box flexDirection="row" width="100%">
1000
1072
  <Box flexShrink={1} flexGrow={1} overflow="hidden" minWidth={0}>
1001
1073
  <Box flexDirection="row">
@@ -1014,7 +1086,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
1014
1086
 
1015
1087
  <ResultBody
1016
1088
  lines={visibleDetailLines}
1017
- rawText={hasDisplayResult ? displayedResultText : (rawRt || '')}
1089
+ rawText={hasDisplayBody ? displayedResultBodyText : stripLeadingStatusMarkerFromText(rawRt || '')}
1018
1090
  pathArg={toolArgPath}
1019
1091
  isShell={isShellSurface}
1020
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)}`;