mixdog 0.8.1 → 0.9.1

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 (299) hide show
  1. package/README.md +47 -23
  2. package/package.json +34 -28
  3. package/scripts/_test-folder-dialog.mjs +30 -0
  4. package/scripts/agent-parallel-smoke.mjs +388 -0
  5. package/scripts/agent-tag-reuse-smoke.mjs +183 -0
  6. package/scripts/background-task-meta-smoke.mjs +38 -0
  7. package/scripts/boot-smoke.mjs +52 -9
  8. package/scripts/build-runtime-linux.sh +348 -0
  9. package/scripts/build-runtime-macos.sh +217 -0
  10. package/scripts/build-runtime-windows.ps1 +242 -0
  11. package/scripts/compact-active-turn-test.mjs +68 -0
  12. package/scripts/compact-smoke.mjs +859 -129
  13. package/scripts/compact-trigger-migration-smoke.mjs +187 -0
  14. package/scripts/fix-brief-fn.mjs +35 -0
  15. package/scripts/fix-format-tool-surface.mjs +24 -0
  16. package/scripts/fix-tool-exec-visible.mjs +42 -0
  17. package/scripts/generate-runtime-manifest.mjs +166 -0
  18. package/scripts/hook-bus-test.mjs +330 -0
  19. package/scripts/lead-workflow-smoke.mjs +33 -39
  20. package/scripts/live-worker-smoke.mjs +43 -37
  21. package/scripts/llm-trace-summary.mjs +315 -0
  22. package/scripts/memory-meta-concurrency-test.mjs +20 -0
  23. package/scripts/output-style-smoke.mjs +56 -15
  24. package/scripts/parent-abort-link-test.mjs +44 -0
  25. package/scripts/patch-agent-brief.mjs +48 -0
  26. package/scripts/patch-app.mjs +21 -0
  27. package/scripts/patch-app2.mjs +18 -0
  28. package/scripts/patch-dist-brief.mjs +96 -0
  29. package/scripts/patch-tool-exec.mjs +70 -0
  30. package/scripts/pretool-ask-runtime-test.mjs +54 -0
  31. package/scripts/provider-toolcall-test.mjs +376 -0
  32. package/scripts/reactive-compact-persist-smoke.mjs +124 -0
  33. package/scripts/sanitize-tool-pairs-test.mjs +260 -0
  34. package/scripts/session-context-bench.mjs +205 -33
  35. package/scripts/session-ingest-smoke.mjs +177 -0
  36. package/scripts/set-effort-config-test.mjs +41 -0
  37. package/scripts/smoke-runtime-negative.ps1 +106 -0
  38. package/scripts/smoke-runtime-negative.sh +97 -0
  39. package/scripts/smoke.mjs +25 -0
  40. package/scripts/tool-result-hook-test.mjs +48 -0
  41. package/scripts/tool-smoke.mjs +1222 -90
  42. package/scripts/toolcall-args-test.mjs +150 -0
  43. package/scripts/tui-background-failure-smoke.mjs +73 -0
  44. package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
  45. package/src/agents/debugger/AGENT.md +8 -0
  46. package/src/agents/explore/AGENT.md +4 -0
  47. package/src/agents/heavy-worker/AGENT.md +9 -3
  48. package/src/agents/maintainer/AGENT.md +4 -0
  49. package/src/agents/reviewer/AGENT.md +8 -0
  50. package/src/agents/scheduler-task/AGENT.md +12 -0
  51. package/src/agents/scheduler-task/agent.json +6 -0
  52. package/src/agents/webhook-handler/AGENT.md +12 -0
  53. package/src/agents/webhook-handler/agent.json +6 -0
  54. package/src/agents/worker/AGENT.md +9 -3
  55. package/src/app.mjs +77 -3
  56. package/src/defaults/hidden-roles.json +17 -12
  57. package/src/headless-role.mjs +117 -0
  58. package/src/help.mjs +30 -0
  59. package/src/hooks/lib/permission-evaluator.cjs +11 -475
  60. package/src/lib/keychain-cjs.cjs +9 -1
  61. package/src/lib/mixdog-debug.cjs +0 -29
  62. package/src/lib/plugin-paths.cjs +1 -7
  63. package/src/lib/rules-builder.cjs +242 -98
  64. package/src/lib/text-utils.cjs +1 -1
  65. package/src/mixdog-session-runtime.mjs +2211 -446
  66. package/src/output-styles/default.md +12 -28
  67. package/src/output-styles/extreme-simple.md +9 -6
  68. package/src/output-styles/simple.md +22 -9
  69. package/src/repl.mjs +108 -59
  70. package/src/rules/agent/00-common.md +15 -0
  71. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  72. package/src/rules/agent/30-explorer.md +22 -0
  73. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  75. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  76. package/src/rules/lead/01-general.md +9 -5
  77. package/src/rules/lead/04-workflow.md +51 -12
  78. package/src/rules/lead/lead-tool.md +6 -0
  79. package/src/rules/shared/01-tool.md +12 -1
  80. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  81. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  82. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  85. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  86. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  87. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  88. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  89. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  90. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  92. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  93. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  94. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  95. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +380 -268
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +131 -93
  97. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  98. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  100. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  101. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  102. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  103. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  104. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  107. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +203 -71
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +512 -312
  109. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -518
  110. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  111. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +289 -11
  113. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  114. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  115. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  116. package/src/runtime/agent/orchestrator/session/loop.mjs +840 -612
  117. package/src/runtime/agent/orchestrator/session/manager.mjs +1574 -459
  118. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  119. package/src/runtime/agent/orchestrator/session/store.mjs +67 -29
  120. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  121. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  122. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  123. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +35 -8
  124. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -8
  125. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -53
  126. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  127. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  130. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  133. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  138. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  139. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  140. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  141. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  142. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  143. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  146. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  147. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  148. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  149. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  150. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  151. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  152. package/src/runtime/channels/index.mjs +149 -51
  153. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  154. package/src/runtime/channels/lib/config.mjs +0 -1
  155. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  156. package/src/runtime/channels/lib/executor.mjs +0 -3
  157. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  158. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  159. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  160. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  161. package/src/runtime/channels/lib/session-discovery.mjs +3 -6
  162. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -12
  164. package/src/runtime/channels/lib/webhook.mjs +1 -1
  165. package/src/runtime/channels/tool-defs.mjs +29 -29
  166. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  167. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  168. package/src/runtime/memory/index.mjs +519 -118
  169. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  170. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  171. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  172. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  173. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  174. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  175. package/src/runtime/memory/lib/memory-cycle1.mjs +28 -7
  176. package/src/runtime/memory/lib/memory-cycle2.mjs +4 -5
  177. package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
  178. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -2
  179. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  180. package/src/runtime/memory/lib/memory.mjs +88 -9
  181. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  182. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  183. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  184. package/src/runtime/memory/lib/runtime-fetcher.mjs +80 -21
  185. package/src/runtime/memory/lib/session-ingest.mjs +196 -0
  186. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  187. package/src/runtime/memory/tool-defs.mjs +46 -37
  188. package/src/runtime/search/index.mjs +104 -473
  189. package/src/runtime/search/lib/config.mjs +0 -4
  190. package/src/runtime/search/lib/state.mjs +1 -15
  191. package/src/runtime/search/lib/web-tools.mjs +44 -25
  192. package/src/runtime/search/tool-defs.mjs +16 -23
  193. package/src/runtime/shared/abort-controller.mjs +1 -1
  194. package/src/runtime/shared/atomic-file.mjs +4 -3
  195. package/src/runtime/shared/background-tasks.mjs +122 -11
  196. package/src/runtime/shared/child-spawn-gate.mjs +139 -0
  197. package/src/runtime/shared/config.mjs +7 -4
  198. package/src/runtime/shared/err-text.mjs +131 -4
  199. package/src/runtime/shared/llm/cost.mjs +2 -2
  200. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  201. package/src/runtime/shared/llm/index.mjs +34 -11
  202. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  203. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  204. package/src/runtime/shared/singleton-owner.mjs +104 -0
  205. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  206. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  207. package/src/runtime/shared/tool-surface.mjs +624 -95
  208. package/src/runtime/shared/user-data-guard.mjs +0 -2
  209. package/src/standalone/agent-task-status.mjs +203 -0
  210. package/src/standalone/agent-task-status.test.mjs +76 -0
  211. package/src/standalone/agent-tool.mjs +1913 -0
  212. package/src/standalone/channel-worker.mjs +370 -14
  213. package/src/standalone/explore-tool.mjs +165 -70
  214. package/src/standalone/folder-dialog.mjs +314 -0
  215. package/src/standalone/hook-bus.mjs +898 -22
  216. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  217. package/src/standalone/projects.mjs +226 -0
  218. package/src/standalone/provider-admin.mjs +41 -24
  219. package/src/standalone/seeds.mjs +1 -78
  220. package/src/standalone/usage-dashboard.mjs +96 -8
  221. package/src/tui/App.jsx +4800 -2140
  222. package/src/tui/components/AnsiText.jsx +39 -28
  223. package/src/tui/components/ContextPanel.jsx +14 -4
  224. package/src/tui/components/Markdown.jsx +43 -77
  225. package/src/tui/components/MarkdownTable.jsx +9 -184
  226. package/src/tui/components/Message.jsx +28 -11
  227. package/src/tui/components/Picker.jsx +95 -56
  228. package/src/tui/components/PromptInput.jsx +428 -239
  229. package/src/tui/components/QueuedCommands.jsx +1 -1
  230. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  231. package/src/tui/components/Spinner.jsx +67 -38
  232. package/src/tui/components/StatusLine.jsx +606 -38
  233. package/src/tui/components/TextEntryPanel.jsx +128 -9
  234. package/src/tui/components/ToolExecution.jsx +597 -362
  235. package/src/tui/components/TurnDone.jsx +3 -3
  236. package/src/tui/components/UsagePanel.jsx +3 -5
  237. package/src/tui/components/tool-output-format.mjs +499 -0
  238. package/src/tui/components/tool-output-format.test.mjs +312 -0
  239. package/src/tui/dist/index.mjs +9192 -2399
  240. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  241. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  242. package/src/tui/engine.mjs +1455 -279
  243. package/src/tui/figures.mjs +21 -40
  244. package/src/tui/index.jsx +75 -31
  245. package/src/tui/input-editing.mjs +25 -0
  246. package/src/tui/markdown/format-token.mjs +671 -69
  247. package/src/tui/markdown/format-token.test.mjs +312 -0
  248. package/src/tui/markdown/render-ansi.mjs +94 -0
  249. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  250. package/src/tui/markdown/stream-fence.mjs +34 -0
  251. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  252. package/src/tui/markdown/table-layout.mjs +250 -0
  253. package/src/tui/paste-attachments.mjs +0 -7
  254. package/src/tui/spinner-verbs.mjs +1 -2
  255. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  256. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  257. package/src/tui/theme.mjs +756 -24
  258. package/src/tui/time-format.mjs +1 -1
  259. package/src/tui/transcript-tool-failures.mjs +67 -0
  260. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  261. package/src/ui/ansi.mjs +1 -2
  262. package/src/ui/markdown.mjs +85 -26
  263. package/src/ui/markdown.test.mjs +70 -0
  264. package/src/ui/model-display.mjs +121 -0
  265. package/src/ui/session-stats.mjs +44 -0
  266. package/src/ui/statusline-context-label.test.mjs +15 -0
  267. package/src/ui/statusline.mjs +386 -178
  268. package/src/ui/tool-card.mjs +2 -13
  269. package/src/vendor/statusline/bin/statusline-lib.mjs +1 -620
  270. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  271. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  272. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  273. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  274. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  275. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  276. package/src/workflows/default/WORKFLOW.md +12 -5
  277. package/src/workflows/default/workflow.json +0 -1
  278. package/src/workflows/solo/WORKFLOW.md +15 -0
  279. package/src/workflows/solo/workflow.json +7 -0
  280. package/vendor/ink/build/ink.js +54 -8
  281. package/vendor/ink/build/output.js +6 -1
  282. package/src/agents/scheduler-task.md +0 -3
  283. package/src/agents/web-researcher/AGENT.md +0 -3
  284. package/src/agents/web-researcher/agent.json +0 -6
  285. package/src/agents/webhook-handler.md +0 -3
  286. package/src/hooks/lib/permission-rules.cjs +0 -170
  287. package/src/hooks/lib/settings-loader.cjs +0 -112
  288. package/src/lib/hook-pipe-path.cjs +0 -10
  289. package/src/rules/bridge/00-common.md +0 -5
  290. package/src/rules/bridge/30-explorer.md +0 -4
  291. package/src/rules/lead/00-tool-lead.md +0 -5
  292. package/src/rules/shared/00-language.md +0 -3
  293. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  294. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  295. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  296. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  297. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  298. package/src/standalone/bridge-tool.mjs +0 -1414
  299. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -1,199 +1,63 @@
