mixdog 0.9.1 → 0.9.3

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 (301) hide show
  1. package/package.json +9 -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/anthropic-maxtokens-test.mjs +119 -0
  6. package/scripts/background-task-meta-smoke.mjs +1 -1
  7. package/scripts/bench-run.mjs +262 -0
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/compact-smoke.mjs +12 -0
  10. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  11. package/scripts/explore-bench.mjs +124 -0
  12. package/scripts/hook-bus-test.mjs +191 -0
  13. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  14. package/scripts/internal-comms-bench.mjs +727 -0
  15. package/scripts/internal-comms-smoke.mjs +75 -0
  16. package/scripts/lead-workflow-smoke.mjs +4 -4
  17. package/scripts/live-worker-smoke.mjs +9 -9
  18. package/scripts/output-style-bench.mjs +285 -0
  19. package/scripts/output-style-smoke.mjs +13 -10
  20. package/scripts/patch-replay.mjs +90 -0
  21. package/scripts/path-suffix-test.mjs +57 -0
  22. package/scripts/provider-stream-stall-test.mjs +276 -0
  23. package/scripts/provider-toolcall-test.mjs +599 -1
  24. package/scripts/recall-bench.mjs +207 -0
  25. package/scripts/routing-corpus.mjs +281 -0
  26. package/scripts/session-bench.mjs +1526 -0
  27. package/scripts/session-diag.mjs +595 -0
  28. package/scripts/task-bench.mjs +207 -0
  29. package/scripts/tool-failures.mjs +6 -6
  30. package/scripts/tool-smoke.mjs +310 -67
  31. package/scripts/toolcall-args-test.mjs +81 -0
  32. package/src/agents/debugger/AGENT.md +4 -4
  33. package/src/agents/heavy-worker/AGENT.md +20 -9
  34. package/src/agents/reviewer/AGENT.md +4 -4
  35. package/src/agents/worker/AGENT.md +17 -9
  36. package/src/app.mjs +10 -6
  37. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  38. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  39. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  40. package/src/headless-role.mjs +14 -14
  41. package/src/help.mjs +1 -0
  42. package/src/lib/rules-builder.cjs +32 -54
  43. package/src/mixdog-session-runtime.mjs +1040 -2036
  44. package/src/output-styles/default.md +12 -7
  45. package/src/output-styles/minimal.md +25 -0
  46. package/src/output-styles/oneline.md +21 -0
  47. package/src/output-styles/simple.md +10 -9
  48. package/src/repl.mjs +17 -7
  49. package/src/rules/agent/00-common.md +7 -5
  50. package/src/rules/agent/30-explorer.md +8 -12
  51. package/src/rules/lead/01-general.md +3 -1
  52. package/src/rules/lead/lead-tool.md +13 -2
  53. package/src/rules/shared/01-tool.md +23 -12
  54. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  55. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  59. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  60. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  61. package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
  62. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  63. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
  65. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  66. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
  70. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
  71. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
  72. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  73. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  74. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  75. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  76. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
  78. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
  79. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
  80. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  82. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
  83. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  84. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
  85. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  86. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  87. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  88. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  89. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  90. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  91. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  93. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  94. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  97. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  98. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  99. package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
  100. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  101. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  102. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  103. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  104. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  105. package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
  106. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  107. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  108. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  109. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  110. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  113. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  116. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  118. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  119. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  121. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
  123. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  124. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  125. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
  126. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  127. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  129. package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
  130. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  131. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  132. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  134. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  135. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  136. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  137. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  138. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  139. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  140. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  141. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  142. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  148. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  149. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  150. package/src/runtime/channels/backends/discord.mjs +99 -9
  151. package/src/runtime/channels/backends/telegram.mjs +501 -0
  152. package/src/runtime/channels/index.mjs +437 -1439
  153. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  154. package/src/runtime/channels/lib/config.mjs +54 -2
  155. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  156. package/src/runtime/channels/lib/format.mjs +4 -2
  157. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  158. package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
  159. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  160. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  161. package/src/runtime/channels/lib/telegram-format.mjs +280 -0
  162. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  164. package/src/runtime/channels/lib/webhook.mjs +59 -31
  165. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  166. package/src/runtime/channels/tool-defs.mjs +1 -1
  167. package/src/runtime/memory/index.mjs +465 -345
  168. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  169. package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
  170. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  171. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  172. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  173. package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
  174. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  175. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  176. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  177. package/src/runtime/memory/lib/memory.mjs +121 -4
  178. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  179. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  180. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  181. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  182. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  183. package/src/runtime/memory/tool-defs.mjs +10 -7
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/background-tasks.mjs +2 -3
  186. package/src/runtime/shared/buffered-appender.mjs +149 -0
  187. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  188. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  189. package/src/runtime/shared/config.mjs +9 -0
  190. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  191. package/src/runtime/shared/schedules-store.mjs +21 -19
  192. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  193. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  194. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  195. package/src/runtime/shared/tool-surface.mjs +98 -13
  196. package/src/runtime/shared/transcript-writer.mjs +156 -0
  197. package/src/runtime/shared/update-checker.mjs +214 -0
  198. package/src/session-runtime/config-helpers.mjs +209 -0
  199. package/src/session-runtime/effort.mjs +128 -0
  200. package/src/session-runtime/fs-utils.mjs +10 -0
  201. package/src/session-runtime/model-capabilities.mjs +130 -0
  202. package/src/session-runtime/output-styles.mjs +124 -0
  203. package/src/session-runtime/plugin-mcp.mjs +114 -0
  204. package/src/session-runtime/session-text.mjs +100 -0
  205. package/src/session-runtime/statusline-route.mjs +35 -0
  206. package/src/session-runtime/tool-catalog.mjs +720 -0
  207. package/src/session-runtime/workflow.mjs +358 -0
  208. package/src/standalone/agent-tool.mjs +302 -117
  209. package/src/standalone/channel-admin.mjs +133 -40
  210. package/src/standalone/channel-worker.mjs +10 -292
  211. package/src/standalone/explore-tool.mjs +12 -5
  212. package/src/standalone/hook-bus.mjs +165 -8
  213. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  214. package/src/standalone/opencode-go-login.mjs +121 -0
  215. package/src/standalone/provider-admin.mjs +25 -3
  216. package/src/standalone/usage-dashboard.mjs +1 -1
  217. package/src/tui/App.jsx +2883 -778
  218. package/src/tui/components/ConfirmBar.jsx +47 -0
  219. package/src/tui/components/ContextPanel.jsx +5 -3
  220. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  221. package/src/tui/components/Markdown.jsx +22 -98
  222. package/src/tui/components/Message.jsx +14 -35
  223. package/src/tui/components/Picker.jsx +87 -12
  224. package/src/tui/components/PromptInput.jsx +355 -22
  225. package/src/tui/components/QueuedCommands.jsx +1 -1
  226. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  227. package/src/tui/components/Spinner.jsx +7 -7
  228. package/src/tui/components/StatusLine.jsx +40 -21
  229. package/src/tui/components/TextEntryPanel.jsx +51 -7
  230. package/src/tui/components/ToolExecution.jsx +183 -101
  231. package/src/tui/components/TurnDone.jsx +4 -4
  232. package/src/tui/components/UsagePanel.jsx +1 -1
  233. package/src/tui/components/tool-output-format.mjs +161 -23
  234. package/src/tui/components/tool-output-format.test.mjs +87 -0
  235. package/src/tui/display-width.mjs +69 -0
  236. package/src/tui/display-width.test.mjs +35 -0
  237. package/src/tui/dist/index.mjs +6731 -2333
  238. package/src/tui/engine/agent-envelope.mjs +296 -0
  239. package/src/tui/engine/boot-profile.mjs +21 -0
  240. package/src/tui/engine/labels.mjs +67 -0
  241. package/src/tui/engine/notice-text.mjs +112 -0
  242. package/src/tui/engine/queue-helpers.mjs +161 -0
  243. package/src/tui/engine/session-stats.mjs +46 -0
  244. package/src/tui/engine/tool-call-fields.mjs +23 -0
  245. package/src/tui/engine/tool-result-text.mjs +126 -0
  246. package/src/tui/engine.mjs +569 -948
  247. package/src/tui/index.jsx +117 -7
  248. package/src/tui/input-editing.mjs +58 -8
  249. package/src/tui/input-editing.selection.test.mjs +75 -0
  250. package/src/tui/keyboard-protocol.mjs +42 -0
  251. package/src/tui/lib/voice-recorder.mjs +469 -0
  252. package/src/tui/markdown/format-token.mjs +128 -76
  253. package/src/tui/markdown/format-token.test.mjs +61 -19
  254. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  255. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  256. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  257. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  258. package/src/tui/markdown/table-layout.mjs +9 -9
  259. package/src/tui/paste-attachments.mjs +36 -9
  260. package/src/tui/paste-fix.test.mjs +119 -0
  261. package/src/tui/prompt-history-store.mjs +129 -0
  262. package/src/tui/prompt-history-store.test.mjs +52 -0
  263. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  264. package/src/tui/theme.mjs +41 -657
  265. package/src/tui/themes/base.mjs +86 -0
  266. package/src/tui/themes/basic.mjs +85 -0
  267. package/src/tui/themes/catppuccin.mjs +72 -0
  268. package/src/tui/themes/dracula.mjs +70 -0
  269. package/src/tui/themes/everforest.mjs +71 -0
  270. package/src/tui/themes/gruvbox.mjs +71 -0
  271. package/src/tui/themes/index.mjs +71 -0
  272. package/src/tui/themes/indigo.mjs +78 -0
  273. package/src/tui/themes/kanagawa.mjs +80 -0
  274. package/src/tui/themes/light.mjs +81 -0
  275. package/src/tui/themes/nord.mjs +72 -0
  276. package/src/tui/themes/onedark.mjs +16 -0
  277. package/src/tui/themes/rosepine.mjs +70 -0
  278. package/src/tui/themes/teal.mjs +80 -0
  279. package/src/tui/themes/tokyonight.mjs +79 -0
  280. package/src/tui/themes/utils.mjs +106 -0
  281. package/src/tui/themes/warm.mjs +79 -0
  282. package/src/tui/transcript-tool-failures.mjs +13 -2
  283. package/src/ui/markdown.mjs +1 -1
  284. package/src/ui/model-display.mjs +2 -2
  285. package/src/ui/statusline.mjs +75 -27
  286. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  287. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  288. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  289. package/src/workflows/default/WORKFLOW.md +46 -12
  290. package/src/workflows/sequential/WORKFLOW.md +51 -0
  291. package/src/workflows/solo/WORKFLOW.md +12 -1
  292. package/vendor/ink/build/display-width.js +62 -0
  293. package/vendor/ink/build/ink.js +100 -12
  294. package/vendor/ink/build/measure-text.js +4 -1
  295. package/vendor/ink/build/output.js +115 -9
  296. package/vendor/ink/build/render-node-to-output.js +4 -1
  297. package/vendor/ink/build/render.js +4 -0
  298. package/src/output-styles/extreme-simple.md +0 -20
  299. package/src/rules/lead/04-workflow.md +0 -51
  300. package/src/workflows/default/workflow.json +0 -13
  301. 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();
