mixdog 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/package.json +2 -1
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/build-tui.mjs +13 -1
  4. package/scripts/explore-bench.mjs +124 -0
  5. package/scripts/hook-bus-test.mjs +191 -0
  6. package/scripts/path-suffix-test.mjs +57 -0
  7. package/scripts/recall-bench.mjs +207 -0
  8. package/scripts/tool-smoke.mjs +7 -4
  9. package/src/agents/debugger/AGENT.md +2 -2
  10. package/src/agents/heavy-worker/AGENT.md +20 -11
  11. package/src/agents/reviewer/AGENT.md +2 -2
  12. package/src/agents/worker/AGENT.md +17 -11
  13. package/src/mixdog-session-runtime.mjs +424 -1812
  14. package/src/repl.mjs +5 -5
  15. package/src/rules/agent/30-explorer.md +8 -11
  16. package/src/rules/lead/lead-tool.md +9 -5
  17. package/src/rules/shared/01-tool.md +11 -5
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -68
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +46 -7
  24. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  28. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  29. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  30. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +7 -5
  31. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +33 -23
  32. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -14
  33. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  34. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  35. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  36. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  37. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  39. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  40. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  41. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  42. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  43. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  44. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  45. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  46. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  47. package/src/runtime/agent/orchestrator/session/loop.mjs +169 -918
  48. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  50. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  51. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +65 -1032
  54. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  64. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  69. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  75. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  76. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  77. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  79. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  80. package/src/runtime/agent/orchestrator/tools/patch.mjs +3 -3
  81. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  82. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  83. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  84. package/src/runtime/channels/index.mjs +14 -233
  85. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  86. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  87. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  88. package/src/runtime/channels/lib/output-forwarder.mjs +8 -0
  89. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  90. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  91. package/src/runtime/memory/index.mjs +314 -359
  92. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  93. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  94. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  95. package/src/runtime/memory/lib/memory-cycle2.mjs +56 -3
  96. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  97. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  98. package/src/runtime/memory/lib/memory.mjs +20 -0
  99. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  100. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  101. package/src/runtime/memory/tool-defs.mjs +4 -4
  102. package/src/runtime/shared/abort-controller.mjs +1 -1
  103. package/src/runtime/shared/background-tasks.mjs +2 -3
  104. package/src/runtime/shared/buffered-appender.mjs +149 -0
  105. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  106. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  107. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  108. package/src/runtime/shared/transcript-writer.mjs +29 -2
  109. package/src/session-runtime/config-helpers.mjs +209 -0
  110. package/src/session-runtime/effort.mjs +128 -0
  111. package/src/session-runtime/fs-utils.mjs +10 -0
  112. package/src/session-runtime/model-capabilities.mjs +130 -0
  113. package/src/session-runtime/output-styles.mjs +124 -0
  114. package/src/session-runtime/plugin-mcp.mjs +114 -0
  115. package/src/session-runtime/session-text.mjs +100 -0
  116. package/src/session-runtime/statusline-route.mjs +35 -0
  117. package/src/session-runtime/tool-catalog.mjs +720 -0
  118. package/src/session-runtime/workflow.mjs +358 -0
  119. package/src/standalone/agent-tool.mjs +49 -10
  120. package/src/standalone/channel-worker.mjs +3 -2
  121. package/src/standalone/explore-tool.mjs +10 -3
  122. package/src/standalone/hook-bus.mjs +165 -8
  123. package/src/standalone/opencode-go-login.mjs +121 -0
  124. package/src/standalone/provider-admin.mjs +14 -3
  125. package/src/tui/App.jsx +884 -143
  126. package/src/tui/components/PromptInput.jsx +272 -15
  127. package/src/tui/components/ToolExecution.jsx +20 -10
  128. package/src/tui/components/tool-output-format.mjs +2 -2
  129. package/src/tui/dist/index.mjs +1771 -1947
  130. package/src/tui/engine/agent-envelope.mjs +296 -0
  131. package/src/tui/engine/boot-profile.mjs +21 -0
  132. package/src/tui/engine/labels.mjs +67 -0
  133. package/src/tui/engine/notice-text.mjs +112 -0
  134. package/src/tui/engine/queue-helpers.mjs +161 -0
  135. package/src/tui/engine/session-stats.mjs +46 -0
  136. package/src/tui/engine/tool-call-fields.mjs +23 -0
  137. package/src/tui/engine/tool-result-text.mjs +126 -0
  138. package/src/tui/engine.mjs +311 -851
  139. package/src/tui/input-editing.mjs +58 -8
  140. package/src/tui/input-editing.selection.test.mjs +75 -0
  141. package/src/tui/keyboard-protocol.mjs +2 -2
  142. package/src/tui/lib/voice-recorder.mjs +35 -19
  143. package/src/tui/markdown/format-token.mjs +7 -8
  144. package/src/tui/markdown/format-token.test.mjs +3 -3
  145. package/src/tui/paste-attachments.mjs +38 -0
  146. package/src/tui/paste-fix.test.mjs +119 -0
  147. package/src/tui/themes/base.mjs +2 -2
  148. package/src/tui/themes/kanagawa.mjs +4 -4
  149. package/src/tui/themes/teal.mjs +4 -5
  150. package/src/tui/themes/utils.mjs +1 -1
  151. package/src/ui/statusline.mjs +49 -0
  152. package/src/workflows/default/WORKFLOW.md +16 -9
  153. package/src/workflows/sequential/WORKFLOW.md +16 -11
  154. package/src/workflows/solo/WORKFLOW.md +5 -1
@@ -4,76 +4,75 @@
4
4
  * Runs mixdog's session manager outside React and exposes a tiny subscribable
5
5
  * store. The React/ink layer consumes it via useSyncExternalStore
6
6
  * (see hooks/useEngine.mjs).
7
+ *
8
+ * Pure/stateless helpers live in ./engine/* (boot-profile, session-stats,
9
+ * labels, notice-text, tool-result-text, tool-call-fields, agent-envelope,
10
+ * queue-helpers) and are re-exported here so the public surface is unchanged.
11
+ * This file keeps the stateful createEngineSession store + notification plan.
7
12
  */
8
13
  import { performance } from 'node:perf_hooks';
9
- import { SPINNER_VERBS } from './spinner-verbs.mjs';
10
14
  import {
11
15
  aggregateToolCategoryEntry,
12
16
  classifyToolCategory,
17
+ formatAggregateDetail,
13
18
  summarizeToolResult,
14
19
  } from '../runtime/shared/tool-surface.mjs';
15
- import { isBackgroundErrorOnlyBody, presentErrorText } from '../runtime/shared/err-text.mjs';
16
20
  import {
17
21
  isModelVisibleToolCompletionWrapper,
18
22
  modelVisibleToolCompletionMessage,
19
23
  } from '../runtime/shared/tool-execution-contract.mjs';
24
+ import { presentErrorText } from '../runtime/shared/err-text.mjs';
20
25
  import { listThemes, getThemeSetting, setThemeSetting } from './theme.mjs';
21
26
  import { resetAllStreamingMarkdownStablePrefixes } from './markdown/streaming-markdown.mjs';
22
-
23
- const BOOT_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ''));
24
- const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
25
-
26
- function bootProfile(event, fields = {}) {
27
- if (!BOOT_PROFILE_ENABLED) return;
28
- const elapsedMs = performance.now() - BOOT_PROFILE_START;
29
- const parts = [`[mixdog-boot] +${elapsedMs.toFixed(1)}ms`, `tui:${event}`];
30
- for (const [key, value] of Object.entries(fields || {})) {
31
- if (value === undefined || value === null || value === '') continue;
32
- parts.push(`${key}=${String(value).replace(/\s+/g, '_')}`);
33
- }
34
- try { process.stderr.write(`${parts.join(' ')}\n`); } catch {}
35
- }
36
-
37
- // Session-usage accumulator - inlined (not imported from ui/statusline.mjs) so
38
- // engine.mjs has no static dependency on the vendored statusline closure.
39
- function createSessionStats() {
40
- return {
41
- inputTokens: 0,
42
- outputTokens: 0,
43
- cachedTokens: 0,
44
- cacheWriteTokens: 0,
45
- promptTokens: 0,
46
- latestInputTokens: 0,
47
- latestOutputTokens: 0,
48
- latestCachedTokens: 0,
49
- latestCacheWriteTokens: 0,
50
- latestPromptTokens: 0,
51
- currentContextTokens: 0,
52
- costUsd: 0,
53
- turns: 0,
54
- };
55
- }
56
- function num(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; }
57
- function applyUsageDelta(stats, delta = {}) {
58
- if (!stats || !delta) return stats;
59
- const inputTokens = num(delta.deltaInput);
60
- const outputTokens = num(delta.deltaOutput);
61
- const cachedTokens = num(delta.deltaCachedRead);
62
- const cacheWriteTokens = num(delta.deltaCacheWrite);
63
- const promptTokens = num(delta.deltaPrompt);
64
- stats.inputTokens += inputTokens;
65
- stats.outputTokens += outputTokens;
66
- stats.cachedTokens += cachedTokens;
67
- stats.cacheWriteTokens += cacheWriteTokens;
68
- stats.promptTokens += promptTokens;
69
- stats.latestInputTokens = inputTokens;
70
- stats.latestOutputTokens = outputTokens;
71
- stats.latestCachedTokens = cachedTokens;
72
- stats.latestCacheWriteTokens = cacheWriteTokens;
73
- stats.latestPromptTokens = promptTokens;
74
- stats.costUsd += num(delta.costUsd);
75
- return stats;
76
- }
27
+ import { bootProfile } from './engine/boot-profile.mjs';
28
+ import { createSessionStats, applyUsageDelta } from './engine/session-stats.mjs';
29
+ import {
30
+ pickVerb,
31
+ pickDoneVerb,
32
+ formatElapsedSeconds,
33
+ compactEventLabel,
34
+ compactEventDetail,
35
+ projectNameFromPath,
36
+ } from './engine/labels.mjs';
37
+ import { polishNoticeText } from './engine/notice-text.mjs';
38
+ import {
39
+ toolResultText,
40
+ toolAggregateDetailFallback,
41
+ toolGroupedDisplayFallback,
42
+ toolErrorDisplay,
43
+ } from './engine/tool-result-text.mjs';
44
+ import {
45
+ toolCallId,
46
+ toolResultCallId,
47
+ toolCallName,
48
+ toolCallArgs,
49
+ } from './engine/tool-call-fields.mjs';
50
+ import {
51
+ parseAgentJob,
52
+ parseAgentResultEnvelope,
53
+ parseBackgroundTaskEnvelope,
54
+ parseSyntheticAgentMessage,
55
+ isStatusOnlyAgentCompletionNotification,
56
+ agentJobResultText,
57
+ agentArgsWithResultMetadata,
58
+ toolResultStatus,
59
+ isErrorToolStatus,
60
+ } from './engine/agent-envelope.mjs';
61
+ import {
62
+ queuePriorityValue,
63
+ defaultQueuePriority,
64
+ isQueuedEntryEditable,
65
+ isQueuedEntryVisible,
66
+ isSlashQueuedEntry,
67
+ shortTextFingerprint,
68
+ notificationDisplayText,
69
+ sessionActivityTimestamp,
70
+ promptDisplayText,
71
+ mergePromptContents,
72
+ mergePastedImages,
73
+ mergePastedTexts,
74
+ callCommitCallbacks,
75
+ } from './engine/queue-helpers.mjs';
77
76
 