1
- /**
2
- * components/ToolExecution.jsx — a tool call + its result.
3
- *
4
- * Ported from Claude Code's AssistantToolUseMessage.tsx / MessageResponse.tsx:
1
+ /**
2
+ * components/ToolExecution.jsx — a tool call + its result.
3
+ *
4
+ * Tool call + result layout:
5
5
  * - The call line: `● Tool Name(summary)` where the dot is BLACK_CIRCLE
6
6
  * (2-wide gutter), the tool name is the user-facing label and the argument
7
7
  * summary sits in muted parentheses. NOT raw MCP/internal names.
8
- * - The result hangs under a single dim ` ⎿ ` gutter — the gutter is placed
9
- * once, not repeated per wrapped line (CC MessageResponse.tsx style).
10
- */
11
- import React, { useEffect, useRef, useState } from 'react';
8
+ * - The result hangs under a single dim ` ⎿ ` gutter — the gutter is placed
9
+ * once, not repeated per wrapped line.
10
+ */
11
+ import React, { useEffect, useState } from 'react';
12
12
  import { Box, Text } from 'ink';
13
13
  import stringWidth from 'string-width';
14
- import { theme, TURN_MARKER, RESULT_GUTTER } from '../theme.mjs';
14
+ import { theme, TURN_MARKER, RESULT_GUTTER, RESULT_GUTTER_CONT } from '../theme.mjs';
15
15
  import { formatElapsed } from '../time-format.mjs';
16
16
  import { BULLET_OPERATOR } from '../figures.mjs';
17
17
  import {
18
18
  displayToolName as surfaceDisplayToolName,
19
19
  formatToolSurface,
20
- summarizeToolArgs as surfaceSummarizeToolArgs,
21
20
  summarizeToolResult as surfaceSummarizeToolResult,
22
21
  formatAggregateHeader,
22
+ formatToolActionHeader,
23
+ displayModelName,
24
+ summarizeAgentSurfaceBrief,
25
+ AGENT_SURFACE_BRIEF_MAX,
23
26
  } from '../../runtime/shared/tool-surface.mjs';
27
+ import { backgroundTaskFailureStatusLabel, isBackgroundErrorOnlyBody } from '../../runtime/shared/err-text.mjs';
28
+ import { formatExpandedResult, wrapExpandedResultLines } from './tool-output-format.mjs';
24
29
 
25
30
  const MIN_RESULT_LINE_CHARS = 24;
26
31
  // Hard cap for the parenthesized header arg summary so a long path/query does
27
32
  // not eat the whole header line; anything longer is truncated with an ellipsis.
28
33
  const SUMMARY_MAX_CHARS = 48;
34
+ const HEADER_FAILURE_STATUS_MAX = 32;
29
35
 
30
36
  export function displayToolName(name, args) {
31
37
  return surfaceDisplayToolName(name, args);
32
38
  }
33
39
 
34
- /** Claude Code-style one-line renderToolUseMessage summary. */
35
- export function summarizeArgs(name, args) {
36
- return surfaceSummarizeToolArgs(name, args);
37
- }
38
-
39
- export const MAX_RESULT_LINES = 8;
40
40
  const TOOL_BLINK_MS = 500;
41
41
  const TOOL_BLINK_LIMIT_MS = 3000;
42
42
  const TOOL_PENDING_SHOW_DELAY_MS = 1000;
43
- const TOOL_HINT_DONE_COLOR = theme.subtle;
44
- const COUNT_TWEEN_MS = 700;
45
- const COUNT_TWEEN_FRAME_MS = 70;
43
+ // Read `theme.subtle` at use-time (not captured here) so a live `/theme`
44
+ // switch re-tones the tool hints. `theme` is mutated in-place on switch.
46
45
 
47
46
  function normalizeCount(value) {
48
47
  const n = Number(value || 0);
49
48
  return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
50
49
  }
51
50
 
52
- function baselineCount(value) {
53
- return normalizeCount(value) > 0 ? 1 : 0;
54
- }
55
-
56
- function easeOutCubic(t) {
57
- const clamped = Math.max(0, Math.min(1, Number(t) || 0));
58
- return 1 - Math.pow(1 - clamped, 3);
59
- }
60
-
61
- function tweenCount(from, to, progress) {
62
- const start = normalizeCount(from);
63
- const end = normalizeCount(to);
64
- if (end <= start) return end;
65
- const clamped = Math.max(0, Math.min(1, Number(progress) || 0));
66
- if (clamped >= 1) return end;
67
- return Math.min(end - 1, Math.max(start, Math.floor(start + ((end - start) * easeOutCubic(clamped)))));
68
- }
69
-
70
- function useCountUp(target, enabled = true) {
71
- const normalized = normalizeCount(target);
72
- const initial = enabled ? normalized : baselineCount(normalized);
73
- const [display, setDisplay] = useState(initial);
74
- const displayRef = useRef(initial);
75
- const timerRef = useRef(null);
76
-
77
- useEffect(() => {
78
- if (timerRef.current) {
79
- clearInterval(timerRef.current);
80
- timerRef.current = null;
81
- }
82
- if (!enabled) {
83
- const baseline = baselineCount(normalized);
84
- displayRef.current = baseline;
85
- setDisplay(baseline);
86
- return undefined;
87
- }
88
- const from = normalizeCount(displayRef.current);
89
- const to = normalized;
90
- if (to <= from) {
91
- displayRef.current = to;
92
- setDisplay(to);
93
- return undefined;
94
- }
95
- const started = Date.now();
96
- const tick = () => {
97
- const progress = Math.min(1, (Date.now() - started) / COUNT_TWEEN_MS);
98
- const next = tweenCount(from, to, progress);
99
- displayRef.current = next;
100
- setDisplay(next);
101
- if (progress >= 1 && timerRef.current) {
102
- clearInterval(timerRef.current);
103
- timerRef.current = null;
104
- }
105
- };
106
- displayRef.current = from;
107
- setDisplay(from);
108
- const timer = setInterval(tick, COUNT_TWEEN_FRAME_MS);
109
- timerRef.current = timer;
110
- timer.unref?.();
111
- return () => {
112
- clearInterval(timer);
113
- if (timerRef.current === timer) timerRef.current = null;
114
- };
115
- }, [normalized, enabled]);
116
-
117
- return display;
118
- }
119
-
120
51
  function normalizeCountMap(value = {}) {
121
52
  const out = {};
122
- for (const [key, raw] of Object.entries(value || {})) out[key] = normalizeCount(raw);
123
- return out;
124
- }
125
-
126
- function baselineCountMap(value = {}) {
127
- const out = {};
128
- for (const [key, raw] of Object.entries(value || {})) out[key] = baselineCount(raw);
129
- return out;
130
- }
131
-
132
- function countMapSignature(value = {}) {
133
- return Object.keys(value || {})
134
- .sort()
135
- .map((key) => `${key}:${normalizeCount(value[key])}`)
136
- .join('|');
137
- }
138
-
139
- function useCountUpMap(targets = {}, enabled = true) {
140
- const normalizedTargets = normalizeCountMap(targets);
141
- const signature = countMapSignature(normalizedTargets);
142
- const initial = enabled ? normalizedTargets : baselineCountMap(normalizedTargets);
143
- const [display, setDisplay] = useState(initial);
144
- const displayRef = useRef(initial);
145
- const timerRef = useRef(null);
146
-
147
- useEffect(() => {
148
- if (timerRef.current) {
149
- clearInterval(timerRef.current);
150
- timerRef.current = null;
151
- }
152
- if (!enabled) {
153
- const baseline = baselineCountMap(normalizedTargets);
154
- displayRef.current = baseline;
155
- setDisplay(baseline);
156
- return undefined;
157
- }
158
- const from = {};
159
- let needsTween = false;
160
- for (const [key, to] of Object.entries(normalizedTargets)) {
161
- const current = normalizeCount(displayRef.current?.[key]);
162
- from[key] = current;
163
- if (to > current) needsTween = true;
164
- }
165
- if (!needsTween) {
166
- displayRef.current = normalizedTargets;
167
- setDisplay(normalizedTargets);
168
- return undefined;
53
+ for (const [key, raw] of Object.entries(value || {})) {
54
+ if (raw && typeof raw === 'object') {
55
+ out[key] = { ...raw, count: normalizeCount(raw.count) };
56
+ } else {
57
+ out[key] = normalizeCount(raw);
169
58
  }
170
- const started = Date.now();
171
- const tick = () => {
172
- const progress = Math.min(1, (Date.now() - started) / COUNT_TWEEN_MS);
173
- const next = {};
174
- for (const [key, to] of Object.entries(normalizedTargets)) {
175
- const start = normalizeCount(from[key]);
176
- next[key] = tweenCount(start, to, progress);
177
- }
178
- displayRef.current = next;
179
- setDisplay(next);
180
- if (progress >= 1 && timerRef.current) {
181
- clearInterval(timerRef.current);
182
- timerRef.current = null;
183
- }
184
- };
185
- displayRef.current = from;
186
- setDisplay(from);
187
- const timer = setInterval(tick, COUNT_TWEEN_FRAME_MS);
188
- timerRef.current = timer;
189
- timer.unref?.();
190
- return () => {
191
- clearInterval(timer);
192
- if (timerRef.current === timer) timerRef.current = null;
193
- };
194
- }, [signature, enabled]);
195
-
196
- return display;
59
+ }
60
+ return out;
197
61
  }
198
62
 
199
63
  function deltaColor(token) {
@@ -225,89 +89,131 @@ function renderDeltaText(text) {
225
89
  ));
226
90
  }