@@ -540,7 +571,10 @@ function toolSearchLoadedSummary(resultText) {
540
571
  try {
541
572
  parsed = JSON.parse(String(resultText || ''));
542
573
  } catch {
543
- return '';
574
+ const text = String(resultText || '');
575
+ const match = /^Loaded deferred tools:\s*(.+)$/m.exec(text);
576
+ if (!match) return '';
577
+ return [...new Set(match[1].split(',').map((name) => name.trim()).filter(Boolean))].join(', ');
544
578
  }
545
579
  const tools = parsed?.selected?.tools;
546
580
  if (!tools || typeof tools !== 'object') return '';
@@ -566,7 +600,8 @@ function agentTerminalDetail(status, isError, elapsed, error = '') {
566
600
  : /done|success|complete|closed/.test(s)
567
601
  ? 'Finished'
568
602
  : '';
569
- return word ? `${word}${elapsed ? ` after ${elapsed}` : ''}` : '';
603
+ // Unified ` · <time>` convention (previously "Finished after 12s").
604
+ return word ? `${word}${elapsed ? ` · ${elapsed}` : ''}` : '';
570
605
  }
571
606
 
572
607
  function clampFailureCount(errorCount, groupCount, isError) {
@@ -575,17 +610,30 @@ function clampFailureCount(errorCount, groupCount, isError) {
575
610
  return isError ? groupCount : 0;
576
611
  }
577
612
 
613
+ // Single source of truth for the tool-card dot (●) color. Both the aggregate
614
+ // and normal (single-tool) render paths must call this with a resolved
615
+ // `terminalStatus` — do not recompute color inline elsewhere.
616
+ // running/pending -> theme.text (white; blink handled by caller)
617
+ // success -> theme.success
618
+ // partial failure -> mixdogOrange || warning (some, not all, of the group failed)
619
+ // all failed -> theme.error
620
+ // cancelled -> theme.warning
621
+ // Note: callers resolve terminalStatus to 'failed' whenever failedCount > 0
622
+ // (they cannot tell partial from total failure), so a 'failed' status must
623
+ // still fall through to the failedCount check below rather than short-circuit
624
+ // to error — otherwise a mixed-success batch (e.g. 1 of 3 failed) renders as
625
+ // full-failure red instead of partial orange.
578
626
  function toolStatusColor({ pending, groupCount, failedCount, terminalStatus = '' }) {
579
- if (pending) return theme.success;
627
+ if (pending) return theme.text;
580
628
  const status = normalizeTerminalStatus(terminalStatus);
581
- if (status === 'failed') return theme.error;
582
- if (status === 'cancelled') return theme.warning || theme.mixdogOrange || theme.subtle;
583
- if (failedCount <= 0) return theme.success;
629
+ if (status === 'cancelled') return theme.warning;
630
+ if (failedCount <= 0) return status === 'failed' ? theme.error : theme.success;
584
631
  if (groupCount > 1 && failedCount < groupCount) return theme.mixdogOrange || theme.warning;
585
632
  return theme.error;
586
633
  }
587
634
 
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 }) {
635
+ 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 }) {
636
+ const rowWidth = Math.max(1, Number(columns || 80));
589
637
  const [blinkOn, setBlinkOn] = useState(true);
590
638
  const [blinkExpired, setBlinkExpired] = useState(false);
591
639
  const [pendingDelayElapsed, setPendingDelayElapsed] = useState(false);
@@ -619,7 +667,6 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
619
667
  const elapsedMs = startedAtMs ? Math.max(0, (pending ? Date.now() : (completedAtMs || Date.now())) - startedAtMs) : 0;
620
668
  const elapsed = elapsedMs >= 1000 ? formatElapsed(elapsedMs) : '';
621
669
  const failedCount = clampFailureCount(errorCount, groupCount, isError);
622
- const statusColor = toolStatusColor({ pending, groupCount, failedCount });
623
670
  const displayGroupCount = groupCount;
624
671
  const displayCategories = normalizeCountMap(categories || {});
625
672
 
@@ -692,7 +739,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
692
739
  // header + one brief detail row (see estimateTranscriptItemRows).
693
740
  const placeholderSingleRow = !aggregate && SKILL_SURFACE_NAMES.has(placeholderNormalizedName);
694
741
  return (
695
- <Box flexDirection="column" marginTop={attached ? 0 : 1}>
742
+ <Box flexDirection="column" marginTop={attached ? 0 : 1} width={rowWidth} overflow="hidden">
696
743
  <Text> </Text>
697
744
  {placeholderSingleRow ? null : <Text> </Text>}
698
745
  </Box>
@@ -708,7 +755,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
708
755
  // No stableVerbWidth: see statusCopy — the padding only left a mid-header
709
756
  // gap ("Searched 1 pattern, Read 1 file") since Ink trims trailing
710
757
  // spaces and never stabilized the flip.
711
- const headerText = formatAggregateHeader(displayCategories || {}, { pending: headerPending, order: headerOrder });
758
+ const headerText = safeInlineText(formatAggregateHeader(displayCategories || {}, { pending: headerPending, order: headerOrder }));
712
759
  let detailText;
713
760
  if (hasResult) {
714
761
  // The aggregate card reserves EXACTLY ONE detail row when it is not
@@ -719,33 +766,40 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
719
766
  // "settle" taller than reserved. Collapse to a single logical line
720
767
  // (whitespace-normalized); fitResultLine below trims it to the column
721
768
  // width so it can never exceed one terminal row.
722
- detailText = String(rt).replace(/\s+/g, ' ').trim();
769
+ detailText = safeInlineText(rt);
723
770
  } else {
724
771
  detailText = '';
725
772
  }
726
773
 
727
- const dotColor = !hasResult && !pending ? theme.subtle : statusColor;
774
+ // Resolve the aggregate's terminalStatus from the collapsed detail `rt`
775
+ // (which carries a `[status: cancelled]`/`<status>` marker when the
776
+ // aggregate was cancelled) plus isError/failedCount for failures. Pending
777
+ // stays running; a clean completion stays success. toolStatusColor is the
778
+ // single source of dot color for both aggregate and normal cards.
779
+ const aggregateTerminalStatus = pending
780
+ ? 'running'
781
+ : (resultTerminalStatus(rt) || (isError || failedCount > 0 ? 'failed' : 'completed'));
782
+ const dotColor = toolStatusColor({ pending, groupCount, failedCount, terminalStatus: aggregateTerminalStatus });
728
783
  const dotText = pending && !blinkExpired && !blinkOn ? ' ' : TURN_MARKER;
729
784
  const gutter = 2;
730
785
  const showHeaderExpandHint = hasRawResult;
731
786
  const hintLabel = `ctrl+o ${expanded ? 'collapse' : 'expand'}`;
732
787
  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));