78
77
  // Source tests resolve from src/tui/engine.mjs; the built bundle resolves from
79
78
  // src/tui/dist/index.mjs.
@@ -89,574 +88,10 @@ const TOOL_APPROVAL_TIMEOUT_MS = (() => {
89
88
  let _idSeq = 0;
90
89
  const nextId = () => `it_${++_idSeq}`;
91
90
 
92
- function pickVerb(turn) {
93
- return SPINNER_VERBS[(turn * 7 + 3) % SPINNER_VERBS.length];
94
- }
95
-
96
- const TURN_DONE_VERBS = [
97
- 'Thought',
98
- 'Reasoned',
99
- 'Mapped',
100
- 'Checked',
101
- 'Solved',
102
- 'Composed',
103
- 'Synthesized',
104
- 'Wrapped',
105
- ];
106
-
107
- function pickDoneVerb(turn) {
108
- return TURN_DONE_VERBS[(turn * 5 + 2) % TURN_DONE_VERBS.length];
109
- }
110
-
111
- function formatElapsedSeconds(ms) {
112
- const value = Math.max(0, Number(ms) || 0);
113
- if (value <= 0) return '0s';
114
- return `${Math.max(1, Math.ceil(value / 1000))}s`;
115
- }
116
-
117
- function compactEventLabel(event = {}) {
118
- const status = String(event.status || '').toLowerCase();
119
- const reactive = String(event.trigger || '').toLowerCase() === 'reactive';
120
- if (status === 'failed') return reactive ? 'Compact failed (overflow retry)' : 'Compact failed';
121
- if (status === 'skipped') return 'Compact skipped';
122
- if (status === 'no_change') return 'Compact checked';
123
- return reactive ? 'Compact complete (overflow recovery)' : 'Compact complete';
124
- }
125
-
126
- function compactEventDetail(event = {}) {
127
- // Keep the elapsed time as the lead detail, but no longer discard the rest of
128
- // the compact metadata. Surface type/trigger and the boundary/pressure so the
129
- // statusdone marker reflects what actually fired.
130
- const parts = [];
131
- const elapsed = formatElapsedSeconds(Number(event.durationMs ?? event.elapsedMs ?? 0));
132
- if (elapsed) parts.push(elapsed);
133
- const type = String(event.compactType || event.type || '').trim();
134
- if (type && type !== 'semantic') parts.push(type);
135
- const trigger = String(event.trigger || '').toLowerCase();
136
- if (trigger === 'reactive') parts.push('reactive');
137
- else if (trigger === 'manual') parts.push('manual');
138
- const before = Number(event.beforeTokens ?? event.pressureTokens ?? 0);
139
- const after = Number(event.afterTokens ?? 0);
140
- const fmtTok = (n) => {
141
- const v = Number(n) || 0;
142
- if (v >= 1000) return `${(v / 1000).toFixed(v >= 10_000 ? 0 : 1)}k`;
143
- return `${Math.round(v)}`;
144
- };
145
- if (before > 0 && after > 0 && after !== before) parts.push(`${fmtTok(before)}→${fmtTok(after)}`);
146
- return parts.join(' · ');
147
- }
148
-
149
- function projectNameFromPath(value) {
150
- const text = String(value || '').replace(/[\\/]+$/, '');
151
- return text.split(/[\\/]/).pop() || text || '(current)';
152
- }
153
-
154
- const FAILED_NOTICE_ACTIONS = new Map([
155
- ['api key save', 'save API key'],
156
- ['auth-forget', 'forget auth'],
157
- ['auto-clear', 'update auto-clear'],
158
- ['autoclear', 'update auto-clear'],
159
- ['agent', 'run agent command'],
160
- ['channels', 'load channels'],
161
- ['channels update', 'update channels'],
162
- ['clear', 'clear chat'],
163
- ['compact', 'compact context'],
164
- ['copy', 'copy'],
165
- ['core memory', 'load core memory'],
166
- ['cwd', 'update working directory'],
167
- ['effort switch', 'switch effort'],
168
- ['fast', 'update fast mode'],
169
- ['hook rule update', 'update hook rule'],
170
- ['hook toggle', 'toggle hook'],
171
- ['hook update', 'update hook'],
172
- ['hooks status', 'load hooks'],
173
- ['local provider update', 'update local provider'],
174
- ['mcp add', 'add MCP server'],
175
- ['mcp reconnect', 'reconnect MCP server'],
176
- ['mcp status', 'load MCP status'],
177
- ['mcp toggle', 'toggle MCP server'],
178
- ['memory', 'run memory command'],
179
- ['memory status', 'load memory status'],
180
- ['model save', 'save model'],
181
- ['model switch', 'switch model'],
182
- ['oauth code', 'finish OAuth login'],
183
- ['oauth login', 'start OAuth login'],
184
- ['output style switch', 'switch output style'],
185
- ['OpenAI usage auth save', 'save OpenAI usage auth'],
186
- ['OpenCode Go usage auth save', 'save OpenCode Go usage auth'],
187
- ['plugin add', 'add plugin'],
188
- ['plugin MCP enable', 'enable plugin MCP'],
189
- ['plugin uninstall', 'uninstall plugin'],
190
- ['plugin update', 'update plugin'],
191
- ['plugins status', 'load plugins'],
192
- ['providers', 'load providers'],
193
- ['recall', 'run recall'],
194
- ['resume', 'resume chat'],
195
- ['schedule toggle', 'toggle schedule'],
196
- ['setup save', 'save setup'],
197
- ['settings update', 'update settings'],
198
- ['skill add', 'add skill'],
199
- ['skills status', 'load skills'],
200
- ['tools status', 'load tool status'],
201
- ['usage', 'load usage'],
202
- ['webhook toggle', 'toggle webhook'],
203
- ['workflow switch', 'switch workflow'],
204
- ]);
205
-
206
- function polishNoticeAction(action) {
207
- const value = String(action || '').trim();
208
- if (!value) return 'finish';
209
- const key = value.toLowerCase();
210
- for (const [candidate, replacement] of FAILED_NOTICE_ACTIONS.entries()) {
211
- if (candidate.toLowerCase() === key) return replacement;
212
- }
213
- const suffixes = [
214
- [' save', 'save'],
215
- [' switch', 'switch'],
216
- [' update', 'update'],
217
- [' toggle', 'toggle'],
218
- [' reconnect', 'reconnect'],
219
- [' enable', 'enable'],
220
- [' uninstall', 'uninstall'],
221
- [' add', 'add'],
222
- ];
223
- for (const [suffix, verb] of suffixes) {
224
- if (!key.endsWith(suffix)) continue;
225
- const subject = value.slice(0, -suffix.length).trim();
226
- return subject ? `${verb} ${subject}` : verb;
227
- }
228
- return value;
229
- }
230
-
231
- function sentenceStart(text) {
232
- const value = String(text || '').trim();
233
- return value ? `${value[0].toUpperCase()}${value.slice(1)}` : value;
234
- }
235
-
236
- function polishNoticeText(text) {
237
- let value = String(text ?? '').trim().replace(/^✓\s*/, '');
238
- if (!value) return '';
239
- const error = /^error\s*:\s*(.+)$/i.exec(value);
240
- if (error?.[1]) value = error[1].trim();
241
- const couldNot = /^could not\s+(.+?)(?::\s*(.+))?$/i.exec(value);
242
- if (couldNot) {
243
- return couldNot[2]
244
- ? `Couldn’t ${couldNot[1]}: ${couldNot[2]}`
245
- : `Couldn’t ${couldNot[1]}.`;
246
- }
247
- const failed = /^(.+?)\s+failed(?::\s*(.+))?$/i.exec(value);
248
- if (failed) {
249
- const action = polishNoticeAction(failed[1]);
250
- return failed[2] ? `Couldn’t ${action}: ${failed[2]}` : `Couldn’t ${action}.`;
251
- }
252
- const busy = /^(.+?)\s+already in progress\.?$/i.exec(value);
253
- if (busy) return `${sentenceStart(polishNoticeAction(busy[1]))} is already running.`;
254
- const required = /^(.+?)\s+is required(?:\s+for\s+(.+))?\.?$/i.exec(value);
255
- if (required) {
256
- const subject = required[1].trim();
257
- const target = required[2]?.trim();
258
- return `${subject}${target ? ` required for ${target}` : ' required'}.`;
259
- }
260
- return value;
261
- }
262
-
263
- function toolResultText(content) {
264
- if (content == null) return '';
265
- if (typeof content === 'string') return content;
266
- if (Array.isArray(content)) {
267
- return content.map((c) => toolResultPartText(c)).filter((t) => t !== '').join('\n');
268
- }
269
- if (typeof content === 'object') {
270
- if (Array.isArray(content.content)) {
271
- const nested = content.content.map((c) => toolResultPartText(c)).filter((t) => t !== '').join('\n');
272
- if (nested) return nested;
273
- } else if (content.content != null && typeof content.content === 'object') {
274
- const nested = toolResultPartText(content.content);
275
- if (nested) return nested;
276
- }
277
- if (Array.isArray(content.parts)) {
278
- const nested = content.parts.map((c) => toolResultPartText(c)).filter((t) => t !== '').join('\n');
279
- if (nested) return nested;
280
- }
281
- const fromPart = toolResultPartText(content);
282
- if (fromPart) return fromPart;
283
- if (content?.type === 'tool_result') return '';
284
- if (typeof content.text === 'string') return content.text;
285
- if (typeof content.content === 'string') return content.content;
286
- }
287
- try { return JSON.stringify(content); } catch { return String(content); }
288
- }
289
-
290
- const TOOL_RESULT_PART_MAX_DEPTH = 12;
291
- const TOOL_RESULT_JSON_FALLBACK_MAX = 480;
292
- // Absolute cap for a collapsed tool detail line (the second row under the ⎿
293
- // gutter). Terminal-width independent so a wide terminal never lets a long line
294
- // stretch the row; lockstep with ToolExecution RESULT_LINE_HARD_MAX (80).
295
- const TOOL_DETAIL_LINE_MAX = 80;
296
-
297
- function compactToolResultObjectFallback(obj) {
298
- if (obj?.type === 'tool_result') return '';
299
- try {
300
- const json = JSON.stringify(obj);
301
- if (!json || json === '{}') return '';
302
- if (json.length <= TOOL_RESULT_JSON_FALLBACK_MAX) return json;
303
- return `${json.slice(0, TOOL_RESULT_JSON_FALLBACK_MAX - 1)}…`;
304
- } catch {
305
- return String(obj);
306
- }
307
- }
308
-
309
- function toolResultPartText(part, depth = 0) {
310
- if (part == null) return '';
311
- if (depth > TOOL_RESULT_PART_MAX_DEPTH) return '';
312
- if (typeof part === 'string') return part;
313
- if (part?.type === 'image' || part?.type === 'input_image') {
314
- return `[image: ${part.mimeType || part.mediaType || part.source?.media_type || 'image'}]`;
315
- }
316
- if (part?.type === 'tool_result') {
317
- const inner = part.content;
318
- if (typeof inner === 'string') return inner;
319
- if (Array.isArray(inner)) {
320
- return inner.map((c) => toolResultPartText(c, depth + 1)).filter((t) => t !== '').join('\n');
321
- }
322
- if (inner != null && typeof inner === 'object') {
323
- return toolResultPartText(inner, depth + 1);
324
- }
325
- return '';
326
- }
327
- if (part?.type === 'text' || part?.type === 'output_text' || part?.type === 'input_text') {
328
- return part.text ?? '';
329
- }
330
- if (Array.isArray(part)) {
331
- return part.map((c) => toolResultPartText(c, depth + 1)).filter((t) => t !== '').join('\n');
332
- }
333
- if (typeof part === 'object') {
334
- if (Array.isArray(part.content)) {
335
- const nested = part.content.map((c) => toolResultPartText(c, depth + 1)).filter((t) => t !== '').join('\n');
336
- if (nested) return nested;
337
- }
338
- if (part.content != null && typeof part.content === 'object') {
339
- const nested = toolResultPartText(part.content, depth + 1);
340
- if (nested) return nested;
341
- }
342
- if (Array.isArray(part.parts)) {
343
- const nested = part.parts.map((c) => toolResultPartText(c, depth + 1)).filter((t) => t !== '').join('\n');
344
- if (nested) return nested;
345
- }
346
- if (typeof part.text === 'string' && part.text) return part.text;
347
- if (typeof part.output === 'string' && part.output) return part.output;
348
- if (typeof part.message === 'string' && part.message) return part.message;
349
- if (typeof part.content === 'string') return part.content;
350
- if (part.source?.type === 'base64' && part.source?.data) {
351
- return `[image: ${part.source.media_type || part.source.mediaType || 'base64'}]`;
352
- }
353
- return compactToolResultObjectFallback(part);
354
- }
355
- return '';
356
- }
357
-
358
- function toolAggregateDetailFallback(detailText, rawResult) {
359
- if (String(detailText || '').trim()) return detailText;
360
- const raw = String(rawResult || '').replace(/\s+$/, '').trim();
361
- if (!raw) return detailText;
362
- const line = raw.split('\n').map((l) => l.trim()).find(Boolean) || '';
363
- if (!line) return detailText;
364
- return line.length > TOOL_DETAIL_LINE_MAX ? `${line.slice(0, TOOL_DETAIL_LINE_MAX - 3)}…` : line;
365
- }
366
-
367
- function toolGroupedDisplayFallback(resultText, text, rawText) {
368
- if (String(resultText || '').trim()) return resultText;
369
- const body = String(text || rawText || '').trim();
370
- if (body) return text || rawText;
371
- return resultText;
372
- }
373
-
374
- function toolErrorDisplay(value, surface = 'tool') {
375
- const text = presentErrorText(value, { surface });
376
- if (/^(?:Search failed|Fetch failed|No first response|The .+ went stale|(?:Web search agent|Agent|Tool) (?:stopped|was cancelled))/i.test(text)) {
377
- return text;
378
- }
379
- return /^error\s*:/i.test(text) ? text : `Error: ${text}`;
380
- }
381
-
382
- function toolCallId(call) {
383
- return call?.id ?? call?.toolCallId ?? call?.tool_call_id ?? call?.call_id;
384
- }
385
-
386
- function toolResultCallId(message) {
387
- return message?.toolCallId
388
- ?? message?.tool_call_id
389
- ?? message?.tool_use_id
390
- ?? message?.call_id
391
- ?? message?.id;
392
- }
393
-
394
- function toolCallName(call) {
395
- return call?.name ?? call?.function?.name ?? call?.toolName ?? call?.tool_name ?? 'tool';
396
- }
397
-
398
- function toolCallArgs(call) {
399
- return call?.arguments ?? call?.args ?? call?.input ?? call?.function?.arguments;
400
- }
401
-
402
- function textBetweenTag(text, tag) {
403
- const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i');
404
- const match = re.exec(String(text ?? ''));
405
- return match ? match[1].trim() : '';
406
- }
407
-
408
- function stripSyntheticAgentTags(text) {
409
- const value = String(text ?? '').trim();
410
- const finalAnswer = textBetweenTag(value, 'final-answer');
411
- if (finalAnswer) return finalAnswer;
412
- const taskResult = textBetweenTag(value, 'result');
413
- if (taskResult) return taskResult;
414
- return value
415
- .replace(/^agent result[^\n]*(?:\n|$)/i, '')
416
- .replace(/<\/?(?:final-answer|task-notification|task-id|tool-use-id|output-file|result|status|summary|usage|total_tokens|tool_uses|duration_ms|worktree|worktreePath|worktreeBranch)[^>]*>/gi, '')
417
- .trim();
418
- }
419
-
420
- function splitBridgeEnvelope(text) {
421
- const value = String(text ?? '').trim();
422
- if (!value) return { head: '', body: '' };
423
- const match = /\n\s*\n/.exec(value);
424
- if (!match) return { head: value, body: '' };
425
- return {
426
- head: value.slice(0, match.index).trim(),
427
- body: value.slice(match.index + match[0].length).trim(),
428
- };
429
- }
430
-
431
- function agentJobStatusText(parsed) {
432
- if (!parsed) return '';
433
- const parts = [];
434
- if (parsed.status) parts.push(`status: ${parsed.status}`);
435
- if (parsed.taskId) parts.push(`task_id: ${parsed.taskId}`);
436
- return parts.join(' · ');
437
- }
438
-
439
- function agentJobResultText(text, parsed = parseAgentJob(text)) {
440
- const value = String(text ?? '').trim();
441
- if (!value) return '';
442
- if (parsed?.taskId) {
443
- const { body } = splitBridgeEnvelope(value);
444
- const cleanBody = stripSyntheticAgentTags(body);
445
- if (cleanBody) return cleanBody;
446
- return agentJobStatusText(parsed);
447
- }
448
- return stripSyntheticAgentTags(value) || value;
449
- }
450
-
451
- function parseAgentResultEnvelope(text, fallback = {}) {
452
- const value = String(text ?? '').trim();
453
- if (!/^agent result\b/i.test(value)) return null;
454
- const [head = '', ...restLines] = value.split('\n');
455
- const body = stripSyntheticAgentTags(restLines.join('\n'));
456
- const attrs = {};
457
- const attrRe = /([a-zA-Z][\w-]*)=("[^"]*"|'[^']*'|\S+)/g;
458
- let match;
459
- while ((match = attrRe.exec(head))) {
460
- attrs[match[1].toLowerCase()] = String(match[2] || '').replace(/^["']|["']$/g, '');
461
- }
462
- const providerModel = /\s([a-zA-Z0-9_.-]+)\/([^\s]+)\s*$/i.exec(head);
463
- const agent = attrs.agent || fallback.agent || '';
464
- return {
465
- name: 'agent',
466
- label: String(fallback.status || attrs.status || 'completed').toLowerCase(),
467
- args: {
468
- type: 'result',
469
- status: fallback.status || attrs.status || 'completed',
470
- task_id: fallback.taskId || attrs.task_id || attrs.taskid || undefined,
471
- tag: fallback.tag || attrs.tag || undefined,
472
- agent: agent || undefined,
473
- provider: fallback.provider || attrs.provider || providerModel?.[1] || undefined,
474
- model: fallback.model || attrs.model || providerModel?.[2] || undefined,
475
- preset: fallback.preset || attrs.preset || undefined,
476
- effort: fallback.effort || attrs.effort || undefined,
477
- fast: fallback.fast ?? attrs.fast,
478
- },
479
- result: body || agentJobStatusText({ status: fallback.status || attrs.status || 'completed', taskId: fallback.taskId || attrs.task_id || attrs.taskid || '' }),
480
- isError: /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(fallback.status || attrs.status || ''),
481
- };
482
- }
483
-
91
+ // Re-export the shared tool-result/notification helpers so importers (and tests)
92
+ // keep resolving them from engine.mjs unchanged.
484
93
  export { toolResultText, toolAggregateDetailFallback, toolGroupedDisplayFallback };
485
-
486
- export function parseBackgroundTaskEnvelope(text) {
487
- const value = String(text ?? '').trim();
488
- if (!/^background task\b/i.test(value)) return null;
489
- const allLines = value.split('\n');
490
- const rest = allLines.slice(1);
491
- const blank = rest.findIndex((line) => !line.trim());
492
- const headLines = blank >= 0 ? rest.slice(0, blank) : rest;
493
- const body = blank >= 0 ? rest.slice(blank + 1).join('\n').trim() : '';
494
- const fields = {};
495
- for (const line of headLines) {
496
- const match = /^([a-zA-Z][\w-]*):\s*(.*)$/.exec(line.trim());
497
- if (match) fields[match[1].toLowerCase()] = match[2].trim();
498
- }
499
- const surface = String(fields.surface || fields.operation || 'task').toLowerCase();
500
- const name = surface === 'explore' || surface === 'search' || surface === 'shell' || surface === 'agent' ? surface : 'task';
501
- const status = String(fields.status || '').toLowerCase();
502
- const taskId = fields.task_id || fields.taskid || '';
503
- const errorText = fields.error || '';
504
- const agentResult = parseAgentResultEnvelope(body, {
505
- status,
506
- taskId,
507
- tag: fields.tag || fields.label || '',
508
- agent: fields.agent || '',
509
- provider: fields.provider || '',
510
- model: fields.model || '',
511
- preset: fields.preset || '',
512
- effort: fields.effort || '',
513
- fast: fields.fast,
514
- });
515
- if (agentResult) return { ...agentResult, rawResult: value };
516
- const errorOnlyBody = isBackgroundErrorOnlyBody(body, errorText);
517
- const resultBody = body && !errorOnlyBody ? body : '';
518
- return {
519
- name,
520
- label: status || 'notification',
521
- args: {
522
- type: body ? 'result' : (fields.operation || 'status'),
523
- status,
524
- task_id: taskId || undefined,
525
- surface,
526
- operation: fields.operation || undefined,
527
- label: fields.label || undefined,
528
- tag: fields.tag || undefined,
529
- agent: fields.agent || undefined,
530
- provider: fields.provider || undefined,
531
- model: fields.model || undefined,
532
- preset: fields.preset || undefined,
533
- effort: fields.effort || undefined,
534
- fast: fields.fast || undefined,
535
- error: errorText || undefined,
536
- startedAt: fields.started || fields.startedat || undefined,
537
- finishedAt: fields.finished || fields.finishedat || undefined,
538
- },
539
- result: resultBody || (!errorText ? [status ? `status: ${status}` : '', taskId ? `task_id: ${taskId}` : ''].filter(Boolean).join(' · ') : ''),
540
- rawResult: value,
541
- isError: /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(status) || /^error:/i.test(body) || Boolean(errorText),
542
- };
543
- }
544
-
545
- function isStatusOnlyAgentCompletionNotification(text) {
546
- const background = parseBackgroundTaskEnvelope(text);
547
- if (background?.name === 'agent' && /^(completed|cancelled|canceled)$/i.test(background.label || '')) {
548
- return !(hasAgentResponseResultText(background.result) || hasAgentResponseResultText(text));
549
- }
550
- const parsed = parseAgentJob(text);
551
- const result = agentJobResultText(text, parsed);
552
- if (!parsed?.taskId || !/^(completed|cancelled|canceled)$/i.test(parsed.status || '')) return false;
553
- return !(hasAgentResponseResultText(result) || hasAgentResponseResultText(text));
554
- }
555
-
556
- function hasAgentResponseResultText(text) {
557
- const value = String(text || '').trim();
558
- if (!value) return false;
559
- if (/^status:\s*(?:running|pending|queued|completed|failed|cancelled|canceled)(?:\s*·\s*task_id:\s*\S+)?$/i.test(value)) return false;
560
- if (/^(?:background task\b|agent task:|task_id:)/i.test(value) && !/\n\s*\n[\s\S]*\S/.test(value)) return false;
561
- return true;
562
- }
563
-
564
- function bracketField(text, name) {
565
- const re = new RegExp(`^\\[${name}:\\s*([^\\]]*)\\]`, 'mi');
566
- return re.exec(String(text ?? ''))?.[1]?.trim() || '';
567
- }
568
-
569
- function toolResultStatus(text) {
570
- const value = String(text ?? '');
571
- const tagged = textBetweenTag(value, 'status');
572
- if (tagged) return tagged.trim();
573
- const bracketed = bracketField(value, 'status');
574
- if (bracketed) return bracketed.trim();
575
- const inline = /^(?:status|state):\s*([^\s·,;]+)/mi.exec(value);
576
- return inline ? inline[1].trim() : '';
577
- }
578
-
579
- function isErrorToolStatus(status) {
580
- return /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(String(status || '').trim());
581
- }
582
-
583
- function parseSyntheticAgentMessage(text) {
584
- const value = String(text ?? '').trim();
585
- if (!value) return null;
586
- const finalAnswer = textBetweenTag(value, 'final-answer');
587
- if (finalAnswer) {
588
- return {
589
- name: 'agent',
590
- label: 'final',
591
- args: { type: 'read', description: 'agent result' },
592
- result: finalAnswer,
593
- };
594
- }
595
- const agentResult = parseAgentResultEnvelope(value);
596
- if (agentResult) return agentResult;
597
- const backgroundTask = parseBackgroundTaskEnvelope(value);
598
- if (backgroundTask) return backgroundTask;
599
- const shellTaskId = bracketField(value, 'task_id');
600
- if (shellTaskId) {
601
- const status = bracketField(value, 'status') || 'done';
602
- const exit = bracketField(value, 'exit');
603
- const command = bracketField(value, 'command');
604
- return {
605
- name: 'shell',
606
- label: status,
607
- args: { type: 'result', task_id: shellTaskId, command },
608
- result: value,
609
- isError: /^(failed|error|timeout|cancelled|killed)$/i.test(status) || (exit && exit !== '0' && exit !== 'n/a'),
610
- };
611
- }
612
- const agentJob = parseAgentJob(value);
613
- if (agentJob?.taskId) {
614
- const label = agentJob.status || 'notification';
615
- const result = agentJobResultText(value, agentJob);
616
- return {
617
- name: 'agent',
618
- label,
619
- args: agentArgsWithResultMetadata({ type: agentJob.type || 'notification', description: 'agent notification' }, agentJob),
620
- result: result || agentJobStatusText(agentJob) || 'agent notification',
621
- isError: /^(failed|error|timeout|cancelled|killed)$/i.test(label),
622
- };
623
- }
624
- if (/<task-notification\b/i.test(value)) {
625
- const status = textBetweenTag(value, 'status') || 'completed';
626
- const summary = textBetweenTag(value, 'summary') || `Agent ${status}`;
627
- const taskId = textBetweenTag(value, 'task-id');
628
- const result = stripSyntheticAgentTags(value);
629
- return {
630
- name: 'agent',
631
- label: status,
632
- taskId,
633
- summary,
634
- result: result || summary,
635
- };
636
- }
637
- return null;
638
- }
639
-
640
- function normalizeToolName(name) {
641
- return String(name || 'tool')
642
- .replace(/^mcp__.*__/, '')
643
- .replace(/^functions\./, '')
644
- .replace(/-/g, '_')
645
- .toLowerCase();
646
- }
647
-
648
- function parseToolArgs(args) {
649
- if (!args) return {};
650
- if (typeof args === 'string') {
651
- try {
652
- const parsed = JSON.parse(args);
653
- return parsed && typeof parsed === 'object' ? parsed : {};
654
- } catch {
655
- return {};
656
- }
657
- }
658
- return typeof args === 'object' ? args : {};
659
- }
94
+ export { parseBackgroundTaskEnvelope };
660
95
 
661
96
  // Ink renders through a maxFps throttle (120fps in index.jsx, ≈8.3ms). A plain
662
97
  // setImmediate only yields to the event loop; if Ink already painted within the
@@ -669,170 +104,6 @@ const yieldToRenderer = () => new Promise((resolve) => {
669
104
  setTimeout(resolve, RENDER_THROTTLE_FLUSH_MS);
670
105
  });
671
106
 
672
- function parseAgentJob(text) {
673
- const value = String(text || '');
674
- const idMatch = /^agent task:\s*([^\s]+)/m.exec(value) || /^task_id:\s*([^\s]+)/m.exec(value);
675
- if (!idMatch) return null;
676
- const statusMatch = /^status:\s*([^\s(]+)/m.exec(value);
677
- const typeMatch = /^type:\s*(.+)$/m.exec(value);
678
- const targetMatch = /^target:\s*(.+)$/m.exec(value);
679
- const agentMatch = /^agent:\s*(.+)$/m.exec(value);
680
- const presetMatch = /^preset:\s*(.+)$/m.exec(value);
681
- const modelMatch = /^model:\s*([^/\s]+)\/(.+)$/m.exec(value);
682
- const effortMatch = /^effort:\s*(.+)$/m.exec(value);
683
- const fastMatch = /^fast:\s*(on|off|true|false)$/m.exec(value);
684
- return {
685
- taskId: idMatch[1],
686
- status: (statusMatch?.[1] || '').toLowerCase(),
687
- type: (typeMatch?.[1] || '').trim(),
688
- target: (targetMatch?.[1] || '').trim(),
689
- agent: (agentMatch?.[1] || '').trim(),
690
- preset: (presetMatch?.[1] || '').trim(),
691
- provider: (modelMatch?.[1] || '').trim(),
692
- model: (modelMatch?.[2] || '').trim(),
693
- effort: (effortMatch?.[1] || '').trim(),
694
- fast: fastMatch ? /^(on|true)$/i.test(fastMatch[1]) : undefined,
695
- };
696
- }
697
-
698
- const QUEUE_PRIORITY = { now: 0, next: 1, later: 2 };
699
-
700
- function queuePriorityValue(value) {
701
- return QUEUE_PRIORITY[String(value || 'next')] ?? QUEUE_PRIORITY.next;
702
- }
703
-
704
- function defaultQueuePriority(mode) {
705
- // Queue priority defaults:
706
- // - user/bashed prompt input defaults to `next`, so it can be attached at the
707
- // next model-send boundary while a turn is active.
708
- // - task notifications default to `later`, unless the caller explicitly marks
709
- // them urgent (e.g. interactive shell stall/completion).
710
- return mode === 'task-notification' ? 'later' : 'next';
711
- }
712
-
713
- function isQueuedEntryEditable(entry) {
714
- const mode = entry?.mode || 'prompt';
715
- return mode !== 'task-notification' && mode !== 'pending-resume';
716
- }
717
-
718
- function isQueuedEntryVisible(entry) {
719
- // state.queued drives the user-command wait list above the prompt. Background
720
- // task completions stay in the internal pending queue, but should never look
721
- // like commands typed by the user while they wait to be drained.
722
- const mode = entry?.mode || 'prompt';
723
- if (mode === 'pending-resume') return false;
724
- return isQueuedEntryEditable(entry);
725
- }
726
-
727
- function isSlashQueuedEntry(entry) {
728
- if (entry?.skipSlashCommands) return false;
729
- const text = promptContentText(entry?.content ?? entry?.text ?? '');
730
- return text.trim().startsWith('/');
731
- }
732
-
733
- function firstQueueLine(text) {
734
- return String(text || '').split('\n').map((line) => line.trim()).find(Boolean) || '';
735
- }
736
-
737
- function shortTextFingerprint(text) {
738
- const value = String(text || '').trim();
739
- let hash = 2166136261;
740
- for (let i = 0; i < value.length; i += 1) {
741
- hash ^= value.charCodeAt(i);
742
- hash = Math.imul(hash, 16777619);
743
- }
744
- return (hash >>> 0).toString(36);
745
- }
746
-
747
- function notificationDisplayText(text) {
748
- const parsed = parseAgentJob(text);
749
- const result = agentJobResultText(text, parsed);
750
- const synthetic = parseSyntheticAgentMessage(text);
751
- return firstQueueLine(synthetic?.result || result || text) || 'agent notification';
752
- }
753
-
754
- function promptContentText(content) {
755
- if (typeof content === 'string') return content;
756
- if (Array.isArray(content)) {
757
- return content.map((part) => {
758
- if (typeof part === 'string') return part;
759
- if (part?.type === 'text') return part.text || '';
760
- if (part?.type === 'image') return '[Image]';
761
- return part?.text || '';
762
- }).filter(Boolean).join('\n');
763
- }
764
- return String(content ?? '');
765
- }
766
-
767
- function timestampMs(value) {
768
- if (value == null || value === '') return 0;
769
- const n = Number(value);
770
- if (Number.isFinite(n) && n > 0) return n;
771
- const parsed = Date.parse(String(value));
772
- return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
773
- }
774
-
775
- function hasModelVisibleConversation(session) {
776
- const messages = Array.isArray(session?.messages) ? session.messages : [];
777
- return messages.some((message) => {
778
- const role = message?.role;
779
- if (role !== 'user' && role !== 'assistant' && role !== 'tool') return false;
780
- const text = promptContentText(message.content).trim();
781
- if (role === 'user' && text.startsWith('<system-reminder>')) return false;
782
- if (role === 'assistant' && text === '.' && !Array.isArray(message.toolCalls)) return false;
783
- return !!text || role === 'assistant' || role === 'tool';
784
- });
785
- }
786
-
787
- function sessionActivityTimestamp(session, fallback = 0) {
788
- if (!hasModelVisibleConversation(session)) return 0;
789
- return timestampMs(session?.lastUsedAt)
790
- || timestampMs(session?.updatedAt)
791
- || timestampMs(fallback);
792
- }
793
-
794
- function promptDisplayText(content, options = {}) {
795
- if (typeof options.displayText === 'string') return options.displayText;
796
- return promptContentText(content);
797
- }
798
-
799
- function mergePromptContents(entries) {
800
- const batch = Array.isArray(entries) ? entries : [];
801
- if (batch.every((entry) => typeof entry?.content === 'string')) {
802
- return batch.map((entry) => entry.content).filter((text) => String(text || '').trim()).join('\n');
803
- }
804
- const parts = [];
805
- for (const entry of batch) {
806
- const content = entry?.content;
807
- if (typeof content === 'string') {
808
- if (content.trim()) parts.push({ type: 'text', text: content });
809
- } else if (Array.isArray(content)) {
810
- parts.push(...content);
811
- }
812
- parts.push({ type: 'text', text: '\n' });
813
- }
814
- while (parts.length && parts[parts.length - 1]?.type === 'text' && parts[parts.length - 1]?.text === '\n') parts.pop();
815
- return parts.length === 1 && parts[0]?.type === 'text' ? parts[0].text : parts;
816
- }
817
-
818
- function mergePastedImages(entries) {
819
- const out = {};
820
- for (const entry of entries || []) {
821
- const images = entry?.pastedImages;
822
- if (!images || typeof images !== 'object') continue;
823
- for (const [id, image] of Object.entries(images)) {
824
- if (image) out[id] = image;
825
- }
826
- }
827
- return Object.keys(out).length > 0 ? out : null;
828
- }
829
-
830
- function callCommitCallbacks(entries) {
831
- for (const entry of entries || []) {
832
- try { entry?.onCommitted?.(); } catch {}
833
- }
834
- }
835
-
836
107
  function notificationQueueKey(event, text, parsed) {
837
108
  const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
838
109
  const synthetic = parseSyntheticAgentMessage(text);
@@ -893,33 +164,6 @@ function isExecutionNotification(event, text, parsed) {
893
164
  return Boolean(parsed?.taskId && /^(?:agent task:|task_id:)/mi.test(String(text || '')));
894
165
  }
895
166
 
896
- function agentArgsWithResultMetadata(args, parsed) {
897
- if (!parsed) return args;
898
- const next = { ...(args && typeof args === 'object' ? args : {}) };
899
- const requestedAction = String(next.type || next.action || next.mode || '').trim().toLowerCase();
900
- if (parsed.type) {
901
- // Job status envelopes report the original job type (usually "spawn").
902
- // Preserve the user's current agent tool action ("status", "read", …) so
903
- // manual checks render as "Reviewer status" instead of another
904
- // "Spawning Reviewer" card. Keep the job type as metadata for detail.
905
- if (!requestedAction || /^(notification|result|completion)$/i.test(requestedAction)) next.type = parsed.type;
906
- else next.jobType = parsed.type;
907
- }
908
- if (parsed.status) next.status = parsed.status;
909
- if (parsed.taskId) next.task_id = parsed.taskId;
910
- if (parsed.agent) next.agent = parsed.agent;
911
- if (parsed.preset) next.preset = parsed.preset;
912
- if (parsed.provider) next.provider = parsed.provider;
913
- if (parsed.model) next.model = parsed.model;
914
- if (parsed.effort) next.effort = parsed.effort;
915
- if (parsed.fast !== undefined) next.fast = parsed.fast;
916
- if (!next.tag && parsed.target) {
917
- const target = parsed.target.split(/\s+/)[0];
918
- if (target && !target.startsWith('sess_')) next.tag = target;
919
- }
920
- return next;
921
- }
922
-
923
167
  export async function createEngineSession({
924
168
  provider: providerName,
925
169
  model,
@@ -1009,6 +253,7 @@ export async function createEngineSession({
1009
253
  let state = {
1010
254
  items: [],
1011
255
  toasts: [],
256
+ progressHint: null,
1012
257
  busy: false,
1013
258
  commandBusy: false,
1014
259
  commandStatus: null,
@@ -1018,6 +263,14 @@ export async function createEngineSession({
1018
263
  toolApproval: null,
1019
264
  lastTurn: null,
1020
265
  stats: createSessionStats(),
266
+ // Incremental derivations published by the engine so App does not scan all
267
+ // transcript items on every change:
268
+ // - activeToolSummary: running Explore/Search active counts + earliest
269
+ // startedAt for the prompt-line status (replaces App.jsx O(n) items scan).
270
+ // - promptHistoryList: newest-first deduped user-prompt history, rebuilt
271
+ // only when a user item is appended (replaces the per-change rescan).
272
+ activeToolSummary: null,
273
+ promptHistoryList: [],
1021
274
  ...baseRouteState(),
1022
275
  displayContextWindow: 0,
1023
276
  compactBoundaryTokens: 0,
@@ -1131,10 +384,85 @@ export async function createEngineSession({
1131
384
  const id = nextItems[i]?.id;
1132
385
  if (id != null) itemIndexById.set(id, i);
1133
386
  }
387
+ // Bulk item swap (session load / clear / compact). Derive the prompt-history
388
+ // list from the NEW items and stage it onto state here so App never rescans;
389
+ // the callers that invoke replaceItems always follow with a set({items:...,
390
+ // ...}) that carries fresh references, so this pre-stage does not defeat any
391
+ // emit (the accompanying set() diffs the full patch). A bulk swap also
392
+ // discards the old transcript, so drop any tracked active tool calls.
393
+ activeToolCalls.clear();
394
+ state = { ...state, items: nextItems, promptHistoryList: recomputePromptHistory(nextItems), activeToolSummary: null };
1134
395
  return nextItems;
1135
396
  };
1136
397
  let flushDeferredBeforeImmediatePush = null;
1137
398
  let pushingFromDeferredEntry = false;
399
+ // --- Prompt-history list (newest-first, deduped) maintained incrementally ---
400
+ // App previously rebuilt this from state.items on EVERY transcript change
401
+ // (App.jsx recentPromptHistory useMemo). It only changes when a user item is
402
+ // appended, so rebuild it there and on bulk item swaps, publishing to
403
+ // state.promptHistoryList.
404
+ const PROMPT_HISTORY_LIMIT = 50;
405
+ const promptHistoryKey = (value) => String(value || '').trim().replace(/\s+/g, ' ');
406
+ const recomputePromptHistory = (sourceItems = null) => {
407
+ // Pure: derive the newest-first deduped user-prompt history from the given
408
+ // items (defaults to state.items) and RETURN it. Callers decide whether to
409
+ // publish via set() so the immutable-emit contract is preserved.
410
+ const items = Array.isArray(sourceItems) ? sourceItems : (Array.isArray(state.items) ? state.items : []);
411
+ const seen = new Set();
412
+ const history = [];
413
+ for (let i = items.length - 1; i >= 0 && history.length < PROMPT_HISTORY_LIMIT; i -= 1) {
414
+ const item = items[i];
415
+ if (item?.kind !== 'user') continue;
416
+ const text = String(item.text || '').trim();
417
+ const key = promptHistoryKey(text);
418
+ if (!key || seen.has(key)) continue;
419
+ seen.add(key);
420
+ history.push(text);
421
+ }
422
+ return history;
423
+ };
424
+ // --- Active-tool summary (Explore/Search) maintained incrementally ---
425
+ // App previously scanned every transcript item on every change to derive the
426
+ // prompt-line "Exploring N / Searching N" status. Instead the tool lifecycle
427
+ // below tracks per-callId category + started-at in activeToolCalls and derives
428
+ // the small summary from it, publishing state.activeToolSummary only when the
429
+ // aggregate (counts + earliest start) actually changes.
430
+ const activeToolCalls = new Map(); // callKey -> { category, count, startedAt }
431
+ const recomputeActiveToolSummary = () => {
432
+ let exploreCount = 0, exploreStart = 0, searchCount = 0, searchStart = 0;
433
+ for (const rec of activeToolCalls.values()) {
434
+ if (!rec) continue;
435
+ const c = Math.max(1, Number(rec.count || 1));
436
+ const started = Number(rec.startedAt || 0);
437
+ if (rec.category === 'Explore') {
438
+ exploreCount += c;
439
+ if (started > 0 && (exploreStart === 0 || started < exploreStart)) exploreStart = started;
440
+ } else if (rec.category === 'Search') {
441
+ searchCount += c;
442
+ if (started > 0 && (searchStart === 0 || started < searchStart)) searchStart = started;
443
+ }
444
+ }
445
+ const next = (exploreCount || searchCount)
446
+ ? `${exploreCount}:${exploreStart}:${searchCount}:${searchStart}`
447
+ : '';
448
+ const prev = state.activeToolSummary || '';
449
+ if (next !== prev) set({ activeToolSummary: next || null });
450
+ };
451
+ const markToolCallActive = (callKey, category, count, startedAt) => {
452
+ if (!callKey || (category !== 'Explore' && category !== 'Search')) return;
453
+ activeToolCalls.set(callKey, { category, count: Math.max(1, Number(count || 1)), startedAt: Number(startedAt || Date.now()) });
454
+ recomputeActiveToolSummary();
455
+ };
456
+ const markToolCallDone = (callKey) => {
457
+ if (!callKey || !activeToolCalls.has(callKey)) return;
458
+ activeToolCalls.delete(callKey);
459
+ recomputeActiveToolSummary();
460
+ };
461
+ const clearActiveToolSummary = () => {
462
+ if (activeToolCalls.size === 0 && !state.activeToolSummary) return;
463
+ activeToolCalls.clear();
464
+ if (state.activeToolSummary) set({ activeToolSummary: null });
465
+ };
1138
466
  const pushItem = (item) => {
1139
467
  if (!pushingFromDeferredEntry && flushDeferredBeforeImmediatePush) {
1140
468
  flushDeferredBeforeImmediatePush();
@@ -1142,7 +470,16 @@ export async function createEngineSession({
1142
470
  const index = state.items.length;
1143
471
  const items = [...state.items, item];
1144
472
  if (item?.id != null) itemIndexById.set(item.id, index);
1145
- set({ items });
473
+ if (item?.kind === 'user') {
474
+ // Rebuild the derived history against the NEW list (not yet in state) and
475
+ // publish items + the fresh list in ONE set(). Do NOT pre-assign to state
476
+ // first — set() diffs against the current state, so a pre-assign would make
477
+ // the references identical and skip emit().
478
+ const promptHistoryList = recomputePromptHistory(items);
479
+ set({ items, promptHistoryList });
480
+ } else {
481
+ set({ items });
482
+ }
1146
483
  };
1147
484
  const upsertSyntheticToolItem = (text, id = nextId(), parsed = null) => {
1148
485
  const synthetic = parseSyntheticAgentMessage(text);
@@ -1198,6 +535,16 @@ export async function createEngineSession({
1198
535
  pushItem({ kind: 'notice', id, text: value, tone });
1199
536
  return id;
1200
537
  };
538
+ // Sticky (non-TTL) input-hint-line progress state, for long-running
539
+ // installs (e.g. voice runtime download) that would otherwise spam the
540
+ // 3s toast queue. Distinct from pushToast/pushNotice: it persists across
541
+ // renders until explicitly cleared (setProgressHint('', ...) or a falsy
542
+ // text), and App.jsx's inputHint falls back to it only when no promptHint
543
+ // and no live toast currently cover the same line.
544
+ const setProgressHint = (text, tone = 'info') => {
545
+ const value = String(text ?? '').trim();
546
+ set({ progressHint: value ? { text: value, tone } : null });
547
+ };
1201
548
  const toolApprovalQueue = [];
1202
549
  let activeToolApproval = null;
1203
550
  function normalizeToolApprovalRequest(input = {}, id = nextId()) {
@@ -1523,6 +870,9 @@ export async function createEngineSession({
1523
870
  if (!card || card.done) return false;
1524
871
  const callId = toolResultCallId(message) || card.callId;
1525
872
  if (callId && done.has(callId)) return false;
873
+ // Any resolving call clears its active-summary entry (keyed by the same
874
+ // callKey used at markToolCallActive; card.callId holds it for both branches).
875
+ markToolCallDone(card.callId);
1526
876
  // A result for this card arrived (possibly before its deferred push delay
1527
877
  // elapsed) — surface the card now so the patch below has a live item and the
1528
878
  // fast tool paints a completed card directly, no pending placeholder stage.
@@ -1551,19 +901,19 @@ export async function createEngineSession({
1551
901
  const allCalls = [...aggregate.calls.values()];
1552
902
  const completed = allCalls.filter((r) => r.resolved).length;
1553
903
  const errors = allCalls.filter((r) => r.isError).length;
1554
- // Collapsed detail is status-only (no per-result summary). Failures keep a
1555
- // bare 'N Failed' status so an error stays visible while collapsed.
904
+ // Collapsed detail carries the merged per-call count summary
905
+ // ("512 lines, 6 matches, 3 files") so the finished card answers "how
906
+ // much" without ctrl+o. Failures keep a bare 'N Ok · N Failed' status so
907
+ // an error stays visible while collapsed.
1556
908
  const succeeded = completed - errors;
1557
909
  const detailText = errors > 0
1558
910
  ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
1559
- : '';
911
+ : formatAggregateDetail(aggregateSummaries(aggregate));
1560
912
  const currentItem = state.items.find((it) => it.id === card.itemId);
1561
913
  const earlyCompleted = allCalls.filter((r) => r.resolved || r.completedEarly).length;
1562
914
  const visualCompleted = Math.max(completed, earlyCompleted, Math.min(allCalls.length, Number(currentItem?.completedCount || 0)));
1563
915
  const rawResult = aggregateRawResult(allCalls);
1564
- // Collapsed aggregate detail carries no per-result summary the card body
1565
- // shows only a status word ('Finished') or, on failure, 'N Failed'. The
1566
- // numbered+labelled raw (rawResult) is preserved for ctrl+o expansion.
916
+ // The numbered+labelled raw (rawResult) is preserved for ctrl+o expansion.
1567
917
  const displayDetail = detailText;
1568
918
  patchItem(card.itemId, {
1569
919
  result: displayDetail,
@@ -1652,25 +1002,30 @@ export async function createEngineSession({
1652
1002
  if (aggregate && card.itemId === aggregate.itemId) {
1653
1003
  const allCalls = [...aggregate.calls.values()];
1654
1004
  // Never let a call that truly never resolved be presented as a real
1655
- // completion. Stamp it resolved with an empty, non-error result so
1656
- // completedCount reflects an honest (if degenerate) accounting instead
1657
- // of manufacturing success out of a call that never came back.
1005
+ // completion. Stamp it resolved so completedCount reflects an honest
1006
+ // (if degenerate) accounting instead of manufacturing success out of
1007
+ // a call that never came back. A record already marked completedEarly
1008
+ // (via __earlyNotify) already carries a real isError/resultText/summary
1009
+ // from its actual result — preserve those; only blank-fill for calls
1010
+ // truly never heard from (no completedEarly, no resolved).
1658
1011
  for (const rec of allCalls) {
1659
1012
  if (rec.resolved) continue;
1660
1013
  rec.resolved = true;
1661
- rec.isError = false;
1662
- rec.resultText = rec.resultText || '';
1014
+ if (!rec.completedEarly) {
1015
+ rec.isError = false;
1016
+ rec.resultText = rec.resultText || '';
1017
+ }
1663
1018
  }
1664
1019
  const completed = allCalls.filter((r) => r.resolved).length;
1665
1020
  const totalCompleted = completed;
1666
1021
  const errors = allCalls.filter((r) => r.isError).length;
1667
1022
  const succeeded = completed - errors;
1668
1023
  const rawResult = aggregateRawResult(allCalls);
1669
- // Collapsed detail is status-only: no per-result summary. Failures keep a
1670
- // bare 'N Failed' status. The numbered+labelled raw is kept for ctrl+o.
1024
+ // Collapsed detail carries the merged per-call count summary; failures
1025
+ // keep a bare 'N Ok · N Failed' status. Raw is kept for ctrl+o.
1671
1026
  let displayDetail = errors > 0
1672
1027
  ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
1673
- : '';
1028
+ : formatAggregateDetail(aggregateSummaries(aggregate));
1674
1029
  if (cancelled) {
1675
1030
  // Cancelled aggregates MUST keep the [status: cancelled] marker on the
1676
1031
  // result so terminalStatus parsing resolves to 'cancelled'. Only normal
@@ -1721,6 +1076,7 @@ export async function createEngineSession({
1721
1076
  activePromptRestore = {
1722
1077
  text: String(displayText || '').trim(),
1723
1078
  pastedImages: options.pastedImages && typeof options.pastedImages === 'object' ? options.pastedImages : null,
1079
+ pastedTexts: options.pastedTexts && typeof options.pastedTexts === 'object' ? options.pastedTexts : null,
1724
1080
  onCommitted: typeof options.onCommitted === 'function' ? options.onCommitted : null,
1725
1081
  restorable: options.restorable !== false,
1726
1082
  submittedIds,
@@ -1849,6 +1205,7 @@ export async function createEngineSession({
1849
1205
  activePromptRestore.committed = true;
1850
1206
  activePromptRestore.requeueEntries = [];
1851
1207
  activePromptRestore.pastedImages = null;
1208
+ activePromptRestore.pastedTexts = null;
1852
1209
  }
1853
1210
  };
1854
1211
 
@@ -1884,11 +1241,11 @@ export async function createEngineSession({
1884
1241
  const completed = allCalls.filter((r) => r.resolved).length;
1885
1242
  const succeeded = completed - errors;
1886
1243
  const rawResult = aggregateRawResult(allCalls);
1887
- // Status-only collapsed detail (see patchToolCardResult): no per-result
1888
- // summary; failures keep 'N Failed'. Raw preserved for ctrl+o expansion.
1244
+ // Merged count summary (see patchToolCardResult); failures keep
1245
+ // 'N Ok · N Failed'. Raw preserved for ctrl+o expansion.
1889
1246
  const displayDetail = errors > 0
1890
1247
  ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
1891
- : '';
1248
+ : formatAggregateDetail(aggregateSummaries(aggregate));
1892
1249
  patchItem(aggregate.itemId, {
1893
1250
  result: displayDetail,
1894
1251
  text: displayDetail,
@@ -2046,6 +1403,17 @@ export async function createEngineSession({
2046
1403
  let _pendingThinkFlush = false; // true when a thinking update is queued
2047
1404
  let _pendingThinkingLastEndedAt = 0;
2048
1405
  let compactingActive = false;
1406
+ // Engine-local streaming scalars. Neither responseLength nor thinkingText is
1407
+ // rendered per-token by any consumer: App reads state.thinking only as a
1408
+ // boolean (App.jsx `!!(state.thinking || liveSpinner?.thinking)`) and the
1409
+ // Spinner takes outputTokens, not responseLength. So we keep these growing
1410
+ // values in engine-local vars and publish to the store only on a visible
1411
+ // transition (thinking on↔off), a completed visible text line, tool/usage
1412
+ // updates, or finalization — not on every 8ms streaming flush.
1413
+ let _publishedThinkingActive = false; // last thinking boolean pushed to store
1414
+ // responseLength is only consumed at finalize as an outputTokens fallback
1415
+ // (Math.round(responseLength/4)); we refresh state.spinner.responseLength on
1416
+ // visible-line flush and finalize so that fallback stays valid.
2049
1417
 
2050
1418
  const flushStreamBatch = () => {
2051
1419
  if (_batchTimer !== null) {
@@ -2090,24 +1458,51 @@ export async function createEngineSession({
2090
1458
  }
2091
1459
  }
2092
1460
  }
1461
+ // Only touch the spinner when there is a real reason: a visible-line
1462
+ // change (patch.items set above), a thinking→responding transition, or a
1463
+ // pending thinking end timestamp. Refresh responseLength here so the
1464
+ // finalize outputTokens fallback stays valid without a per-token push.
2093
1465
  const responseLengthVal = assistantText.length + thinkingText.length;
2094
- if (state.spinner) {
1466
+ const visibleLineChanged = patch.items !== undefined;
1467
+ const thinkingTransition = _publishedThinkingActive === true; // was thinking, now responding
1468
+ if (state.spinner && (visibleLineChanged || thinkingTransition || _pendingThinkingLastEndedAt)) {
2095
1469
  patch.spinner = { ...state.spinner, responseLength: responseLengthVal, thinking: false, thinkingLastEndedAt: _pendingThinkingLastEndedAt || state.spinner.thinkingLastEndedAt, mode: compactingActive ? 'compacting' : 'responding' };
1470
+ _publishedThinkingActive = false;
2096
1471
  }
2097
1472
  if (Object.keys(patch).length > 0) set(patch);
2098
1473
  _pendingThinkingLastEndedAt = 0;
2099
1474
  }
2100
1475
  if (_pendingThinkFlush) {
2101
1476
  _pendingThinkFlush = false;
2102
- const responseLengthVal = assistantText.length + thinkingText.length;
2103
- const thinkingElapsedMs = accumulatedThinkingMs + (thinkingSegmentStartedAt ? Math.max(0, Date.now() - thinkingSegmentStartedAt) : 0);
2104
- const patch = { thinking: compactingActive ? null : thinkingText };
2105
- if (state.spinner) {
2106
- patch.spinner = compactingActive
2107
- ? { ...state.spinner, responseLength: responseLengthVal, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: state.spinner.thinkingLastEndedAt || 0, mode: 'compacting' }
2108
- : { ...state.spinner, responseLength: responseLengthVal, thinking: true, thinkingStartedAt, thinkingSegmentStartedAt, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: 0, mode: 'thinking' };
1477
+ // App only consumes state.thinking as a boolean and the Spinner only
1478
+ // reads the thinking flag + timing anchors none of them render the
1479
+ // growing thinkingText. So publish the thinking boolean only on the
1480
+ // OFF→ON transition (or when compacting toggles the flag), not on every
1481
+ // 8ms reasoning chunk. The full thinkingText stays engine-local and is
1482
+ // emitted at finalize via the normal spinner/thinking teardown.
1483
+ const nextThinkingActive = !compactingActive;
1484
+ // Skip the push when the published thinking boolean is unchanged: neither
1485
+ // the growing thinkingText nor responseLength is rendered per-token, and
1486
+ // the Spinner derives its live elapsed from the (already-published)
1487
+ // thinkingSegmentStartedAt anchor. Applies to both thinking and
1488
+ // compacting steady state.
1489
+ if (nextThinkingActive === _publishedThinkingActive) {
1490
+ // no-op: boolean unchanged
1491
+ } else {
1492
+ const responseLengthVal = assistantText.length + thinkingText.length;
1493
+ const thinkingElapsedMs = accumulatedThinkingMs + (thinkingSegmentStartedAt ? Math.max(0, Date.now() - thinkingSegmentStartedAt) : 0);
1494
+ // state.thinking stays a truthy sentinel while active; consumers read it
1495
+ // as a boolean. Keep the value stable (thinkingText) so a late consumer
1496
+ // still sees real text, but only push on transition.
1497
+ const patch = { thinking: compactingActive ? null : thinkingText };
1498
+ if (state.spinner) {
1499
+ patch.spinner = compactingActive
1500
+ ? { ...state.spinner, responseLength: responseLengthVal, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: state.spinner.thinkingLastEndedAt || 0, mode: 'compacting' }
1501
+ : { ...state.spinner, responseLength: responseLengthVal, thinking: true, thinkingStartedAt, thinkingSegmentStartedAt, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: 0, mode: 'thinking' };
1502
+ }
1503
+ set(patch);
1504
+ _publishedThinkingActive = nextThinkingActive;
2109
1505
  }
2110
- set(patch);
2111
1506
  }
2112
1507
  };
2113
1508
 
@@ -2122,6 +1517,8 @@ export async function createEngineSession({
2122
1517
  const markToolCardCompletedState = (callId, message) => {
2123
1518
  const card = cardByCallId.get(callId);
2124
1519
  if (!card) return;
1520
+ // Early completion also clears the active-summary entry.
1521
+ markToolCallDone(card.callId);
2125
1522
  const aggregate = card.aggregate;
2126
1523
  if (aggregate && card.itemId === aggregate.itemId) {
2127
1524
  const callRec = aggregate.calls.get(callId);
@@ -2194,7 +1591,11 @@ export async function createEngineSession({
2194
1591
  assistantText = '';
2195
1592
  const value = String(text || '').trim();
2196
1593
  if (value) {
2197
- finalizeToolHeaders();
1594
+ // Any non-tool transcript item is a block boundary: seal the
1595
+ // aggregate continuation (not just finalize headers) so a later
1596
+ // same-category tool call opens a fresh card instead of reusing
1597
+ // one whose count would then change ABOVE this steered user item.
1598
+ clearAggregateContinuation();
2198
1599
  pushUserOrSyntheticItem(value);
2199
1600
  }
2200
1601
  },
@@ -2208,6 +1609,7 @@ export async function createEngineSession({
2208
1609
  if (thinkingText && state.thinking) {
2209
1610
  const thinkingLastEndedAt = closeThinkingSegment();
2210
1611
  set({ thinking: null, spinner: state.spinner ? { ...state.spinner, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingLastEndedAt, mode: 'tool-use' } : state.spinner });
1612
+ _publishedThinkingActive = false;
2211
1613
  } else if (state.spinner) {
2212
1614
  set({ spinner: { ...state.spinner, mode: 'tool-use' } });
2213
1615
  }
@@ -2233,6 +1635,13 @@ export async function createEngineSession({
2233
1635
  const bucket = aggregateBucketForCategory(category);
2234
1636
  const callId = toolCallId(c);
2235
1637
  const callKey = callId || `__tool_${toolCards.length}_${i}`;
1638
+ // The old App scan counted multi-pattern calls via
1639
+ // aggregateToolCategoryEntry(...).count, not a flat 1. Derive the same
1640
+ // count here so the incremental Explore/Search summary matches.
1641
+ const activeCount = Number(aggregateToolCategoryEntry(name, args, category)?.count || 1);
1642
+ // Track Explore/Search calls as active for the incremental prompt-
1643
+ // line summary; cleared when their result lands or the turn ends.
1644
+ markToolCallActive(callKey, category, activeCount, Date.now());
2236
1645
 
2237
1646
  if (!bucket) {
2238
1647
  const itemId = nextId();
@@ -2321,6 +1730,11 @@ export async function createEngineSession({
2321
1730
  },
2322
1731
  onCompactEvent: (event) => {
2323
1732
  flushStreamBatch();
1733
+ // Non-tool transcript item — same block-boundary rule as the
1734
+ // steered user item above: seal any live aggregate first so a
1735
+ // later same-category tool call doesn't reuse a card whose count
1736
+ // would then change above this statusdone item.
1737
+ clearAggregateContinuation();
2324
1738
  pushItem({
2325
1739
  kind: 'statusdone',
2326
1740
  id: nextId(),
@@ -2335,6 +1749,7 @@ export async function createEngineSession({
2335
1749
  compactingActive = true;
2336
1750
  const thinkingLastEndedAt = closeThinkingSegment();
2337
1751
  _pendingThinkFlush = false;
1752
+ _publishedThinkingActive = false; // compacting cleared the thinking flag
2338
1753
  set({
2339
1754
  thinking: null,
2340
1755
  spinner: {
@@ -2362,7 +1777,11 @@ export async function createEngineSession({
2362
1777
  if (!textChunk) return;
2363
1778
  markPromptCommitted();
2364
1779
  const thinkingLastEndedAt = closeThinkingSegment();
2365
- if (state.thinking) set({ thinking: null }); // collapse thinking panel immediately, no batch delay
1780
+ // Drop any queued think-flush too: it would otherwise re-publish
1781
+ // spinner.thinking:true from flushStreamBatch and resurrect the
1782
+ // indicator after we cleared it here.
1783
+ _pendingThinkFlush = false;
1784
+ if (state.thinking) { set({ thinking: null }); _publishedThinkingActive = false; } // collapse thinking panel immediately, no batch delay
2366
1785
  assistantText += textChunk;
2367
1786
  currentAssistantText += textChunk;
2368
1787
  // Accumulate text and schedule a batched flush (≤1 render per
@@ -2391,7 +1810,8 @@ export async function createEngineSession({
2391
1810
  if (currentAssistantText.trim()) return;
2392
1811
  markPromptCommitted();
2393
1812
  closeThinkingSegment();
2394
- if (state.thinking) set({ thinking: null });
1813
+ _pendingThinkFlush = false; // see onTextDelta: prevent a stale think flush resurrecting the indicator
1814
+ if (state.thinking) { set({ thinking: null }); _publishedThinkingActive = false; }
2395
1815
  assistantText += full;
2396
1816
  currentAssistantText += full;
2397
1817
  _pendingTextFlush = true;
@@ -2477,7 +1897,15 @@ export async function createEngineSession({
2477
1897
  closeThinkingSegment();
2478
1898
  const elapsedMs = Date.now() - startedAt;
2479
1899
  const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
2480
- const finalOutputTokens = Math.max(0, Number(state.spinner?.outputTokens || 0), Math.round(Number(state.spinner?.responseLength || 0) / 4));
1900
+ // responseLength is engine-local now (not pushed per-token), so compute the
1901
+ // fallback from the live accumulator instead of the possibly-stale
1902
+ // state.spinner.responseLength. Final-only / non-streaming turns never
1903
+ // accumulate `assistantText` (only currentAssistantText is set at the
1904
+ // finalize reconcile above), so take the larger of the two text sources so
1905
+ // a no-usage turn still estimates tokens from the final content.
1906
+ const finalAssistantLen = Math.max(assistantText.length, currentAssistantText.length);
1907
+ const finalResponseLength = finalAssistantLen + thinkingText.length;
1908
+ const finalOutputTokens = Math.max(0, Number(state.spinner?.outputTokens || 0), Math.round(finalResponseLength / 4));
2481
1909
  const turnStatus = cancelled ? 'cancelled' : 'done';
2482
1910
  const resultContent = askResult?.content != null ? String(askResult.content).trim() : '';
2483
1911
  const assistantOutput = (currentAssistantText || assistantText || '').trim();
@@ -2510,6 +1938,8 @@ export async function createEngineSession({
2510
1938
  });
2511
1939
  flushDeferredExecutionPendingResumeKick();
2512
1940
  }
1941
+ clearActiveToolSummary();
1942
+ _publishedThinkingActive = false; // turn teardown cleared state.thinking
2513
1943
  return cancelled ? 'cancelled' : 'done';
2514
1944
  }
2515
1945
 
@@ -2526,6 +1956,7 @@ export async function createEngineSession({
2526
1956
  text: displayText,
2527
1957
  content: text,
2528
1958
  pastedImages: options.pastedImages && typeof options.pastedImages === 'object' ? options.pastedImages : null,
1959
+ pastedTexts: options.pastedTexts && typeof options.pastedTexts === 'object' ? options.pastedTexts : null,
2529
1960
  onCommitted: typeof options.onCommitted === 'function' ? options.onCommitted : null,
2530
1961
  mode,
2531
1962
  priority,
@@ -2539,6 +1970,7 @@ export async function createEngineSession({
2539
1970
  const ids = new Set(entries.map((entry) => entry.id));
2540
1971
  const keys = entries.map((entry) => entry.key).filter(Boolean);
2541
1972
  for (const key of keys) pendingNotificationKeys.delete(key);
1973
+ for (const key of keys) displayedExecutionNotificationKeys.delete(key);
2542
1974
  const queued = state.queued.filter((q) => !ids.has(q.id));
2543
1975
  if (queued.length !== state.queued.length) set({ queued });
2544
1976
  }
@@ -2617,9 +2049,11 @@ export async function createEngineSession({
2617
2049
  }
2618
2050
  const nonEditable = batch.filter((entry) => !isQueuedEntryEditable(entry));
2619
2051
  const batchPastedImages = mergePastedImages(batch);
2052
+ const batchPastedTexts = mergePastedTexts(batch);
2620
2053
  const turnStatus = await runTurn(merged, {
2621
2054
  displayText: batch.map((entry) => entry.text).filter((text) => String(text || '').trim()).join('\n'),
2622
2055
  pastedImages: batchPastedImages,
2056
+ pastedTexts: batchPastedTexts,
2623
2057
  onCommitted: () => callCommitCallbacks(batch),
2624
2058
  submittedIds: [...ids],
2625
2059
  restorable: nonEditable.length === 0,
@@ -2747,7 +2181,7 @@ export async function createEngineSession({
2747
2181
  removeQueuedEntries(queued);
2748
2182
  const queuedText = queued.map((item) => item.text).filter((text) => String(text || '').trim()).join('\n');
2749
2183
  const combinedText = [queuedText, String(currentText || '')].filter((text) => text.trim()).join('\n');
2750
- return { count: queued.length, text: combinedText, pastedImages: mergePastedImages(queued) };
2184
+ return { count: queued.length, text: combinedText, pastedImages: mergePastedImages(queued), pastedTexts: mergePastedTexts(queued) };
2751
2185
  }
2752
2186
 
2753
2187
  const resetStats = () => {
@@ -2762,6 +2196,8 @@ export async function createEngineSession({
2762
2196
  state.spinner = null;
2763
2197
  state.lastTurn = null;
2764
2198
  state.busy = false;
2199
+ pendingNotificationKeys.clear();
2200
+ displayedExecutionNotificationKeys.clear();
2765
2201
  };
2766
2202
  const resetTuiForPendingSessionReset = () => {
2767
2203
  pendingSessionReset = true;
@@ -2929,8 +2365,15 @@ export async function createEngineSession({
2929
2365
  set({ commandBusy: true });
2930
2366
  try {
2931
2367
  const next = runtime.setCompactionSettings?.(input) || {};
2932
- syncContextStats({ allowEstimated: true });
2933
2368
  set({ ...routeState(), stats: { ...state.stats } });
2369
+ // Context-stats recompute (transcript scan + per-message JSON
2370
+ // stringify) is the secondary hitch source on this toggle; defer it
2371
+ // off the key-handler tick so Ink repaints the setting change first.
2372
+ // Stats become eventually consistent on the next tick/repaint.
2373
+ setTimeout(() => {
2374
+ syncContextStats({ allowEstimated: true });
2375
+ set({ stats: { ...state.stats } });
2376
+ }, 0);
2934
2377
  return next;
2935
2378
  } finally {
2936
2379
  set({ commandBusy: false });
@@ -2944,8 +2387,14 @@ export async function createEngineSession({
2944
2387
  set({ commandBusy: true });
2945
2388
  try {
2946
2389
  const next = await runtime.setMemoryEnabled?.(enabled);
2947
- syncContextStats({ allowEstimated: true });
2948
2390
  set({ ...routeState(), stats: { ...state.stats } });
2391
+ // Deferred for the same reason as setCompactionSettings above: keep
2392
+ // the recompute off the key-handler tick so the toggle repaints
2393
+ // immediately; stats catch up right after.
2394
+ setTimeout(() => {
2395
+ syncContextStats({ allowEstimated: true });
2396
+ set({ stats: { ...state.stats } });
2397
+ }, 0);
2949
2398
  return next;
2950
2399
  } finally {
2951
2400
  set({ commandBusy: false });
@@ -3278,6 +2727,7 @@ export async function createEngineSession({
3278
2727
  const canRestore = restoreState?.restorable && !hasPendingSteering;
3279
2728
  const restoreText = canRestore ? restoreState.text : '';
3280
2729
  const restorePastedImages = canRestore && restoreState?.pastedImages ? restoreState.pastedImages : null;
2730
+ const restorePastedTexts = canRestore && restoreState?.pastedTexts ? restoreState.pastedTexts : null;
3281
2731
  // When steering suppresses the restore, the interrupted prompt's pasted
3282
2732
  // images never get committed (onCommitted won't fire) nor re-installed into
3283
2733
  // the draft, so hand them back for cleanup to avoid a stale `[Image #id]`
@@ -3285,6 +2735,9 @@ export async function createEngineSession({
3285
2735
  const discardPastedImages = restoreState?.restorable && hasPendingSteering && restoreState?.pastedImages
3286
2736
  ? restoreState.pastedImages
3287
2737
  : null;
2738
+ const discardPastedTexts = restoreState?.restorable && hasPendingSteering && restoreState?.pastedTexts
2739
+ ? restoreState.pastedTexts
2740
+ : null;
3288
2741
  const requeueEntries = restoreState && !restoreState.committed && Array.isArray(restoreState.requeueEntries)
3289
2742
  ? restoreState.requeueEntries.slice()
3290
2743
  : [];
@@ -3306,7 +2759,7 @@ export async function createEngineSession({
3306
2759
  restoreState.restorable = false;
3307
2760
  restoreState.requeueEntries = [];
3308
2761
  }
3309
- return { aborted, restoreText, pastedImages: restorePastedImages, discardPastedImages };
2762
+ return { aborted, restoreText, pastedImages: restorePastedImages, discardPastedImages, pastedTexts: restorePastedTexts, discardPastedTexts };
3310
2763
  },
3311
2764
  resolveToolApproval: (id, decision = {}) => {
3312
2765
  const approved = decision === true || decision?.approved === true;
@@ -3368,8 +2821,14 @@ export async function createEngineSession({
3368
2821
  set({ commandBusy: true });
3369
2822
  try {
3370
2823
  const result = await runtime.setOutputStyle?.(styleId);
3371
- resetStatsAndSyncContext();
2824
+ resetStats();
3372
2825
  set({ ...routeState(), stats: { ...state.stats } });
2826
+ // Defer the context recompute (transcript scan) off this tick so
2827
+ // the style change repaints immediately; stats settle right after.
2828
+ setTimeout(() => {
2829
+ syncContextStats({ allowEstimated: true });
2830
+ set({ stats: { ...state.stats } });
2831
+ }, 0);
3373
2832
  return result;
3374
2833
  } finally {
3375
2834
  set({ commandBusy: false });
@@ -3603,6 +3062,7 @@ export async function createEngineSession({
3603
3062
  }
3604
3063
  },
3605
3064
  pushNotice,
3065
+ setProgressHint,
3606
3066
  clear: async () => {
3607
3067
  if (state.commandBusy) return false;
3608
3068
  set({ commandBusy: true });