227
91
 
92
+ // Shared multi-line result body: `└` on the first row, `│` continuation rail on
93
+ // every following row, body text in one flex column so wrapping stays aligned
94
+ // under the head gutter.
95
+ //
96
+ // Two render paths:
97
+ // - COLLAPSED (raw=false): a single fitted summary line, diff(+/-) colored via
98
+ // renderDeltaText.
99
+ // - EXPANDED (raw=true): formatExpandedResult then wrapExpandedResultLines so
100
+ // each physical row fits the body width before render (rail rows stay 1:1;
101
+ // ink does not re-wrap). Physical row mount cap: MIXDOG_TUI_TOOL_OUTPUT_MAX_RENDER_LINES
102
+ // (default 600; 0 disables). Shell/script bodies keep the newest tail when capped.
103
+ function ResultBody({ lines, rawText, pathArg = '', isShell = false, columns, color, raw }) {
104
+ const renderLines = raw
105
+ ? wrapExpandedResultLines(
106
+ formatExpandedResult(rawText, { pathArg, isShell }),
107
+ columns,
108
+ { isShell },
109
+ )
110
+ : (lines || []);
111
+ if (!renderLines || renderLines.length === 0) return null;
112
+ return (
113
+ <Box flexDirection="row">
114
+ <Box flexShrink={0} flexDirection="column">
115
+ {renderLines.map((_, i) => (
116
+ <Text key={i} color={theme.subtle}>{i === 0 ? RESULT_GUTTER : RESULT_GUTTER_CONT}</Text>
117
+ ))}
118
+ </Box>
119
+ <Box flexDirection="column" flexShrink={1} flexGrow={1}>
120
+ {renderLines.map((line, i) => (
121
+ <Text key={i} color={raw ? undefined : color} wrap="truncate">
122
+ {raw
123
+ ? (line || ' ')
124
+ : renderDeltaText(fitResultLine(line || ' ', columns))}
125
+ </Text>
126
+ ))}
127
+ </Box>
128
+ </Box>
129
+ );
130
+ }
131
+
228
132
  function plural(count, singular, pluralText = `${singular}s`) {
229
133
  return count === 1 ? singular : pluralText;
230
134
  }
231
135
 
232
- function statusCopy(normalizedName, label, count, doneCount, pending, isError) {
136
+ function isShellTool(normalizedName, label = '') {
233
137
  const n = String(normalizedName || '').toLowerCase();
234
138
  const l = String(label || '').toLowerCase();
139
+ return n === 'shell' || n === 'bash' || n === 'bash_session' || n === 'shell_command' || n === 'job_wait' || l === 'run';
140
+ }
235
141
 
236
- const copy = (active, done, noun, pluralNoun = `${noun}s`) => {
237
- if (pending) return active;
238
- const object = `${count} ${plural(count, noun, pluralNoun)}`;
239
- return `${done} ${object}`;
240
- };
142
+ function shellResultStatus(value) {
143
+ const match = String(value || '').match(/(?:^|\b)status:\s*(running|pending|queued|completed|failed|cancelled|canceled)\b/im);
144
+ return match ? String(match[1] || '').toLowerCase() : '';
145
+ }
241
146
 
242
- const copyTarget = (active, done, target, pluralTarget = `${target}s`) => {
243
- if (pending) return `${active} ${target}`;
244
- const singularTarget = pluralTarget === 'web items' ? 'web item' : target;
245
- return `${done} ${count} ${plural(count, singularTarget, pluralTarget)}`;
246
- };
147
+ function normalizeTerminalStatus(value) {
148
+ const raw = String(value || '').trim().toLowerCase();
149
+ if (!raw) return '';
150
+ if (/^(running|pending|queued|in_progress|in-progress)$/.test(raw)) return 'running';
151
+ if (/^(completed|complete|done|success|succeeded|ok)$/.test(raw)) return 'completed';
152
+ if (/^(failed|fail|error|errored|timeout|timed_out|killed)$/.test(raw)) return 'failed';
153
+ if (/^(cancelled|canceled|cancel)$/.test(raw)) return 'cancelled';
154
+ return '';
155
+ }
247
156
 
248
- switch (n) {
249
- case 'read':
250
- case 'view_image':
251
- case 'read_mcp_resource':
252
- return copy('Reading', 'Read', 'file');
253
- case 'apply_patch':
254
- return copy('Editing', 'Edited', 'file');
255
- case 'grep':
256
- case 'glob':
257
- return copy('Searching', 'Searched', 'file');
258
- case 'list':
259
- case 'ls':
260
- return copy('Searching', 'Searched', 'item');
261
- case 'search':
262
- case 'search_query':
263
- case 'image_query':
264
- case 'web_search':
265
- case 'web_search_call':
266
- case 'firecrawl_search':
267
- case 'web_fetch':
268
- case 'fetch':
269
- case 'download_attachment':
270
- return copyTarget('Researching', 'Researched', 'web', 'web items');
271
- case 'tool_search':
272
- return copy('Searching', 'Searched', 'tool');
273
- case 'explore':
274
- return pending ? 'Exploring' : 'Explored';
275
- case 'shell':
276
- case 'bash':
277
- case 'bash_session':
278
- case 'shell_command':
279
- case 'job_wait':
280
- return copy('Running', 'Ran', 'command');
281
- case 'bridge':
282
- case 'agent':
283
- case 'task':
284
- return copyTarget('Calling', 'Called', 'agent');
285
- case 'recall':
286
- case 'recall_memory':
287
- case 'search_memories':
288
- return copyTarget('Checking', 'Checked', 'memory', 'memories');
289
- case 'remember':
290
- case 'save_memory':
291
- case 'update_memory':
292
- return copyTarget('Writing', 'Wrote', 'memory', 'memories');
293
- default:
294
- if (l === 'web search') return copyTarget('Researching', 'Researched', 'web', 'web items');
295
- if (l === 'search') return copy('Searching', 'Searched', 'tool');
296
- if (l === 'explore') return pending ? 'Exploring' : 'Explored';
297
- if (l === 'update') return copy('Editing', 'Edited', 'file');
298
- if (l === 'read') return copy('Reading', 'Read', 'file');
299
- if (l === 'run') return copy('Running', 'Ran', 'command');
300
- if (l === 'setup') return copy('Setting Up', 'Set Up', 'item');
301
- if (l === 'memory') return copyTarget('Checking', 'Checked', 'memory', 'memories');
302
- if (l === 'agent') return copyTarget('Calling', 'Called', 'agent');
303
- return copy('Calling', 'Called', 'tool');
304
- }
157
+ function displayTerminalStatus(value) {
158
+ const status = normalizeTerminalStatus(value);
159
+ if (status === 'running') return 'Running';
160
+ if (status === 'completed') return 'Finished';
161
+ if (status === 'failed') return 'Failed';
162
+ if (status === 'cancelled') return 'Cancelled';
163
+ return '';
164
+ }
165
+
166
+ function resultTerminalStatus(value) {
167
+ const text = String(value || '');
168
+ const tagged = text.match(/<status[^>]*>([\s\S]*?)<\/status>/i)?.[1]?.trim();
169
+ if (tagged) return normalizeTerminalStatus(tagged);
170
+ const bracketed = text.match(/^\[status:\s*([^\]]*)\]/mi)?.[1]?.trim();
171
+ if (bracketed) return normalizeTerminalStatus(bracketed);
172
+ const inline = text.match(/^(?:status|state):\s*([^\s·,;]+)/mi)?.[1]?.trim();
173
+ return normalizeTerminalStatus(inline);
174
+ }
175
+
176
+ function shellDisplayStatus({ pending = false, failedCount = 0, isError = false, result = '' } = {}) {
177
+ const status = shellResultStatus(result);
178
+ if (pending || /^(running|pending|queued)$/.test(status)) return 'running';
179
+ if (/^cancel/.test(status)) return 'cancelled';
180
+ if (/^(failed|error|killed|timeout)$/.test(status) || isError || failedCount > 0) return 'failed';
181
+ return 'completed';
182
+ }
183
+
184
+ function shellHeader(status, count = 1) {
185
+ const n = Math.max(1, Number(count) || 1);
186
+ const object = `${n} ${plural(n, 'command')}`;
187
+ if (status === 'running') return `Running ${object}`;
188
+ return `Ran ${object}`;
189
+ }
190
+
191
+ function shellDetail(status, elapsed = '') {
192
+ const label = displayTerminalStatus(status) || status;
193
+ return elapsed ? `${elapsed} · ${label}` : label;
194
+ }
195
+
196
+ function shellResultElapsed(value) {
197
+ const match = String(value || '').match(/^\[elapsed:\s*(\d+)\s*ms\]/mi);
198
+ if (!match) return '';
199
+ const elapsedMs = Number(match[1]);
200
+ return Number.isFinite(elapsedMs) && elapsedMs >= 1000 ? formatElapsed(elapsedMs) : '';
201
+ }
202
+
203
+ function statusCopy(name, label, count, doneCount, pending, isError, args = {}) {
204
+ // No stableVerbWidth padding: it padded the done verb to the active ("-ing")
205
+ // width, which Ink trims at the line END (vendor output trimEnd) so it never
206
+ // stabilized the pending→done flip it only left an UGLY mid-header gap
207
+ // ("Searched 1 pattern", "Read 1 file"). The header is wrap="truncate"
208
+ // behind a fixed gutter and the fullscreen full-clear repaints the row, so
209
+ // dropping the pad just normalizes the spacing.
210
+ return formatToolActionHeader(name, args, { pending, count });
305
211
  }