788
+ // The header right-side trailing slot only ever shows the ctrl+o hint. The
789
+ // pending elapsed meta was removed from the header it lives on the detail
790
+ // row now (`Running · 12s`) so a per-second digit change never reflows the
791
+ // header. Still reserve the hint slot for the whole lifecycle so the body
792
+ // clip point stays fixed when the hint appears on completion.
793
+ const rightReserve = stringWidth(hintText);
740
794
  const avail = Math.max(1, (Number(columns) || 80) - 1 - gutter - rightReserve);
741
- const trailingText = headerMetaText || (showHeaderExpandHint ? hintText : '');
795
+ const trailingText = showHeaderExpandHint ? hintText : '';
742
796
  const trailingColor = theme.subtle;
743
797
  const clippedHeader = stringWidth(headerText) > avail
744
798
  ? truncateToWidth(headerText, avail)
745
799
  : 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.
800
+ // Trailing content (ctrl+o hint only; pending elapsed lives on the detail
801
+ // row) sits immediately after the header body — no fixed right-edge pin — so
802
+ // it never jumps to the right edge and snaps back on the pending→done flip.
749
803
  // Keep the aggregate card at a fixed height (header + one detail row) for
750
804
  // its whole lifecycle. Pending cards have no result yet, so reserve the
751
805
  // detail row up front instead of growing from 1→2 rows when the summary
