mixdog 0.8.0 → 0.9.0

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