306
212
 
307
213
  function fitResultLine(line, columns) {
308
214
  const max = Math.max(MIN_RESULT_LINE_CHARS, Number(columns || 80) - 7);
309
215
  const text = String(line ?? '');
310
- return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text;
216
+ return stringWidth(text) > max ? truncateToWidth(text, max) : text;
311
217
  }
312
218
 
313
219
  /** Trim text from the end (by display width) so it fits maxWidth, appending '…'. */
@@ -325,16 +231,19 @@ function truncateToWidth(text, maxWidth) {
325
231
  }
326
232
 
327
233
  function isAgentTool(normalizedName) {
328
- return normalizedName === 'bridge' || normalizedName === 'agent' || normalizedName === 'task';
234
+ return normalizedName === 'agent';
329
235
  }
330
236
 
237
+ const SKILL_SURFACE_NAMES = new Set([
238
+ 'skill', 'skill_execute', 'skill_view', 'skills_list', 'use_skill',
239
+ ]);
240
+
331
241
  function isBackgroundTaskTool(normalizedName) {
332
242
  return new Set(['explore', 'search', 'shell', 'bash', 'bash_session', 'shell_command', 'task']).has(String(normalizedName || '').toLowerCase());
333
243
  }
334
244
 
335
245
  const AGENT_DISPLAY_NAMES = new Map([
336
246
  ['explore', 'Explore'],
337
- ['web-researcher', 'Web Researcher'],
338
247
  ['maintainer', 'Maintainer'],
339
248
  ['worker', 'Worker'],
340
249
  ['heavy-worker', 'Heavy Worker'],
@@ -355,19 +264,60 @@ function titleizeAgentName(value) {
355
264
  .join(' ');
356
265
  }
357
266
 
267
+ function agentModelLabel(args) {
268
+ const a = args && typeof args === 'object' ? args : {};
269
+ const provider = String(a.provider || a.providerId || a.provider_id || '').trim();
270
+ const model = String(a.model || '').trim();
271
+ const displayHint = String(a.modelDisplay || a.model_display || a.displayModel || '').trim();
272
+ return displayModelName(model, provider, displayHint);
273
+ }
274
+
275
+ function withModel(label, args) {
276
+ const model = agentModelLabel(args);
277
+ return model ? `${label} (${model})` : label;
278
+ }
279
+
280
+ function agentTagLabel(args) {
281
+ // The real spawn tag (engine fills parsedArgs.tag from the envelope target).
282
+ // Never fall back to task_id — only the human-meaningful spawn tag belongs in
283
+ // the header parentheses.
284
+ return String(args?.tag || '').trim();
285
+ }
286
+
287
+ function withModelAndTag(label, args) {
288
+ const model = agentModelLabel(args);
289
+ const tag = agentTagLabel(args);
290
+ const inner = [model, tag].filter(Boolean).join(', ');
291
+ return inner ? `${label} (${inner})` : label;
292
+ }
293
+
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;
298
+ }
299
+
358
300
  function agentResponseTitle(args) {
359
301
  const name = titleizeAgentName(args?.agent || args?.role || args?.subagent_type || args?.name || '');
360
- return `${name || 'Agent'} response`;
302
+ // The agent role + model identify the responder; the response summary itself
303
+ // 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);
361
306
  }
362
307
 
363
308
  function agentActionTitle(args) {
364
309
  const name = titleizeAgentName(args?.agent || args?.role || args?.subagent_type || args?.name || '');
365
- const agent = name || 'Agent';
366
310
  const action = String(args?.type || args?.action || '').toLowerCase();
367
- const status = String(args?.status || '').toLowerCase();
368
- if (action === 'spawn') return /^(running|pending|queued)$/i.test(status) ? `Spawning ${agent}` : `Spawned ${agent}`;
369
- if (action === 'send') return /^(running|pending|queued)$/i.test(status) ? `Sending to ${agent}` : `Sent to ${agent}`;
370
- if (action === 'read' || action === 'status') return `${agent} status`;
311
+ // Fixed action verbs regardless of running/completed status. No generic
312
+ // "Agent" fallback for the role: when the role is unknown render the action
313
+ // 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);
316
+ 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);
371
321
  return '';
372
322
  }
373
323
 
@@ -376,15 +326,26 @@ function agentActionSummary(args, summary) {
376
326
  if (!text) return '';
377
327
  const name = titleizeAgentName(args?.agent || args?.role || args?.subagent_type || args?.name || '');
378
328
  if (name && text === name) return '';
379
- if (name && text.startsWith(`${name} · `)) return text.slice(name.length + 3).trim();
380
- return text;
329
+ 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
331
+ // into the header label itself ("Spawn Heavy Worker (Opus 4.8, tag)"), so drop
332
+ // the model and tag tokens from the parenthesized summary to avoid showing
333
+ // them twice.
334
+ const model = agentModelLabel(args);
335
+ if (model && rest === model) return '';
336
+ const tag = agentTagLabel(args);
337
+ if (tag && rest === tag) return '';
338
+ return rest;
381
339
  }
382
340
 