@@ -759,13 +813,22 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
759
813
  // color; the status placeholder is rendered dim.
760
814
  const isPlaceholderDetail = !(expanded && hasRawResult) && !detailText;
761
815
  const showRawAggregate = expanded && hasRawResult;
816
+ // Aggregate cards intentionally omit elapsed time once grouped. A brief
817
+ // `Running · 1s` tick during the grouped→finished handoff reads as visual
818
+ // noise, and the grouped header already communicates that work is active.
819
+ // The placeholder tracks `pending` (real completion), NOT headerPending:
820
+ // the header verb stays active until the block seals, but the detail row
821
+ // must not keep saying "Running" after every call already resolved.
822
+ const pendingPlaceholder = pending
823
+ ? 'Running'
824
+ : 'Finished';
762
825
  const detailLines = showRawAggregate
763
826
  ? rawRt.split('\n')
764
- : (detailText ? [detailText] : [headerPending ? 'Running' : 'Finished']);
827
+ : (detailText ? [detailText] : [pendingPlaceholder]);
765
828
  const aggregateDetailColor = isPlaceholderDetail ? theme.subtle : theme.text;
766
829
  return (
767
- <Box flexDirection="column" marginTop={attached ? 0 : 1}>
768
- <Box flexDirection="row">
830
+ <Box flexDirection="column" marginTop={attached ? 0 : 1} width={rowWidth} overflow="hidden">
831
+ <Box flexDirection="row" width={rowWidth} overflow="hidden">
769
832
  <Box flexShrink={0} minWidth={2}>
770
833
  <Text color={dotColor}>{dotText}</Text>
771
834
  </Box>
@@ -797,30 +860,35 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
797
860
  const backgroundResultText = backgroundMeta?.hasResponse ? backgroundMeta.body : '';
798
861
  const displayedResultText = backgroundResultText || (errorOnlyResult ? '' : (rt || ''));
799
862
  const hasDisplayResult = Boolean(String(displayedResultText || '').trim());
800
- const lines = displayedResultText ? displayedResultText.split('\n') : [];
863
+ const displayedResultBodyText = stripLeadingStatusMarkerFromText(displayedResultText);
864
+ const hasDisplayBody = Boolean(String(displayedResultBodyText || '').trim());
865
+ const lines = displayedResultBodyText ? displayedResultBodyText.split('\n') : [];
801
866
  const totalLines = lines.length;
802
867
  // Semantic one-line summary derived purely from name/args/result text.
803
868
  // Shown in the collapsed, non-error view in place of the raw result block.
804
869
  // Grouped cards ("Searched N files" / "Read N files") get the same treatment
805
870
  // 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)
871
+ const resultSummary = !pending && hasDisplayBody
872
+ ? surfaceSummarizeToolResult(name, args, displayedResultBodyText, isError)
808
873
  : null;
809
874
  // 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);
875
+ const maxResultChars = Math.min(RESULT_LINE_HARD_MAX, Math.max(MIN_RESULT_LINE_CHARS, Number(columns || 80) - 7));
811
876
  const resultColor = theme.text;
812
877
  const firstResultLine = hasDisplayResult ? String(lines[0] ?? '') : '';
813
- const firstResultLineClipped = hasDisplayResult && stringWidth(firstResultLine) > maxResultChars;
814
- const hasHiddenDetail = !pending && hasDisplayResult && (totalLines > 1 || firstResultLineClipped || Boolean(resultSummary));
878
+ const firstResultLineClipped = hasDisplayBody && stringWidth(firstResultLine) > maxResultChars;
879
+ const hasHiddenDetail = !pending && hasDisplayBody && (totalLines > 1 || firstResultLineClipped || Boolean(resultSummary));
815
880
  const shellStatus = isShellSurface ? shellDisplayStatus({ pending, failedCount, isError, result: displayedResultText }) : '';
816
881
  const shellElapsed = isShellSurface ? (shellResultElapsed(displayedResultText) || elapsed) : '';
817
- const shellStatusDetail = isShellSurface ? shellDetail(shellStatus, shellElapsed) : '';
818
882
  const backgroundElapsed = backgroundMeta
819
883
  ? backgroundTaskElapsed(backgroundMeta, elapsed)
820
884
  : (isBackgroundTaskTool(normalizedName) ? backgroundTaskElapsed(parsedArgs, elapsed) : '');
821
885
 
822
886
  const toolArgPath = parsedArgs?.path ?? parsedArgs?.file_path ?? parsedArgs?.file ?? '';
823
- const imageDetail = normalizedName === 'view_image' && toolArgPath ? String(toolArgPath) : '';
887
+ // Audit HIGH: on a FAILED view_image the path detail used to win over the
888
+ // error cause (nonShellDetail order puts imageDetail before genericDetail),
889
+ // so the card showed the filename instead of why it failed. Suppress the
890
+ // path detail on error; the error-cause summary/first line takes the row.
891
+ const imageDetail = normalizedName === 'view_image' && toolArgPath && !isError ? String(toolArgPath) : '';
824
892
  const isBackgroundResult = !pending && isBackgroundTaskTool(normalizedName) && Boolean(backgroundMeta);
825
893
  const isBackgroundResponse = isBackgroundResult && (backgroundMeta?.hasResponse || isBackgroundTaskResponseArgs(normalizedName, parsedArgs));
826
894
  const isBackgroundMetadataResult = isBackgroundResult && !isBackgroundResponse && Boolean(backgroundMeta);
@@ -868,20 +936,22 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
868
936
  // the final summary just fills in place. Skill surfaces collapse to a single
869
937
  // row in BOTH the estimate and the render (visibleDetailLines drops the row
870
938
  // for isSkillSurface below), so they get no placeholder.
871
- const pendingDetailPlaceholder = pending && !isSkillSurface ? 'Running' : '';
939
+ const pendingDetailPlaceholder = pending && !isSkillSurface
940
+ ? (elapsed ? `Running · ${elapsed}` : 'Running')
941
+ : '';
872
942
  const shellCollapsedSummary = isShellSurface && !pending && hasDisplayResult
873
943
  ? (resultSummary || truncateToWidth(firstResultLine, Math.min(120, maxResultChars)))
874
944
  : resultSummary;
875
945
  const collapsedDetail = pending
876
946
  ? pendingDetailPlaceholder
877
947
  : isShellSurface
878
- ? mergeTerminalDetail(shellStatus, shellCollapsedSummary)
948
+ ? prefixElapsed(mergeTerminalDetail(shellStatus, shellCollapsedSummary), shellElapsed)
879
949
  : mergeTerminalDetail(terminalStatus, nonShellDetail);
880
950
  const backgroundMetadataExpandable = isBackgroundMetadataResult && hasRawResult && !pending;
881
- const showRawResult = expanded && (hasDisplayResult || hasRawResult)
951
+ const showRawResult = expanded && (hasDisplayBody || hasRawResult)
882
952
  && (!isBackgroundMetadataResult || hasRawResult);
883
953
  const detailLines = showRawResult
884
- ? (hasDisplayResult ? lines : (rawRt ? rawRt.split('\n') : []))
954
+ ? (hasDisplayBody ? lines : (rawRt ? stripLeadingStatusMarkerLines(rawRt.split('\n')) : []))
885
955
  : (collapsedDetail ? [collapsedDetail] : []);
886
956
  const isPendingPlaceholderDetail = !showRawResult && Boolean(pendingDetailPlaceholder);
887
957
  const detailColor = isPendingPlaceholderDetail ? theme.subtle : theme.text;
@@ -903,7 +973,10 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
903
973
  // collapsed; ctrl+o expand still surfaces the full body.
904
974
  let visibleDetailLines = detailLines;
905
975
  if (isSkillSurface && !showRawResult) {
906
- visibleDetailLines = [];
976
+ // Audit HIGH: a FAILED skill load used to drop its detail row with the
977
+ // success path, hiding the one-line cause entirely. Keep the detail row
978
+ // when the call errored; only the redundant success repeat is dropped.
979
+ visibleDetailLines = isError && collapsedDetail ? [collapsedDetail] : [];
907
980
  } else if (isBackgroundMetadataResult && backgroundMetadataHeaderFailure && !showRawResult) {
908
981
  visibleDetailLines = [];
909
982
  } else if (isAgentSurfaceCard && !showRawResult) {
@@ -922,14 +995,25 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
922
995
  else if (isBackgroundMetadataResult) labelText = backgroundTaskActionTitle(normalizedName, backgroundMeta);
923
996
  else if (isShellSurface) labelText = shellHeader(shellStatus, displayGroupCount);
924
997
  else labelText = (isAgentTool(normalizedName) ? agentActionTitle(parsedArgs) : '') || statusCopy(name, label, displayGroupCount, doneCount, headerPending, isError, parsedArgs);
998
+ labelText = safeInlineText(labelText);
925
999
  // Show the parenthesized arg summary for grouped cards too, matching single
926
1000
  // calls so the header carries the same context.
927
1001
  const toolSearchSummary = !pending && normalizedName === 'tool_search' && hasResult
928
1002
  ? toolSearchLoadedSummary(displayedResultText)
929
1003
  : '';
930
- const summaryText = isAgentResponse || isBackgroundResponse
1004
+ const rawSummaryText = safeInlineText(isAgentResponse || isBackgroundResponse
931
1005
  ? ''
932
- : toolSearchSummary || (isAgentTool(normalizedName) ? agentActionSummary(parsedArgs, summary) : summary);
1006
+ : toolSearchSummary || (isAgentTool(normalizedName) ? agentActionSummary(parsedArgs, summary) : summary));
1007
+ // Drop the parenthesized arg summary when it is a bare "<n> <unit>" count
1008
+ // that the header verb already spells out (e.g. header "Searching 6 patterns"
1009
+ // + summary "6 patterns"). Multi-arg array calls hit this; single calls keep
1010
+ // their descriptive summary ("pattern: \"foo\"") since it never matches the
1011
+ // header tail. Channel surfaces are unaffected — they build the summary from
1012
+ // summarizeToolArgs directly and never render this header verb.
1013
+ const summaryIsHeaderCount = rawSummaryText
1014
+ && /^\d+\s+\S+$/.test(rawSummaryText)
1015
+ && labelText.endsWith(rawSummaryText);
1016
+ const summaryText = summaryIsHeaderCount ? '' : rawSummaryText;
933
1017
  // Agent cards hide their collapsed body but still expose ctrl+o expand only
934
1018
  // when expanding would actually reveal something: an agent response body, or a
935
1019
  // multiline / clipped raw result (e.g. the "agents: N …" worker list). A
@@ -941,6 +1025,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
941
1025
  // hasHiddenDetail, which goes true for any single-line resultSummary and would
942
1026
  // wrongly show ctrl+o on a status-only one-liner that has nothing to expand.
943
1027
  const shellHasExpandableBody = isShellSurface && !pending && hasDisplayResult
1028
+ && hasDisplayBody
944
1029
  && (totalLines > 1 || firstResultLineClipped || Boolean(shellCollapsedSummary && shellCollapsedSummary !== firstResultLine));
945
1030
  const showHeaderExpandHint = (isShellSurface ? shellHasExpandableBody : (isAgentSurfaceCard ? agentHasExpandableBody : (hasHiddenDetail || backgroundMetadataExpandable)))
946
1031
  && normalizedName !== 'tool_search';
@@ -954,25 +1039,22 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
954
1039
  const gutter = 2;
955
1040
  const hintLabel = showHeaderExpandHint ? `ctrl+o ${expanded ? 'collapse' : 'expand'}` : '';
956
1041
  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.
1042
+ // The header right-side trailing slot only ever shows the ctrl+o hint. The
1043
+ // pending elapsed meta was removed from the header it lives on the detail
1044
+ // row now (`Running · 12s`) so a per-second digit change (9s→10s) or the
1045
+ // pending→done swap never reflows the header. The hint slot is reserved for
1046
+ // the whole lifecycle (even while pending) so its later appearance on
1047
+ // completion does not push the body clip point.
966
1048
  const hintReserveLabel = `ctrl+o ${expanded ? 'collapse' : 'expand'}`;
967
1049
  const hintReserveText = ` ${BULLET_OPERATOR} ${hintReserveLabel}`;
968
1050
  const headerFailureText = headerFailureStatus
969
1051
  ? truncateToWidth(headerFailureStatus, HEADER_FAILURE_STATUS_MAX)
970
1052
  : '';
971
1053
  const inlineFailureText = headerFailureText ? ` ${BULLET_OPERATOR} ${headerFailureText}` : '';
972
- const rightReserve = Math.max(stringWidth(hintReserveText), stringWidth(headerMetaText)) + stringWidth(inlineFailureText);
1054
+ const rightReserve = stringWidth(hintReserveText) + stringWidth(inlineFailureText);
973
1055
  const avail = Math.max(1, (Number(columns) || 80) - 1 - gutter - rightReserve);
974
- const trailingText = headerMetaText || (showHeaderExpandHint ? hintText : '');
975
- const trailingColor = headerMetaText ? theme.subtle : expandHintColor;
1056
+ const trailingText = showHeaderExpandHint ? hintText : '';
1057
+ const trailingColor = expandHintColor;
976
1058
  let labelOut;
977
1059
  let summaryOut;
978
1060
  if (stringWidth(labelText) >= avail) {
@@ -989,13 +1071,13 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
989
1071
  : '';
990
1072
  summaryOut = truncatedSummary ? ` (${truncatedSummary})` : '';
991
1073
  }
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.
1074
+ // Keep trailing content (ctrl+o hint only; pending elapsed lives on the detail
1075
+ // row) attached directly after the body for the whole lifecycle. The
1076
+ // fixed-column pin previously used for elapsed is what made the trailing text
1077
+ // jump to the right edge and snap back on the pending→done flip, so there is no
1078
+ // pad. `avail` stays reserved (rightReserve) so the body clip point never reflows.
997
1079
  return (
998
- <Box flexDirection="column" marginTop={attached ? 0 : 1}>
1080
+ <Box flexDirection="column" marginTop={attached ? 0 : 1} width={rowWidth} overflow="hidden">
999
1081
  <Box flexDirection="row" width="100%">
1000
1082
  <Box flexShrink={1} flexGrow={1} overflow="hidden" minWidth={0}>
1001
1083
  <Box flexDirection="row">
@@ -1014,7 +1096,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
1014
1096
 
1015
1097
  <ResultBody
1016
1098
  lines={visibleDetailLines}
1017
- rawText={hasDisplayResult ? displayedResultText : (rawRt || '')}
1099
+ rawText={hasDisplayBody ? displayedResultBodyText : stripLeadingStatusMarkerFromText(rawRt || '')}
1018
1100
  pathArg={toolArgPath}
1019
1101
  isShell={isShellSurface}
1020
1102
  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)}`;