383
341
  function hasAgentResponseResult(value) {
384
342
  const text = String(value || '').trim();
385
343
  if (!text) return false;
344
+ if (/^(?:undefined|null)$/i.test(text)) return false;
386
345
  if (/^status:\s*(?:running|pending|queued|completed|failed|cancelled|canceled)(?:\s*·\s*task_id:\s*\S+)?$/i.test(text)) return false;
387
- const isBridgeEnvelope = /^(?:bridge task:|bridge job:|background task\b|bridge mode:|bridge message queued\b|bridge close:)/i.test(text)
346
+ const isBridgeEnvelope = /^(?:agent task:|background task\b|agent message queued\b|agent close:)/i.test(text)
347
+ || /^(?:agents|tasks):\s*\d/i.test(text)
348
+ || /^\(no agents or tasks\)$/i.test(text)
388
349
  || (/^task_id:\s*\S+/mi.test(text) && /^(?:surface|operation|status):\s*/mi.test(text));
389
350
  if (!isBridgeEnvelope) return true;
390
351
  let sawBlank = false;
@@ -394,10 +355,12 @@ function hasAgentResponseResult(value) {
394
355
  sawBlank = true;
395
356
  continue;
396
357
  }
397
- if (/^bridge result\b/i.test(trimmed)) continue;
358
+ if (/^agent result\b/i.test(trimmed)) continue;
359
+ if (/^(?:undefined|null)$/i.test(trimmed)) continue;
398
360
  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;
399
- if (!sawBlank && /^(?:bridge job|bridge task|background task|bridge message queued\b|bridge 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;
400
- if (!sawBlank && /^(?:bridge mode|agents|tasks):\s*/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;
362
+ if (!sawBlank && /^(?:agents|tasks):\s*/i.test(trimmed)) continue;
363
+ if (/^\(no agents or tasks\)$/i.test(trimmed)) continue;
401
364
  if (!sawBlank && /^-\s+\S+/i.test(trimmed)) continue;
402
365
  return true;
403
366
  }
@@ -420,17 +383,82 @@ function parseBackgroundTaskResult(value) {
420
383
  if (match) fields[match[1].toLowerCase()] = match[2].trim();
421
384
  }
422
385
  const status = String(fields.status || '').toLowerCase();
386
+ const error = fields.error || '';
423
387
  return {
424
388
  taskId: fields.task_id || fields.taskid || '',
425
389
  surface: fields.surface || '',
426
390
  operation: fields.operation || '',
427
391
  label: fields.label || '',
428
392
  status,
393
+ startedAt: fields.started || fields.startedat || '',
394
+ finishedAt: fields.finished || fields.finishedat || '',
429
395
  body,
430
- hasResponse: Boolean(body) && !/^(running|pending|queued)$/i.test(status),
396
+ error,
397
+ hasResponse: Boolean(body) && !isBackgroundErrorOnlyBody(body, error) && !/^(running|pending|queued)$/i.test(status),
398
+ };
399
+ }
400
+
401
+ function backgroundTaskMetaFromArgs(args = {}) {
402
+ const taskId = String(args.task_id || args.taskId || '').trim();
403
+ if (!taskId) return null;
404
+ return {
405
+ taskId,
406
+ surface: args.surface || '',
407
+ operation: args.operation || '',
408
+ label: args.label || '',
409
+ status: String(args.status || '').toLowerCase(),
410
+ startedAt: args.startedAt || args.started || '',
411
+ finishedAt: args.finishedAt || args.finished || '',
412
+ error: args.error || '',
413
+ body: '',
414
+ hasResponse: false,
431
415
  };
432
416
  }
433
417
 
418
+ function resolveBackgroundTaskMeta(parsedArgs = {}, resultText = '') {
419
+ const parsed = parseBackgroundTaskResult(resultText);
420
+ if (parsed) {
421
+ if (!parsed.error && parsedArgs?.error) parsed.error = parsedArgs.error;
422
+ if (!parsed.status && parsedArgs?.status) parsed.status = String(parsedArgs.status).toLowerCase();
423
+ if (!parsed.surface && parsedArgs?.surface) parsed.surface = parsedArgs.surface;
424
+ return parsed;
425
+ }
426
+ return backgroundTaskMetaFromArgs(parsedArgs);
427
+ }
428
+
429
+ function backgroundTaskElapsed(meta = {}, fallback = '') {
430
+ const startedMs = Date.parse(meta.startedAt || '');
431
+ const finishedMs = Date.parse(meta.finishedAt || '');
432
+ if (Number.isFinite(startedMs) && Number.isFinite(finishedMs) && finishedMs >= startedMs) {
433
+ const elapsedMs = finishedMs - startedMs;
434
+ return elapsedMs >= 1000 ? formatElapsed(elapsedMs) : '';
435
+ }
436
+ return fallback || '';
437
+ }
438
+
439
+ function prefixElapsed(detail, elapsed = '') {
440
+ const text = String(detail || '').trim();
441
+ const time = String(elapsed || '').trim();
442
+ if (!time) return text;
443
+ return text ? `${time} · ${text}` : time;
444
+ }
445
+
446
+ function mergeTerminalDetail(status, detail = '') {
447
+ const label = displayTerminalStatus(status);
448
+ const text = String(detail || '').trim();
449
+ if (!label) return text;
450
+ if (label === 'Finished' && text) return text;
451
+ if (!text) return label;
452
+ if (text.toLowerCase().startsWith(label.toLowerCase())) return text;
453
+ return `${label} · ${text}`;
454
+ }
455
+
456
+ function shouldPrefixSyncElapsed(normalizedName, label) {
457
+ const n = String(normalizedName || '').toLowerCase();
458
+ const l = String(label || '').toLowerCase();
459
+ return n === 'explore' || l === 'explore' || n === 'search' || l === 'search' || l === 'web search';
460
+ }
461
+
434
462
  function backgroundTaskDisplayName(normalizedName, meta = {}) {
435
463
  const surface = String(meta.surface || normalizedName || '').toLowerCase();
436
464
  if (surface === 'explore') return 'Explore';
@@ -453,20 +481,29 @@ function backgroundTaskActionTitle(normalizedName, meta = {}) {
453
481
  return `${display} status`;
454
482
  }
455
483
 
456
- function backgroundTaskDetail(meta = {}) {
484
+ function backgroundTaskFailureDetail(meta = {}, parsedArgs = {}) {
485
+ const status = meta.status || parsedArgs?.status;
486
+ const error = meta.error || parsedArgs?.error;
487
+ if (!error) return '';
488
+ const surface = meta.surface || parsedArgs?.surface || '';
489
+ return backgroundTaskFailureStatusLabel(status, error, { surface });
490
+ }
491
+
492
+ function backgroundTaskDetail(meta = {}, elapsed = '', parsedArgs = {}) {
457
493
  const parts = [];
458
- if (meta.status) parts.push(`status: ${meta.status}`);
494
+ const status = displayTerminalStatus(meta.status);
495
+ if (status) parts.push(status);
459
496
  if (meta.taskId) parts.push(`task_id: ${meta.taskId}`);
460
497
  const firstBodyLine = String(meta.body || '').split('\n').map((line) => line.trim()).find(Boolean) || '';
461
498
  if (firstBodyLine && /^(running|pending|queued)$/i.test(meta.status || '')) parts.push(firstBodyLine);
462
- return parts.join(' · ');
499
+ return prefixElapsed(parts.join(' · '), elapsed);
463
500
  }
464
501
 
465
502
  function isBackgroundTaskResponseArgs(normalizedName, args = {}) {
466
503
  if (!isBackgroundTaskTool(normalizedName)) return false;
467
504
  const type = String(args?.type || args?.action || '').toLowerCase();
468
505
  const status = String(args?.status || '').toLowerCase();
469
- return type === 'result' || type === 'completion' || (/^(completed|failed|cancelled|canceled)$/i.test(status) && Boolean(args?.task_id));
506
+ return type === 'result' || type === 'completion' || (/^(completed|cancelled|canceled)$/i.test(status) && Boolean(args?.task_id));
470
507
  }
471
508
 
472
509
  function isOutputDetailTool(normalizedName, label) {
@@ -475,26 +512,14 @@ function isOutputDetailTool(normalizedName, label) {
475
512
  return new Set([
476
513
  'shell', 'bash', 'bash_session', 'shell_command', 'job_wait',
477
514
  'read', 'view_image', 'read_mcp_resource',
478
- 'grep', 'glob', 'search', 'search_query', 'image_query', 'web_search', 'web_search_call', 'firecrawl_search', 'explore', 'web_fetch', 'fetch', 'download_attachment',
515
+ 'grep', 'glob', 'search', 'search_query', 'image_query', 'web_search', 'web_search_call', 'explore', 'web_fetch', 'fetch', 'download_attachment',
479
516
  'list', 'ls', 'code_graph',
480
517
  'recall', 'recall_memory', 'search_memories', 'remember', 'save_memory', 'update_memory',
481
518
  ]).has(n) || l === 'read' || l === 'search' || l === 'web search' || l === 'run';
482
519
  }
483
520
 
484
521
  function progressDetail({ normalizedName, label, elapsed }) {
485
- const n = String(normalizedName || '').toLowerCase();
486
- const l = String(label || '').toLowerCase();
487
- const suffix = elapsed ? ` - ${elapsed}` : '';
488
- if (isAgentTool(n) || l === 'agent') return `Calling Agent${suffix}`;
489
- if (n === 'shell' || n === 'bash' || n === 'bash_session' || n === 'shell_command' || n === 'job_wait' || l === 'run') return `Running${suffix}`;
490
- if (n === 'search' || n === 'search_query' || n === 'image_query' || n === 'web_search' || n === 'web_search_call' || n === 'firecrawl_search' || n === 'web_fetch' || n === 'fetch' || n === 'download_attachment' || l === 'web search') return `Researching Web${suffix}`;
491
- if (n === 'explore' || l === 'explore') return `Exploring${suffix}`;
492
- if (n === 'grep' || n === 'glob' || n === 'list' || n === 'ls' || l === 'search') return `Searching${suffix}`;
493
- if (n === 'read' || n === 'view_image' || n === 'read_mcp_resource' || l === 'read') return `Reading${suffix}`;
494
- if (n === 'apply_patch' || l === 'update') return `Editing${suffix}`;
495
- if (n === 'recall' || n === 'recall_memory' || n === 'search_memories' || l === 'memory') return `Checking Memory${suffix}`;
496
- if (l === 'setup') return `Setting Up${suffix}`;
497
- return `Working${suffix}`;
522
+ return elapsed ? `${elapsed} elapsed` : '';
498
523
  }
499
524
 
500
525
  function genericCompletedDetail({ normalizedName, label, hasResult, firstResultLine, isError }) {
@@ -502,7 +527,7 @@ function genericCompletedDetail({ normalizedName, label, hasResult, firstResultL
502
527
  const l = String(label || '').toLowerCase();
503
528
  if (isError) return hasResult ? firstResultLine : 'Failed';
504
529
  if (n === 'shell' || n === 'bash' || n === 'bash_session' || n === 'shell_command' || n === 'job_wait') {
505
- return hasResult ? firstResultLine : '';
530
+ return '';
506
531
  }
507
532
  if (isOutputDetailTool(n, l)) {
508
533
  return hasResult ? firstResultLine : '';
@@ -510,7 +535,29 @@ function genericCompletedDetail({ normalizedName, label, hasResult, firstResultL
510
535
  return '';
511
536
  }
512
537
 
513
- function agentTerminalDetail(status, isError, elapsed) {
538
+ function toolSearchLoadedSummary(resultText) {
539
+ let parsed;
540
+ try {
541
+ parsed = JSON.parse(String(resultText || ''));
542
+ } catch {
543
+ return '';
544
+ }
545
+ const tools = parsed?.selected?.tools;
546
+ if (!tools || typeof tools !== 'object') return '';
547
+ const names = [
548
+ ...(Array.isArray(tools.added) ? tools.added : []),
549
+ ...(Array.isArray(tools.already) ? tools.already : []),
550
+ ]
551
+ .map((name) => String(name || '').trim())
552
+ .filter(Boolean);
553
+ return [...new Set(names)].join(', ');
554
+ }
555
+
556
+ function agentTerminalDetail(status, isError, elapsed, error = '') {
557
+ const failureDetail = isError && error
558
+ ? backgroundTaskFailureStatusLabel(status, error, { surface: 'agent' })
559
+ : '';
560
+ if (failureDetail) return failureDetail;
514
561
  const s = String(status || '').toLowerCase();
515
562
  const word = /cancel/.test(s)
516
563
  ? 'Cancelled'
@@ -528,17 +575,21 @@ function clampFailureCount(errorCount, groupCount, isError) {
528
575
  return isError ? groupCount : 0;
529
576
  }
530
577
 
531
- function toolStatusColor({ pending, groupCount, failedCount }) {
532
- if (pending) return theme.subtle;
578
+ function toolStatusColor({ pending, groupCount, failedCount, terminalStatus = '' }) {
579
+ if (pending) return theme.success;
580
+ const status = normalizeTerminalStatus(terminalStatus);
581
+ if (status === 'failed') return theme.error;
582
+ if (status === 'cancelled') return theme.warning || theme.mixdogOrange || theme.subtle;
533
583
  if (failedCount <= 0) return theme.success;
534
584
  if (groupCount > 1 && failedCount < groupCount) return theme.mixdogOrange || theme.warning;
535
585
  return theme.error;
536
586
  }
537
587
 
538
- 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 }) {
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 }) {
539
589
  const [blinkOn, setBlinkOn] = useState(true);
540
590
  const [blinkExpired, setBlinkExpired] = useState(false);
541
591
  const [pendingDelayElapsed, setPendingDelayElapsed] = useState(false);
592
+ const [, setElapsedTick] = useState(0);
542
593
  const groupCount = Math.max(1, Number(count || 1));
543
594
  const doneCount = Math.max(0, Math.min(groupCount, Number(completedCount || (result == null ? 0 : groupCount))));
544
595
  const rt = result == null ? null : String(result).replace(/\s+$/, '');
@@ -547,18 +598,30 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
547
598
  const startedAtMs = Number(startedAt || 0);
548
599
  const completedAtMs = Number(completedAt || 0);
549
600
  const pendingAgeMs = pending && startedAtMs ? Math.max(0, Date.now() - startedAtMs) : 0;
550
- const pendingDisplayReady = !pending || !startedAtMs || pendingDelayElapsed || pendingAgeMs >= TOOL_PENDING_SHOW_DELAY_MS;
551
- const completedQuickly = !pending && startedAtMs > 0 && completedAtMs > 0 && Math.max(0, completedAtMs - startedAtMs) < TOOL_PENDING_SHOW_DELAY_MS;
552
- const headerPending = pending || (headerFinalized === false && !completedQuickly);
601
+ // A card that is still pending but already has something to paint (a result
602
+ // landed, or at least one of an aggregate's parallel calls completed) must
603
+ // SKIP the blank placeholder: it was pushed early (engine ensureVisible on a
604
+ // result before the push-delay) so its startedAt is recent and pendingAgeMs <
605
+ // delay, but it has real header counts + a summary to show. Rendering the
606
+ // placeholder instead made an empty card scroll up first and only fill in as
607
+ // each parallel result arrived. Treating "has visible content" as ready lets
608
+ // the card appear already populated and simply grow taller as more results
609
+ // land — no empty band.
610
+ const hasVisibleProgress = doneCount > 0 || Boolean(String(rt || '').trim());
611
+ const pendingDisplayReady = !pending || !startedAtMs || pendingDelayElapsed || pendingAgeMs >= TOOL_PENDING_SHOW_DELAY_MS || hasVisibleProgress || deferredDisplayReady;
612
+ // Keep the action verb in its active form until the engine explicitly seals
613
+ // the tool block. Fast tool batches often complete before the next provider
614
+ // iteration decides whether to call more tools or emit assistant text; flipping
615
+ // "Finding" -> "Found" -> "Finding" during that gap makes the transcript jump.
616
+ const headerPending = pending || headerFinalized === false;
553
617
  const hasResult = result != null && Boolean(String(rt || '').trim());
554
618
  const hasRawResult = rawResult != null && Boolean(String(rawRt || '').trim());
555
619
  const elapsedMs = startedAtMs ? Math.max(0, (pending ? Date.now() : (completedAtMs || Date.now())) - startedAtMs) : 0;
556
620
  const elapsed = elapsedMs >= 1000 ? formatElapsed(elapsedMs) : '';
557
621
  const failedCount = clampFailureCount(errorCount, groupCount, isError);
558
622
  const statusColor = toolStatusColor({ pending, groupCount, failedCount });
559
- const countsVisible = !headerPending;
560
- const displayGroupCount = useCountUp(groupCount, countsVisible);
561
- const displayCategories = useCountUpMap(categories || {}, aggregate === true && countsVisible);
623
+ const displayGroupCount = groupCount;
624
+ const displayCategories = normalizeCountMap(categories || {});
562
625
 
563
626
  useEffect(() => {
564
627
  if (!pending) {
@@ -605,7 +668,36 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
605
668
  return () => clearTimeout(timer);
606
669
  }, [pending, pendingDisplayReady, startedAt]);
607
670
 
608
- if (pending && !pendingDisplayReady) return null;
671
+ useEffect(() => {
672
+ if (!pending || !pendingDisplayReady || !startedAtMs) return undefined;
673
+ const timer = setInterval(() => setElapsedTick((tick) => (tick + 1) % 1000000), 1000);
674
+ return () => clearInterval(timer);
675
+ }, [pending, pendingDisplayReady, startedAtMs]);
676
+
677
+ // While a freshly-started tool is still inside its pending-show delay we used
678
+ // to `return null` (0 rendered rows). But estimateTranscriptItemRows() in
679
+ // App.jsx counts a collapsed tool item from the moment it is pushed (1 row for
680
+ // a skill surface, 2 rows otherwise), so the scroll/window math reserved that
681
+ // height while the component painted 0. The moment the delay elapsed (or the
682
+ // tool completed) the real card popped in, the rendered transcript grew and
683
+ // shoved the content above it — the "new tool card jumps up/down as it
684
+ // settles" bug. Reserve the SAME height the estimator predicts with blank
685
+ // content instead, so the card occupies a constant height for its whole
686
+ // lifecycle and nothing reflows when the real header/detail fill in place.
687
+ if (pending && !pendingDisplayReady) {
688
+ // Mirror estimateTranscriptItemRows: a non-aggregate skill surface collapses
689
+ // to a single header row; everything else reserves header + one detail row.
690
+ const placeholderNormalizedName = String(formatToolSurface(name, args)?.normalizedName || '').toLowerCase();
691
+ // Skill surfaces collapse to a single header row; agent surfaces reserve
692
+ // header + one brief detail row (see estimateTranscriptItemRows).
693
+ const placeholderSingleRow = !aggregate && SKILL_SURFACE_NAMES.has(placeholderNormalizedName);
694
+ return (
695
+ <Box flexDirection="column" marginTop={attached ? 0 : 1}>
696
+ <Text> </Text>
697
+ {placeholderSingleRow ? null : <Text> </Text>}
698
+ </Box>
699
+ );
700
+ }
609
701
 
610
702
  // ── Aggregate card ──────────────────────────────────────────────
611
703
  if (aggregate) {
@@ -613,28 +705,64 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
613
705
  // bounce between "Reading 1 item" and "Reading 4 items". Final counts and
614
706
  // result summaries appear only after completion.
615
707
  const headerOrder = Array.isArray(args?.categoryOrder) ? args.categoryOrder : null;
708
+ // No stableVerbWidth: see statusCopy — the padding only left a mid-header
709
+ // gap ("Searched 1 pattern, Read 1 file") since Ink trims trailing
710
+ // spaces and never stabilized the flip.
616
711
  const headerText = formatAggregateHeader(displayCategories || {}, { pending: headerPending, order: headerOrder });
617
712
  let detailText;
618
713
  if (hasResult) {
619
- detailText = rt;
620
- } else if (pending) {
621
- detailText = '';
714
+ // The aggregate card reserves EXACTLY ONE detail row when it is not
715
+ // expanded-with-raw (App.jsx estimateTranscriptItemRows counts
716
+ // margin + header + 1 detail row for the no-raw aggregate case). The
717
+ // summary `rt` can be multiline; a single <Text> containing '\n' renders
718
+ // MULTIPLE terminal rows, which desyncs the estimate and makes the card
719
+ // "settle" taller than reserved. Collapse to a single logical line
720
+ // (whitespace-normalized); fitResultLine below trims it to the column
721
+ // width so it can never exceed one terminal row.
722
+ detailText = String(rt).replace(/\s+/g, ' ').trim();
622
723
  } else {
623
724
  detailText = '';
624
725
  }
625
726
 
626
- const dotColor = statusColor;
727
+ const dotColor = !hasResult && !pending ? theme.subtle : statusColor;
627
728
  const dotText = pending && !blinkExpired && !blinkOn ? ' ' : TURN_MARKER;
628
729
  const gutter = 2;
629
730
  const showHeaderExpandHint = hasRawResult;
630
- const hintLabel = showHeaderExpandHint ? `ctrl+o ${expanded ? 'collapse' : 'expand'}` : '';
631
- const hintText = hintLabel ? ` ${BULLET_OPERATOR} ${hintLabel}` : '';
632
- const avail = Math.max(1, (Number(columns) || 80) - 1 - gutter - stringWidth(hintText));
731
+ const hintLabel = `ctrl+o ${expanded ? 'collapse' : 'expand'}`;
732
+ 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));
740
+ const avail = Math.max(1, (Number(columns) || 80) - 1 - gutter - rightReserve);
741
+ const trailingText = headerMetaText || (showHeaderExpandHint ? hintText : '');
742
+ const trailingColor = theme.subtle;
633
743
  const clippedHeader = stringWidth(headerText) > avail
634
744
  ? truncateToWidth(headerText, avail)
635
745
  : headerText;
636
- const detailLines = expanded && hasRawResult ? rawRt.split('\n') : (detailText ? [detailText] : []);
637
- const aggregateDetailColor = theme.text;
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.
749
+ // Keep the aggregate card at a fixed height (header + one detail row) for
750
+ // its whole lifecycle. Pending cards have no result yet, so reserve the
751
+ // detail row up front instead of growing from 1→2 rows when the summary
752
+ // lands on completion — that late row push is the "줄 튐" jump. The empty
753
+ // placeholder renders as a blank line under the ⎿ gutter; the final summary
754
+ // simply fills it in place. This matches estimateTranscriptItemRows (always
755
+ // 2 + resultRows), so windowing/scroll stay in lockstep too.
756
+ // When there is no summary yet (pending) or none could be derived, fill the
757
+ // reserved detail row with a status word instead of a blank line so the area
758
+ // under the ⎿ gutter never looks empty. Real summaries keep the normal text
759
+ // color; the status placeholder is rendered dim.
760
+ const isPlaceholderDetail = !(expanded && hasRawResult) && !detailText;
761
+ const showRawAggregate = expanded && hasRawResult;
762
+ const detailLines = showRawAggregate
763
+ ? rawRt.split('\n')
764
+ : (detailText ? [detailText] : [headerPending ? 'Running' : 'Finished']);
765
+ const aggregateDetailColor = isPlaceholderDetail ? theme.subtle : theme.text;
638
766
  return (
639
767
  <Box flexDirection="column" marginTop={attached ? 0 : 1}>
640
768
  <Box flexDirection="row">
@@ -643,93 +771,180 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
643
771
  </Box>
644
772
  <Text wrap="truncate">
645
773
  <Text bold color={theme.text}>{clippedHeader}</Text>
646
- {showHeaderExpandHint ? <Text color={TOOL_HINT_DONE_COLOR}>{hintText}</Text> : null}
774
+ {trailingText ? <Text color={trailingColor}>{trailingText}</Text> : null}
647
775
  </Text>
648
776
  </Box>
649
- {detailLines.length > 0 ? (
650
- <Box flexDirection="row">
651
- <Box flexShrink={0}>
652
- <Text color={theme.subtle}>{RESULT_GUTTER}</Text>
653
- </Box>
654
- <Box flexDirection="column" flexShrink={1} flexGrow={1}>
655
- {detailLines.map((line, i) => (
656
- <Text key={i} color={aggregateDetailColor}>
657
- {renderDeltaText(fitResultLine(line || ' ', columns))}
658
- </Text>
659
- ))}
660
- </Box>
661
- </Box>
662
- ) : null}
777
+ <ResultBody
778
+ lines={detailLines}
779
+ rawText={rawRt || ''}
780
+ columns={columns}
781
+ color={aggregateDetailColor}
782
+ raw={showRawAggregate}
783
+ />
663
784
  </Box>
664
785
  );
665
786
  }
666
787
 
667
788
  // ── Normal (non-aggregate) tool card ────────────────────────────
668
789
  const { label, summary, normalizedName, args: parsedArgs } = formatToolSurface(name, args);
669
- const backgroundMeta = !pending && hasResult && isBackgroundTaskTool(normalizedName)
670
- ? parseBackgroundTaskResult(rt)
790
+ const isShellSurface = isShellTool(normalizedName, label);
791
+ const isSkillSurface = SKILL_SURFACE_NAMES.has(String(normalizedName || '').toLowerCase());
792
+ const backgroundMeta = !pending && isBackgroundTaskTool(normalizedName)
793
+ ? resolveBackgroundTaskMeta(parsedArgs, rt || '')
671
794
  : null;
795
+ const backgroundError = backgroundMeta?.error || parsedArgs?.error || '';
796
+ const errorOnlyResult = Boolean(rt) && isBackgroundErrorOnlyBody(rt, backgroundError);
672
797
  const backgroundResultText = backgroundMeta?.hasResponse ? backgroundMeta.body : '';
673
- const displayedResultText = backgroundResultText || rt;
798
+ const displayedResultText = backgroundResultText || (errorOnlyResult ? '' : (rt || ''));
799
+ const hasDisplayResult = Boolean(String(displayedResultText || '').trim());
674
800
  const lines = displayedResultText ? displayedResultText.split('\n') : [];
675
801
  const totalLines = lines.length;
676
802
  // Semantic one-line summary derived purely from name/args/result text.
677
803
  // Shown in the collapsed, non-error view in place of the raw result block.
678
804
  // Grouped cards ("Searched N files" / "Read N files") get the same treatment
679
805
  // as single calls: a one-line semantic summary stands in for the raw block.
680
- const resultSummary = !pending && hasResult
806
+ const resultSummary = !pending && hasDisplayResult
681
807
  ? surfaceSummarizeToolResult(name, args, displayedResultText, isError)
682
808
  : null;
683
809
  // Same fit budget fitResultLine() uses, to detect a line that will be clipped.
684
810
  const maxResultChars = Math.max(MIN_RESULT_LINE_CHARS, Number(columns || 80) - 7);
685
811
  const resultColor = theme.text;
686
- const firstResultLine = hasResult ? String(lines[0] ?? '') : '';
687
- const firstResultLineClipped = hasResult && firstResultLine.length > maxResultChars;
688
- const hasHiddenDetail = !pending && hasResult && (totalLines > 1 || firstResultLineClipped || Boolean(resultSummary));
812
+ const firstResultLine = hasDisplayResult ? String(lines[0] ?? '') : '';
813
+ const firstResultLineClipped = hasDisplayResult && stringWidth(firstResultLine) > maxResultChars;
814
+ const hasHiddenDetail = !pending && hasDisplayResult && (totalLines > 1 || firstResultLineClipped || Boolean(resultSummary));
815
+ const shellStatus = isShellSurface ? shellDisplayStatus({ pending, failedCount, isError, result: displayedResultText }) : '';
816
+ const shellElapsed = isShellSurface ? (shellResultElapsed(displayedResultText) || elapsed) : '';
817
+ const shellStatusDetail = isShellSurface ? shellDetail(shellStatus, shellElapsed) : '';
818
+ const backgroundElapsed = backgroundMeta
819
+ ? backgroundTaskElapsed(backgroundMeta, elapsed)
820
+ : (isBackgroundTaskTool(normalizedName) ? backgroundTaskElapsed(parsedArgs, elapsed) : '');
689
821
 
690
822
  const toolArgPath = parsedArgs?.path ?? parsedArgs?.file_path ?? parsedArgs?.file ?? '';
691
823
  const imageDetail = normalizedName === 'view_image' && toolArgPath ? String(toolArgPath) : '';
692
- const agentCompletionDetail = !pending && isAgentTool(normalizedName)
693
- ? agentTerminalDetail(parsedArgs?.status, isError, elapsed)
824
+ const isBackgroundResult = !pending && isBackgroundTaskTool(normalizedName) && Boolean(backgroundMeta);
825
+ const isBackgroundResponse = isBackgroundResult && (backgroundMeta?.hasResponse || isBackgroundTaskResponseArgs(normalizedName, parsedArgs));
826
+ const isBackgroundMetadataResult = isBackgroundResult && !isBackgroundResponse && Boolean(backgroundMeta);
827
+ const backgroundMetadataFailureLabel = isBackgroundMetadataResult
828
+ ? backgroundTaskFailureDetail(backgroundMeta, parsedArgs)
694
829
  : '';
695
- const agentDetail = !pending && isAgentTool(normalizedName) && !hasResult
696
- ? agentCompletionDetail
830
+ const backgroundMetadataHeaderFailure = Boolean(backgroundMetadataFailureLabel) && !hasDisplayResult
831
+ ? backgroundMetadataFailureLabel
697
832
  : '';
698
- const pendingDetail = pending
699
- ? progressDetail({ normalizedName, label, elapsed })
833
+ const agentHeaderFailure = !pending && isAgentTool(normalizedName) && isError && parsedArgs?.error && !hasDisplayResult
834
+ ? backgroundTaskFailureStatusLabel(parsedArgs?.status, parsedArgs?.error, { surface: 'agent' })
700
835
  : '';
701
- const genericDetail = !pending && !agentDetail && !imageDetail && !resultSummary
836
+ const headerFailureStatus = backgroundMetadataHeaderFailure || agentHeaderFailure || '';
837
+ const agentCompletionDetail = !pending && isAgentTool(normalizedName) && !agentHeaderFailure
838
+ ? agentTerminalDetail(parsedArgs?.status, isError, elapsed, parsedArgs?.error)
839
+ : '';
840
+ const agentDetail = !pending && isAgentTool(normalizedName) && !hasDisplayResult
841
+ ? agentCompletionDetail
842
+ : '';
843
+ const genericDetail = !pending && !isShellSurface && !agentDetail && !imageDetail && !resultSummary
702
844
  ? genericCompletedDetail({ normalizedName, label, hasResult, firstResultLine, isError })
703
845
  : '';
704
- const isBackgroundResult = !pending && hasResult && isBackgroundTaskTool(normalizedName);
705
- const isBackgroundResponse = isBackgroundResult && (backgroundMeta?.hasResponse || isBackgroundTaskResponseArgs(normalizedName, parsedArgs));
706
- const isBackgroundMetadataResult = isBackgroundResult && !isBackgroundResponse && Boolean(backgroundMeta);
707
- const backgroundMetadataDetail = isBackgroundMetadataResult ? backgroundTaskDetail(backgroundMeta) : '';
846
+ const terminalStatus = pending
847
+ ? 'running'
848
+ : (shellStatus || normalizeTerminalStatus(backgroundMeta?.status) || normalizeTerminalStatus(parsedArgs?.status) || resultTerminalStatus(displayedResultText) || (isError || failedCount > 0 ? 'failed' : 'completed'));
849
+ const backgroundMetadataDetail = isBackgroundMetadataResult && !backgroundMetadataHeaderFailure
850
+ ? backgroundTaskDetail(backgroundMeta, backgroundElapsed, parsedArgs)
851
+ : '';
852
+ const backgroundResponseDetail = isBackgroundResponse && resultSummary
853
+ ? prefixElapsed(resultSummary, backgroundElapsed)
854
+ : resultSummary;
855
+ const syncElapsedDetail = !isBackgroundResponse && shouldPrefixSyncElapsed(normalizedName, label)
856
+ ? prefixElapsed(backgroundResponseDetail, elapsed)
857
+ : backgroundResponseDetail;
858
+ const nonShellDetail = backgroundMetadataDetail || (/^(Cancelled|Failed|Finished)$/i.test(resultSummary || '') && agentCompletionDetail
859
+ ? agentCompletionDetail
860
+ : syncElapsedDetail) || agentDetail || imageDetail || genericDetail;
861
+ // A pending non-aggregate tool used to drop its detail row entirely
862
+ // (collapsedDetail = ''), so the card rendered as a single header row. But
863
+ // estimateTranscriptItemRows() in App.jsx reserves 2 rows for a collapsed
864
+ // non-aggregate tool (1 only for skill surfaces). That left a 1-row gap that
865
+ // closed the instant the result landed — the surviving "튐". Reserve the same
866
+ // dim placeholder detail row the aggregate card uses (`Running`) for the whole
867
+ // pending lifecycle so the height stays fixed at header + one detail row and
868
+ // the final summary just fills in place. Skill surfaces collapse to a single
869
+ // row in BOTH the estimate and the render (visibleDetailLines drops the row
870
+ // for isSkillSurface below), so they get no placeholder.
871
+ const pendingDetailPlaceholder = pending && !isSkillSurface ? 'Running' : '';
872
+ const shellCollapsedSummary = isShellSurface && !pending && hasDisplayResult
873
+ ? (resultSummary || truncateToWidth(firstResultLine, Math.min(120, maxResultChars)))
874
+ : resultSummary;
708
875
  const collapsedDetail = pending
709
- ? pendingDetail
710
- : backgroundMetadataDetail || (/^(Cancelled|Failed|Finished)$/i.test(resultSummary || '') && agentCompletionDetail
711
- ? agentCompletionDetail
712
- : resultSummary) || agentDetail || imageDetail || genericDetail;
713
- const showRawResult = expanded && hasResult && !isBackgroundMetadataResult;
714
- const detailLines = showRawResult ? lines : (collapsedDetail ? [collapsedDetail] : []);
715
- const detailIsSynthetic = pending || agentDetail || resultSummary || imageDetail || backgroundMetadataDetail || (genericDetail && genericDetail !== firstResultLine);
716
- const detailColor = theme.text;
717
-
718
- const isAgentResult = !isBackgroundResult && !pending && isAgentTool(normalizedName) && hasResult;
876
+ ? pendingDetailPlaceholder
877
+ : isShellSurface
878
+ ? mergeTerminalDetail(shellStatus, shellCollapsedSummary)
879
+ : mergeTerminalDetail(terminalStatus, nonShellDetail);
880
+ const backgroundMetadataExpandable = isBackgroundMetadataResult && hasRawResult && !pending;
881
+ const showRawResult = expanded && (hasDisplayResult || hasRawResult)
882
+ && (!isBackgroundMetadataResult || hasRawResult);
883
+ const detailLines = showRawResult
884
+ ? (hasDisplayResult ? lines : (rawRt ? rawRt.split('\n') : []))
885
+ : (collapsedDetail ? [collapsedDetail] : []);
886
+ const isPendingPlaceholderDetail = !showRawResult && Boolean(pendingDetailPlaceholder);
887
+ const detailColor = isPendingPlaceholderDetail ? theme.subtle : theme.text;
888
+
889
+ const isAgentResult = !isBackgroundResult && !pending && isAgentTool(normalizedName) && hasDisplayResult;
719
890
  const isAgentResponse = isAgentResult && hasAgentResponseResult(rt);
720
- const isAgentMetadataResult = isAgentResult && !isAgentResponse;
721
- const dotColor = statusColor;
891
+ const isAgentSurfaceCard = isAgentTool(normalizedName);
892
+ const agentSurfaceBriefRaw = isAgentSurfaceCard && !showRawResult
893
+ ? summarizeAgentSurfaceBrief(name, parsedArgs, displayedResultText || '', { isError, isResponse: isAgentResponse })
894
+ : '';
895
+ const agentSurfaceBrief = agentSurfaceBriefRaw
896
+ ? truncateToWidth(agentSurfaceBriefRaw, Math.min(AGENT_SURFACE_BRIEF_MAX, maxResultChars))
897
+ : '';
898
+ // Skill loads carry the skill name in the header already
899
+ // ("Loaded 1 skill (name)"); the collapsed detail row just repeats it, so
900
+ // drop it and keep the card a single line. Expanding (ctrl+o) still shows the
901
+ // full skill body via the raw-result path.
902
+ // Agent spawn/send/response cards show a tight brief under the ⎿ gutter when
903
+ // collapsed; ctrl+o expand still surfaces the full body.
904
+ let visibleDetailLines = detailLines;
905
+ if (isSkillSurface && !showRawResult) {
906
+ visibleDetailLines = [];
907
+ } else if (isBackgroundMetadataResult && backgroundMetadataHeaderFailure && !showRawResult) {
908
+ visibleDetailLines = [];
909
+ } else if (isAgentSurfaceCard && !showRawResult) {
910
+ const agentDetailFallback = collapsedDetail
911
+ || (pending ? (pendingDetailPlaceholder || 'Running') : 'Finished');
912
+ const agentDetailLine = agentSurfaceBrief
913
+ || truncateToWidth(String(agentDetailFallback), Math.min(AGENT_SURFACE_BRIEF_MAX, maxResultChars));
914
+ visibleDetailLines = agentHeaderFailure && !agentSurfaceBrief ? [] : [agentDetailLine];
915
+ }
916
+ const finalStatusColor = toolStatusColor({ pending, groupCount, failedCount, terminalStatus });
917
+ const dotColor = finalStatusColor;
722
918
  const dotText = pending && !blinkExpired && !blinkOn ? ' ' : TURN_MARKER;
723
919
  let labelText;
724
920
  if (isAgentResponse) labelText = agentResponseTitle(parsedArgs);
725
921
  else if (isBackgroundResponse) labelText = backgroundTaskResultTitle(normalizedName, backgroundMeta || parsedArgs);
726
922
  else if (isBackgroundMetadataResult) labelText = backgroundTaskActionTitle(normalizedName, backgroundMeta);
727
- else labelText = (isAgentTool(normalizedName) ? agentActionTitle(parsedArgs) : '') || statusCopy(normalizedName, label, displayGroupCount, doneCount, headerPending, isError);
923
+ else if (isShellSurface) labelText = shellHeader(shellStatus, displayGroupCount);
924
+ else labelText = (isAgentTool(normalizedName) ? agentActionTitle(parsedArgs) : '') || statusCopy(name, label, displayGroupCount, doneCount, headerPending, isError, parsedArgs);
728
925
  // Show the parenthesized arg summary for grouped cards too, matching single
729
926
  // calls so the header carries the same context.
730
- const summaryText = isAgentResponse || isBackgroundResponse ? '' : (isAgentTool(normalizedName) ? agentActionSummary(parsedArgs, summary) : summary);
731
- const showHeaderExpandHint = hasHiddenDetail && !isAgentMetadataResult && !isBackgroundMetadataResult;
732
- const expandHintColor = TOOL_HINT_DONE_COLOR;
927
+ const toolSearchSummary = !pending && normalizedName === 'tool_search' && hasResult
928
+ ? toolSearchLoadedSummary(displayedResultText)
929
+ : '';
930
+ const summaryText = isAgentResponse || isBackgroundResponse
931
+ ? ''
932
+ : toolSearchSummary || (isAgentTool(normalizedName) ? agentActionSummary(parsedArgs, summary) : summary);
933
+ // Agent cards hide their collapsed body but still expose ctrl+o expand only
934
+ // when expanding would actually reveal something: an agent response body, or a
935
+ // multiline / clipped raw result (e.g. the "agents: N …" worker list). A
936
+ // status-only single-line metadata result has nothing extra to show, so it
937
+ // gets no hint.
938
+ const agentHasExpandableBody = isAgentSurfaceCard && !pending && hasResult
939
+ && (isAgentResponse || totalLines > 1 || firstResultLineClipped);
940
+ // Agent cards gate the hint solely on agentHasExpandableBody — never on
941
+ // hasHiddenDetail, which goes true for any single-line resultSummary and would
942
+ // wrongly show ctrl+o on a status-only one-liner that has nothing to expand.
943
+ const shellHasExpandableBody = isShellSurface && !pending && hasDisplayResult
944
+ && (totalLines > 1 || firstResultLineClipped || Boolean(shellCollapsedSummary && shellCollapsedSummary !== firstResultLine));
945
+ const showHeaderExpandHint = (isShellSurface ? shellHasExpandableBody : (isAgentSurfaceCard ? agentHasExpandableBody : (hasHiddenDetail || backgroundMetadataExpandable)))
946
+ && normalizedName !== 'tool_search';
947
+ const expandHintColor = theme.subtle;
733
948
 
734
949
  // Build a single-line header that never wraps: reserve width for the fixed
735
950
  // trailing expand hint plus the dot gutter and a 1-col Windows last-column
@@ -739,10 +954,25 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
739
954
  const gutter = 2;
740
955
  const hintLabel = showHeaderExpandHint ? `ctrl+o ${expanded ? 'collapse' : 'expand'}` : '';
741
956
  const hintText = hintLabel ? ` ${BULLET_OPERATOR} ${hintLabel}` : '';
742
- const avail = Math.max(
743
- 1,
744
- (Number(columns) || 80) - 1 - gutter - stringWidth(hintText),
745
- );
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.
966
+ const hintReserveLabel = `ctrl+o ${expanded ? 'collapse' : 'expand'}`;
967
+ const hintReserveText = ` ${BULLET_OPERATOR} ${hintReserveLabel}`;
968
+ const headerFailureText = headerFailureStatus
969
+ ? truncateToWidth(headerFailureStatus, HEADER_FAILURE_STATUS_MAX)
970
+ : '';
971
+ const inlineFailureText = headerFailureText ? ` ${BULLET_OPERATOR} ${headerFailureText}` : '';
972
+ const rightReserve = Math.max(stringWidth(hintReserveText), stringWidth(headerMetaText)) + stringWidth(inlineFailureText);
973
+ const avail = Math.max(1, (Number(columns) || 80) - 1 - gutter - rightReserve);
974
+ const trailingText = headerMetaText || (showHeaderExpandHint ? hintText : '');
975
+ const trailingColor = headerMetaText ? theme.subtle : expandHintColor;
746
976
  let labelOut;
747
977
  let summaryOut;
748
978
  if (stringWidth(labelText) >= avail) {
@@ -759,33 +989,38 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
759
989
  : '';
760
990
  summaryOut = truncatedSummary ? ` (${truncatedSummary})` : '';
761
991
  }
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.
762
997
  return (
763
998
  <Box flexDirection="column" marginTop={attached ? 0 : 1}>
764
- <Box flexDirection="row">
765
- <Box flexShrink={0} minWidth={2}>
766
- <Text color={dotColor}>{dotText}</Text>
999
+ <Box flexDirection="row" width="100%">
1000
+ <Box flexShrink={1} flexGrow={1} overflow="hidden" minWidth={0}>
1001
+ <Box flexDirection="row">
1002
+ <Box flexShrink={0} minWidth={2}>
1003
+ <Text color={dotColor}>{dotText}</Text>
1004
+ </Box>
1005
+ <Text wrap="truncate">
1006
+ <Text bold color={theme.text}>{labelOut}</Text>
1007
+ {summaryOut ? <Text color={theme.text}>{summaryOut}</Text> : null}
1008
+ {inlineFailureText ? <Text color={theme.error}>{inlineFailureText}</Text> : null}
1009
+ {trailingText ? <Text color={trailingColor}>{trailingText}</Text> : null}
1010
+ </Text>
1011
+ </Box>
767
1012
  </Box>
768
- <Text wrap="truncate">
769
- <Text bold color={theme.text}>{labelOut}</Text>
770
- {summaryOut ? <Text color={theme.text}>{summaryOut}</Text> : null}
771
- {showHeaderExpandHint ? <Text color={expandHintColor}>{hintText}</Text> : null}
772
- </Text>
773
1013
  </Box>
774
1014
 
775
- {detailLines.length > 0 ? (
776
- <Box flexDirection="row">
777
- <Box flexShrink={0}>
778
- <Text color={theme.subtle}>{RESULT_GUTTER}</Text>
779
- </Box>
780
- <Box flexDirection="column" flexShrink={1} flexGrow={1}>
781
- {detailLines.map((line, i) => (
782
- <Text key={i} color={showRawResult ? resultColor : detailColor}>
783
- {renderDeltaText(fitResultLine(line || ' ', columns))}
784
- </Text>
785
- ))}
786
- </Box>
787
- </Box>
788
- ) : null}
789
- </Box>
790
- );
791
- }
1015
+ <ResultBody
1016
+ lines={visibleDetailLines}
1017
+ rawText={hasDisplayResult ? displayedResultText : (rawRt || '')}
1018
+ pathArg={toolArgPath}
1019
+ isShell={isShellSurface}
1020
+ columns={columns}
1021
+ color={showRawResult ? resultColor : detailColor}
1022
+ raw={showRawResult}
1023
+ />
1024
+ </Box>
1025
+ );
1026
+ }