mixdog 0.9.18 → 0.9.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -29
  3. package/package.json +5 -3
  4. package/scripts/build-runtime-windows.ps1 +242 -242
  5. package/scripts/build-tui.mjs +6 -0
  6. package/scripts/hook-bus-test.mjs +8 -0
  7. package/scripts/log-writer-guard-smoke.mjs +131 -0
  8. package/scripts/output-style-smoke.mjs +2 -2
  9. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  10. package/scripts/recall-bench-cases.json +10 -0
  11. package/scripts/recall-bench.mjs +91 -2
  12. package/scripts/recall-quality-cases.json +1 -2
  13. package/scripts/recall-usecase-cases.json +1 -1
  14. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  15. package/scripts/smoke-runtime-negative.ps1 +106 -106
  16. package/scripts/tool-efficiency-diag.mjs +5 -2
  17. package/scripts/tool-smoke.mjs +251 -72
  18. package/src/agents/debugger/AGENT.md +3 -3
  19. package/src/agents/heavy-worker/AGENT.md +7 -10
  20. package/src/agents/maintainer/AGENT.md +1 -2
  21. package/src/agents/reviewer/AGENT.md +1 -2
  22. package/src/agents/worker/AGENT.md +5 -8
  23. package/src/defaults/agents.json +3 -0
  24. package/src/help.mjs +2 -5
  25. package/src/lib/mixdog-debug.cjs +13 -0
  26. package/src/mixdog-session-runtime.mjs +7 -3311
  27. package/src/rules/agent/00-core.md +4 -3
  28. package/src/rules/agent/30-explorer.md +53 -22
  29. package/src/rules/lead/02-channels.md +3 -3
  30. package/src/rules/lead/lead-tool.md +3 -2
  31. package/src/rules/shared/01-tool.md +24 -29
  32. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  36. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  37. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  38. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  39. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  42. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  43. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  44. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  45. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  46. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  47. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  48. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  49. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  50. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  51. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  52. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  53. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  54. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  55. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  57. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  58. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  59. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  60. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  61. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  62. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  63. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  64. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  66. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  67. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  68. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  72. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  74. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  76. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  77. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  78. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  80. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  82. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  83. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  84. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  85. package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
  86. package/src/runtime/channels/backends/discord.mjs +43 -1
  87. package/src/runtime/channels/index.mjs +6 -2096
  88. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  89. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  90. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  91. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  92. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  93. package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
  94. package/src/runtime/channels/lib/owned-runtime.mjs +547 -0
  95. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  96. package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
  97. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  98. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  99. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  100. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  101. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  102. package/src/runtime/channels/lib/worker-main.mjs +771 -0
  103. package/src/runtime/memory/index.mjs +73 -1725
  104. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  105. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  106. package/src/runtime/memory/lib/http-router.mjs +772 -0
  107. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  108. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  109. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  110. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  111. package/src/runtime/memory/lib/query-handlers.mjs +38 -6
  112. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  113. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  114. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  115. package/src/runtime/shared/atomic-file.mjs +10 -4
  116. package/src/runtime/shared/background-tasks.mjs +4 -2
  117. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  118. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  119. package/src/runtime/shared/tool-surface.mjs +30 -1
  120. package/src/session-runtime/boot-profile.mjs +36 -0
  121. package/src/session-runtime/channel-config-api.mjs +70 -0
  122. package/src/session-runtime/config-lifecycle.mjs +1 -1
  123. package/src/session-runtime/context-status.mjs +181 -0
  124. package/src/session-runtime/cwd-plugins.mjs +46 -3
  125. package/src/session-runtime/env.mjs +17 -0
  126. package/src/session-runtime/lifecycle-api.mjs +242 -0
  127. package/src/session-runtime/mcp-glue.mjs +24 -3
  128. package/src/session-runtime/model-route-api.mjs +198 -0
  129. package/src/session-runtime/output-styles.mjs +44 -10
  130. package/src/session-runtime/provider-auth-api.mjs +135 -0
  131. package/src/session-runtime/resource-api.mjs +282 -0
  132. package/src/session-runtime/runtime-core.mjs +2046 -0
  133. package/src/session-runtime/session-turn-api.mjs +274 -0
  134. package/src/session-runtime/tool-catalog.mjs +18 -264
  135. package/src/session-runtime/tool-defs.mjs +2 -2
  136. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  137. package/src/session-runtime/workflow.mjs +16 -1
  138. package/src/standalone/agent-tool.mjs +2 -2
  139. package/src/standalone/channel-worker.mjs +78 -5
  140. package/src/standalone/explore-tool.mjs +1 -1
  141. package/src/standalone/memory-runtime-proxy.mjs +56 -6
  142. package/src/tui/App.jsx +88 -97
  143. package/src/tui/app/channel-pickers.mjs +45 -0
  144. package/src/tui/app/core-memory-picker.mjs +1 -1
  145. package/src/tui/app/slash-commands.mjs +0 -1
  146. package/src/tui/app/slash-dispatch.mjs +0 -16
  147. package/src/tui/app/transcript-window.mjs +44 -1
  148. package/src/tui/app/use-mouse-input.mjs +15 -2
  149. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  150. package/src/tui/app/use-transcript-scroll.mjs +82 -4
  151. package/src/tui/app/use-transcript-window.mjs +65 -5
  152. package/src/tui/components/PromptInput.jsx +33 -64
  153. package/src/tui/components/ToolExecution.jsx +2 -2
  154. package/src/tui/dist/index.mjs +7908 -7558
  155. package/src/tui/engine/context-state.mjs +145 -0
  156. package/src/tui/engine/prompt-history.mjs +27 -0
  157. package/src/tui/engine/session-api-ext.mjs +478 -0
  158. package/src/tui/engine/session-api.mjs +545 -0
  159. package/src/tui/engine/session-flow.mjs +485 -0
  160. package/src/tui/engine/turn.mjs +1078 -0
  161. package/src/tui/engine.mjs +69 -2582
  162. package/src/tui/index.jsx +7 -0
  163. package/src/tui/lib/voice-setup.mjs +166 -0
  164. package/src/tui/paste-attachments.mjs +12 -5
  165. package/src/tui/prompt-history-store.mjs +125 -12
  166. package/vendor/ink/build/ink.js +16 -1
  167. package/vendor/ink/build/output.js +30 -4
  168. package/vendor/ink/build/render.js +5 -0
  169. package/scripts/bench/cache-probe-tasks.json +0 -8
  170. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  171. package/scripts/bench/lead-review-tasks.json +0 -20
  172. package/scripts/bench/r4-mixed-tasks.json +0 -20
  173. package/scripts/bench/r5-orchestrated-task.json +0 -7
  174. package/scripts/bench/review-tasks.json +0 -20
  175. package/scripts/bench/round-codex.json +0 -114
  176. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  177. package/scripts/bench/round-mixdog-lead.json +0 -269
  178. package/scripts/bench/round-mixdog.json +0 -126
  179. package/scripts/bench/round-r10-bigsample.json +0 -679
  180. package/scripts/bench/round-r11-codexalign.json +0 -257
  181. package/scripts/bench/round-r13-clientmeta.json +0 -464
  182. package/scripts/bench/round-r14-betafeatures.json +0 -466
  183. package/scripts/bench/round-r15-fulldefault.json +0 -462
  184. package/scripts/bench/round-r16-sessionid.json +0 -466
  185. package/scripts/bench/round-r17-wirebytes.json +0 -456
  186. package/scripts/bench/round-r18-prewarm.json +0 -468
  187. package/scripts/bench/round-r19-clean.json +0 -472
  188. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  189. package/scripts/bench/round-r21-delta-retry.json +0 -473
  190. package/scripts/bench/round-r22-full-probe.json +0 -693
  191. package/scripts/bench/round-r23-itemprobe.json +0 -701
  192. package/scripts/bench/round-r24-shapefix.json +0 -677
  193. package/scripts/bench/round-r25-serial.json +0 -464
  194. package/scripts/bench/round-r26-parallel3.json +0 -671
  195. package/scripts/bench/round-r27-parallel10.json +0 -894
  196. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  197. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  198. package/scripts/bench/round-r30-instid.json +0 -253
  199. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  200. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  201. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  202. package/scripts/bench/round-r34-orchestrated.json +0 -120
  203. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  204. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  205. package/scripts/bench/round-r4-codex.json +0 -114
  206. package/scripts/bench/round-r4-mixed.json +0 -225
  207. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  208. package/scripts/bench/round-r6-codex.json +0 -114
  209. package/scripts/bench/round-r6-solo.json +0 -257
  210. package/scripts/bench/round-r7-full.json +0 -254
  211. package/scripts/bench/round-r8-fulldefault.json +0 -255
  212. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  213. package/src/tui/lib/voice-recorder.mjs +0 -469
  214. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -2,12 +2,12 @@
2
2
  * use-prompt-handlers.mjs — the PROMPT HANDLER cluster extracted from App.jsx
3
3
  * (pass-7 split).
4
4
  *
5
- * Exports usePromptHandlers(), which owns the five useCallback handlers wired to
6
- * PromptInput: handleVoiceToggle / handlePromptPaste / handlePromptHistoryNavigate
7
- * / handlePromptEscape / handlePromptInterrupt. Every ref, setter, store value and
8
- * derived value the handlers close over is threaded in explicitly so the deps
9
- * arrays stay byte-identical to the original inline hooks. Import-level helpers
10
- * (voice recorder + paste attachments) are imported directly here.
5
+ * Exports usePromptHandlers(), which owns the four useCallback handlers wired to
6
+ * PromptInput: handlePromptPaste / handlePromptHistoryNavigate / handlePromptEscape
7
+ * / handlePromptInterrupt. Every ref, setter, store value and derived value the
8
+ * handlers close over is threaded in explicitly so the deps arrays stay
9
+ * byte-identical to the original inline hooks. Import-level helpers (paste
10
+ * attachments) are imported directly here.
11
11
  */
12
12
  import { useCallback } from 'react';
13
13
  import {
@@ -18,13 +18,6 @@ import {
18
18
  import {
19
19
  shouldFoldPastedText,
20
20
  } from '../paste-attachments.mjs';
21
- import {
22
- isVoiceEnabled,
23
- getRecorderState,
24
- startRecording,
25
- stopRecording,
26
- cancelRecording,
27
- } from '../lib/voice-recorder.mjs';
28
21
  import { promptHistoryKey } from '../prompt-history-store.mjs';
29
22
 
30
23
  export function usePromptHandlers({
@@ -42,10 +35,8 @@ export function usePromptHandlers({
42
35
  setPastedImages,
43
36
  setPastedTexts,
44
37
  setPromptDraftOverride,
45
- setVoiceRecPhase,
46
38
  setContextPanel,
47
39
  // derived / helper values + callbacks
48
- setVoiceStatusHint,
49
40
  syncPromptLayoutRows,
50
41
  showPromptHint,
51
42
  clearPromptHint,
@@ -63,70 +54,6 @@ export function usePromptHandlers({
63
54
  clearPastedTextsSnapshot,
64
55
  registerPastedText,
65
56
  }) {
66
- // Ctrl+Space handler wired to PromptInput's onVoiceToggle. Dispatches on the
67
- // recorder's CURRENT state (idle/recording/transcribing) — see
68
- // src/tui/lib/voice-recorder.mjs for the state machine itself.
69
- const handleVoiceToggle = useCallback(async () => {
70
- if (!isVoiceEnabled()) {
71
- store.pushNotice('Voice is off — run /voice to turn it on', 'warn');
72
- return;
73
- }
74
- const recState = getRecorderState();
75
- if (recState === 'transcribing') {
76
- store.pushNotice('Voice: already transcribing…', 'info');
77
- return;
78
- }
79
- if (recState === 'recording') {
80
- setVoiceStatusHint('… transcribing', 'info');
81
- setVoiceRecPhase('transcribing');
82
- let result;
83
- try {
84
- result = await stopRecording();
85
- } catch (e) {
86
- result = { ok: false, reason: e?.message || String(e) };
87
- }
88
- setVoiceStatusHint('', 'info');
89
- setVoiceRecPhase('idle');
90
- if (!result) return; // race: recorder was no longer RECORDING
91
- if (!result.ok) {
92
- store.pushNotice(`Voice: ${result.reason || 'transcription failed'}`, 'error');
93
- return;
94
- }
95
- const text = String(result.text || '').trim();
96
- if (!text) {
97
- store.pushNotice('Voice: no speech detected', 'warn');
98
- return;
99
- }
100
- // End-of-draft insertion via promptDraftOverride (approved design —
101
- // no cursor-position insert; see gamerscroll skill's out-of-scope note
102
- // on PromptInput's imperative surface).
103
- // Med-4: promptValueRef.current is read HERE — after `await
104
- // stopRecording()` has already resolved — not captured earlier before
105
- // the await. PromptInput keeps valueRef.current live on every
106
- // keystroke (commitDraft), so this always reflects whatever the user
107
- // typed during the recording+transcribe window; nothing typed in that
108
- // gap is overwritten.
109
- const current = promptValueRef.current || '';
110
- const next = current ? `${current}${/\s$/.test(current) ? '' : ' '}${text}` : text;
111
- syncPromptLayoutRows(next);
112
- setPromptDraftOverride({ id: Date.now(), value: next });
113
- return;
114
- }
115
- // idle -> recording
116
- let result;
117
- try {
118
- result = await startRecording();
119
- } catch (e) {
120
- result = { ok: false, reason: e?.message || String(e) };
121
- }
122
- if (!result?.ok) {
123
- store.pushNotice(`Voice: ${result?.reason || 'failed to start recording'}`, 'error');
124
- return;
125
- }
126
- setVoiceStatusHint('● REC', 'error');
127
- setVoiceRecPhase('recording');
128
- }, [store, setVoiceStatusHint, syncPromptLayoutRows]);
129
-
130
57
  const handlePromptPaste = useCallback((text, meta = {}) => {
131
58
  const source = String(meta?.source || 'paste');
132
59
  const value = String(text ?? '');
@@ -256,19 +183,6 @@ export function usePromptHandlers({
256
183
  // the active turn on the same Esc press.
257
184
  // - empty prompt + active turn interrupts the active turn.
258
185
  const handlePromptEscape = useCallback((text = '', meta = {}) => {
259
- // Recording takes priority over every other Esc branch (usage/context
260
- // panels, slash-clear, queue-restore, turn-interrupt): a live mic capture
261
- // must never silently keep running because Esc was consumed by something
262
- // else first. Only consumes Esc while actually RECORDING — transcribing
263
- // (already stopped, awaiting the HTTP round-trip) and idle fall through
264
- // to the existing branches unchanged.
265
- if (getRecorderState() === 'recording') {
266
- cancelRecording();
267
- setVoiceStatusHint('', 'info');
268
- setVoiceRecPhase('idle');
269
- store.pushNotice('Voice: recording cancelled', 'plain');
270
- return true;
271
- }
272
186
  if (usagePanel) { closeUsagePanel(); return true; }
273
187
  if (contextPanel) { setContextPanel(null); return true; }
274
188
 
@@ -283,7 +197,7 @@ export function usePromptHandlers({
283
197
  // Idle + empty + nothing to restore: nothing (double-press from empty
284
198
  // opens message selector, but we don't have that feature yet).
285
199
  return false;
286
- }, [contextPanel, usagePanel, closeUsagePanel, restoreQueuedToPrompt, clearPromptHint, clearPastedImagesSnapshot, setVoiceStatusHint, store]);
200
+ }, [contextPanel, usagePanel, closeUsagePanel, restoreQueuedToPrompt, clearPromptHint, clearPastedImagesSnapshot, store]);
287
201
 
288
202
  const handlePromptInterrupt = useCallback((currentText = '') => {
289
203
  const result = store.abort?.();
@@ -301,7 +215,6 @@ export function usePromptHandlers({
301
215
  }, [store, clearPromptHint, installPastedImages, clearPastedImagesSnapshot]);
302
216
 
303
217
  return {
304
- handleVoiceToggle,
305
218
  handlePromptPaste,
306
219
  handlePromptHistoryNavigate,
307
220
  handlePromptEscape,
@@ -44,6 +44,71 @@ export function useTranscriptScroll({
44
44
  // SCROLL_COALESCE_MS). Both call sites accumulate into pendingRows.
45
45
  const scrollCoalesceRef = useRef({ pendingRows: 0, timer: null });
46
46
  const selectionTextTimerRef = useRef(null);
47
+ // Stitch buffer: accumulates harvested transcript selection rows across scroll
48
+ // positions so Ctrl+C copies the FULL drag even after it auto-scrolled past the
49
+ // viewport. Keyed by scroll-invariant content row = screenY - scrollTarget at
50
+ // harvest time; value = row text. Only transcript-region drags accumulate.
51
+ const stitchBufferRef = useRef(new Map());
52
+ const stitchHarvestTimerRef = useRef(null);
53
+ // Scroll offset captured at the SCHEDULE (paint) time of the pending harvest —
54
+ // the deferred timer must key rows by the frame's scroll, not by whatever
55
+ // scrollTargetRef holds when the timer eventually fires (a scroll in between
56
+ // would mis-key the rows). Latest schedule wins (latest paint = latest frame).
57
+ const stitchHarvestScrollRef = useRef(0);
58
+
59
+ const clearStitchBuffer = useCallback(() => {
60
+ stitchBufferRef.current.clear();
61
+ if (stitchHarvestTimerRef.current) {
62
+ clearTimeout(stitchHarvestTimerRef.current);
63
+ stitchHarvestTimerRef.current = null;
64
+ }
65
+ }, []);
66
+
67
+ // Deferred (like rememberSelectionTextSoon) harvest of the currently visible
68
+ // selection rows into the stitch buffer. Runs on EVERY transcript selection
69
+ // paint AND on scroll-shift repaints (the rememberText:false path) so rows
70
+ // revealed only mid-scroll are captured. Later harvest of a key overwrites,
71
+ // handling partial↔full endpoint rows on retraction.
72
+ const harvestStitchRowsSoon = useCallback(() => {
73
+ if (dragRef.current.region !== 'transcript') return;
74
+ // Capture the scroll offset for THIS paint (schedule time). If a timer is
75
+ // already pending, only refresh the captured offset to the latest frame and
76
+ // reuse the existing timer.
77
+ stitchHarvestScrollRef.current = Number(scrollTargetRef.current) || 0;
78
+ if (stitchHarvestTimerRef.current) return;
79
+ stitchHarvestTimerRef.current = setTimeout(() => {
80
+ stitchHarvestTimerRef.current = null;
81
+ if (dragRef.current.region !== 'transcript') return;
82
+ const rows = store.getRenderSelectionRows?.();
83
+ if (!Array.isArray(rows)) return;
84
+ const scroll = stitchHarvestScrollRef.current;
85
+ for (const row of rows) {
86
+ if (!row || typeof row.y !== 'number') continue;
87
+ stitchBufferRef.current.set(row.y - scroll, typeof row.text === 'string' ? row.text : '');
88
+ }
89
+ }, 0);
90
+ }, [store]);
91
+
92
+ // Map the CURRENT rect + current scrollTarget onto the content-key range and
93
+ // join buffered rows sorted by key with '\n' (skipping missing keys). Returns
94
+ // '' when unusable so callers can fall back to render/remembered text.
95
+ const getStitchedSelectionText = useCallback(() => {
96
+ const buf = stitchBufferRef.current;
97
+ if (!buf.size) return '';
98
+ if (dragRef.current.region !== 'transcript') return '';
99
+ const rect = dragRef.current.rect;
100
+ if (!rect) return '';
101
+ const y1 = Number(rect.y1);
102
+ const y2 = Number(rect.y2);
103
+ if (!Number.isFinite(y1) || !Number.isFinite(y2)) return '';
104
+ const scroll = Number(scrollTargetRef.current) || 0;
105
+ const lo = Math.min(y1, y2) - scroll;
106
+ const hi = Math.max(y1, y2) - scroll;
107
+ const keys = [...buf.keys()].filter((k) => k >= lo && k <= hi).sort((a, b) => a - b);
108
+ if (!keys.length) return '';
109
+ const text = keys.map((k) => buf.get(k)).filter((t) => t != null).join('\n');
110
+ return text.trim() ? text : '';
111
+ }, []);
47
112
 
48
113
  const stopSmoothScroll = useCallback(() => {
49
114
  if (!scrollAnimationRef.current) return;
@@ -145,19 +210,24 @@ export function useTranscriptScroll({
145
210
  store.setRenderSelection?.(nextRect, { immediate: true });
146
211
  }
147
212
  if (needsCapture) rememberSelectionTextSoon();
213
+ if (nextRect) harvestStitchRowsSoon();
148
214
  return true;
149
215
  }
150
216
  state.rect = nextRect;
151
217
  state.t = Date.now();
152
218
  store.setRenderSelection?.(nextRect, immediate ? { immediate: true } : undefined);
153
219
  if (nextRect && rememberText && nextRect.captureText !== false) rememberSelectionTextSoon();
220
+ if (nextRect) harvestStitchRowsSoon();
154
221
  return true;
155
- }, [store, rememberSelectionTextSoon]);
222
+ }, [store, rememberSelectionTextSoon, harvestStitchRowsSoon]);
156
223
 
157
224
  const applySelectionRect = useCallback((rect) => {
158
225
  const clippedRect = withSelectionClip(rect);
159
226
  dragRef.current.rect = clippedRect || null;
160
- if (!clippedRect) selectionTextRef.current = '';
227
+ if (!clippedRect) {
228
+ selectionTextRef.current = '';
229
+ clearStitchBuffer();
230
+ }
161
231
  const state = selectionPaintRef.current;
162
232
  if (state.timer) {
163
233
  clearTimeout(state.timer);
@@ -165,7 +235,7 @@ export function useTranscriptScroll({
165
235
  state.pending = null;
166
236
  }
167
237
  paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
168
- }, [paintSelectionRect, withSelectionClip]);
238
+ }, [paintSelectionRect, withSelectionClip, clearStitchBuffer]);
169
239
 
170
240
  const applySelectionRectThrottled = useCallback((rect) => {
171
241
  const clippedRect = withSelectionClip(rect, { captureText: false });
@@ -274,6 +344,8 @@ export function useTranscriptScroll({
274
344
  paintState.pending = null;
275
345
  if (selectionTextTimerRef.current) clearTimeout(selectionTextTimerRef.current);
276
346
  selectionTextTimerRef.current = null;
347
+ if (stitchHarvestTimerRef.current) clearTimeout(stitchHarvestTimerRef.current);
348
+ stitchHarvestTimerRef.current = null;
277
349
  const coalesceState = scrollCoalesceRef.current;
278
350
  if (coalesceState.timer) clearTimeout(coalesceState.timer);
279
351
  coalesceState.timer = null;
@@ -341,7 +413,11 @@ export function useTranscriptScroll({
341
413
  }
342
414
  const clippedRect = withSelectionClip(rect);
343
415
  dragRef.current = { ...dragRef.current, rect: clippedRect };
344
- paintSelectionRect(clippedRect, { rememberText: !dragRef.current.active });
416
+ // Never re-harvest selection text from a scroll-shifted rect: the shift
417
+ // clips the rect to the viewport, so a harvest here would OVERWRITE the
418
+ // full text remembered at drag-release with only the still-visible rows
419
+ // (Ctrl+C after scrolling then copied just that fragment).
420
+ paintSelectionRect(clippedRect, { rememberText: false });
345
421
  }
346
422
  if (options.smooth) {
347
423
  startSmoothScroll();
@@ -508,5 +584,7 @@ export function useTranscriptScroll({
508
584
  scrollTranscriptRows,
509
585
  queueScrollCoalesced,
510
586
  moveSelectionFocus,
587
+ getStitchedSelectionText,
588
+ clearStitchBuffer,
511
589
  };
512
590
  }
@@ -22,6 +22,9 @@ import {
22
22
  setStreamingBottomPinned,
23
23
  transcriptRenderWindow,
24
24
  resolveAnchorScrollOffset,
25
+ streamingTailMountedGrowth,
26
+ streamingEstimateRows,
27
+ TRANSCRIPT_WINDOW_OVERSCAN_ROWS,
25
28
  upperBound,
26
29
  shiftSelectionRectY,
27
30
  } from './transcript-window.mjs';
@@ -347,6 +350,37 @@ export function useTranscriptWindow({
347
350
  contentHeight: lockViewRows,
348
351
  floatingPanelRows: Number(floatingPanelRows) || 0,
349
352
  };
353
+ // ── Same-frame streaming-tail growth compensation (scrolled-up only) ───────
354
+ // When the user reads older transcript, the row index freezes the trailing
355
+ // streaming item at its last Yoga-confirmed height (defer-growth), but the
356
+ // mounted Box lays out at the live-grown height THIS frame. renderScrollOffset
357
+ // was just resolved against that frozen row index, so the extra physical rows
358
+ // are unaccounted-for and the visible window jumps up one frame until the
359
+ // harvest bumps measuredRowsVersion. Fold the mounted growth delta into the
360
+ // offset NOW so marginBottom slides those extra rows off the bottom and the
361
+ // window holds still. Bottom-pinned/following is stable (flex-end owns it) and
362
+ // is excluded by the scrolledUp gate. No double-compensation next frame: once
363
+ // measuredRowsVersion bumps, the row index absorbs the growth (idEntry.rows ==
364
+ // the new measured height) and the live estimate matches it, so delta → 0.
365
+ if (scrolledUp && !followingRef.current) {
366
+ const growth = streamingTailMountedGrowth(items, frameColumns, toolOutputExpanded);
367
+ // Only compensate when the tail is actually mounted in the rendered slice
368
+ // (viewport + overscan). Off-slice it is represented by a row-index-sized
369
+ // bottom spacer that does NOT physically grow this frame, so shifting the
370
+ // offset there would itself introduce a jump. In-slice its Box grows now.
371
+ // Gate/clamp on the POST-compensation offset: transcriptRenderWindow drops
372
+ // the tail once effectiveScrollOffset >= tailRows + overscan, so adding the
373
+ // full delta could push the offset past that edge and unmount the very row
374
+ // we are compensating for (a reverse jump at the boundary). Cap the applied
375
+ // delta so the final offset keeps the tail mounted; any residual growth left
376
+ // uncompensated sits in the overscan below the viewport (not visible).
377
+ if (growth && growth.delta > 0) {
378
+ const maxOffsetKeepingTailMounted = growth.tailRows + TRANSCRIPT_WINDOW_OVERSCAN_ROWS - 1;
379
+ if (renderScrollOffset <= maxOffsetKeepingTailMounted) {
380
+ renderScrollOffset += Math.min(growth.delta, maxOffsetKeepingTailMounted - renderScrollOffset);
381
+ }
382
+ }
383
+ }
350
384
  const transcriptWindow = useMemo(() => transcriptRenderWindow(items, {
351
385
  scrollOffset: renderScrollOffset,
352
386
  viewportHeight: transcriptContentHeight,
@@ -367,8 +401,11 @@ export function useTranscriptWindow({
367
401
  // CAPTURE for a missing/dirty anchor (above) reads this from the PREVIOUS
368
402
  // frame to reconstruct the exact top-edge row that was on screen, so the
369
403
  // capture matches what the user saw rather than the stale scrollOffset
370
- // state. Bottom-relative, matching transcriptRenderWindow's window math.
371
- renderOffset: Math.max(0, Number(renderScrollOffset) || 0),
404
+ // state. Publish the CLAMPED effective offset (transcriptRenderWindow clamps
405
+ // renderScrollOffset to maxScrollRows internally) so the reconstruction uses
406
+ // the value the window math actually rendered with, not the raw compensated
407
+ // offset. Bottom-relative, matching transcriptRenderWindow's window math.
408
+ renderOffset: Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0),
372
409
  suppressMeasuredRowHeights,
373
410
  };
374
411
  // The window memo is keyed on a structure signature that intentionally
@@ -456,8 +493,24 @@ export function useTranscriptWindow({
456
493
  const variantKey = transcriptItemVariantKey(item);
457
494
  if (isStreamingAssistant) {
458
495
  const idPrev = streamingMeasuredRowsById.get(item.id);
496
+ // Baseline = the estimate for the text at THIS commit's measured state.
497
+ // Refresh it on EVERY harvest pass (not only on an id-store change): if
498
+ // the estimator's row count drifts while the Yoga height is unchanged
499
+ // (or a same-id text reset), a stale baseline would make
500
+ // streamingTailMountedGrowth compensate with no physical growth.
501
+ const estimateAtMeasure = streamingEstimateRows(item, frameColumns, toolOutputExpanded);
459
502
  if (!idPrev || idPrev.rows !== measured || idPrev.columns !== frameColumns || idPrev.toolExpanded !== toolExpandedFlag) {
460
- streamingMeasuredRowsById.set(item.id, { rows: measured, columns: frameColumns, toolExpanded: toolExpandedFlag });
503
+ // Record the estimate for the EXACT text just measured alongside the
504
+ // real Yoga height, so streamingTailMountedGrowth can measure later
505
+ // growth in the estimate metric (live_now − estimateAtMeasure) and the
506
+ // estimator's steady bias cancels instead of leaking into a permanent
507
+ // over-compensation once measuredRowsVersion has absorbed this height.
508
+ streamingMeasuredRowsById.set(item.id, {
509
+ rows: measured,
510
+ columns: frameColumns,
511
+ toolExpanded: toolExpandedFlag,
512
+ estimateRows: estimateAtMeasure,
513
+ });
461
514
  // ALWAYS bump on a real id-store change, bottom-pinned or not.
462
515
  // estimateTranscriptItemRowsCached (transcript-window.mjs) now
463
516
  // returns ONLY this id-store floor for a streaming item (defer-
@@ -470,6 +523,8 @@ export function useTranscriptWindow({
470
523
  // stream settles — invisible growth for anything reading rowIndex
471
524
  // (windowing, maxScrollRows, an anchor captured mid-stream).
472
525
  changed = true;
526
+ } else {
527
+ idPrev.estimateRows = estimateAtMeasure;
473
528
  }
474
529
  }
475
530
  const prev = transcriptMeasuredRowsCache.get(item);
@@ -662,13 +717,18 @@ export function useTranscriptWindow({
662
717
  if (deltaY === 0) return;
663
718
  const clippedRect = withSelectionClip(shiftSelectionRectY(dragRef.current.rect, deltaY));
664
719
  dragRef.current = { ...dragRef.current, rect: clippedRect };
665
- paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
720
+ // rememberText:false — the shifted rect is viewport-clipped, so harvesting
721
+ // here would replace the full selection text remembered at drag-release
722
+ // with only the still-visible fragment (partial Ctrl+C after scrolling).
723
+ paintSelectionRect(clippedRect, { rememberText: false, immediate: true });
666
724
  }, [transcriptContentHeight, transcriptWindow.totalRows, transcriptWindow.effectiveScrollOffset, withSelectionClip, paintSelectionRect]);
667
725
  useEffect(() => {
668
726
  if (!dragRef.current.rect) return;
669
727
  const clippedRect = withSelectionClip(dragRef.current.rect);
670
728
  dragRef.current = { ...dragRef.current, rect: clippedRect };
671
- paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
729
+ // Theme repaint: same cells, same text — no need to re-harvest (and a
730
+ // clipped rect would clobber the remembered full text with a fragment).
731
+ paintSelectionRect(clippedRect, { rememberText: false, immediate: true });
672
732
  }, [themeEpoch, withSelectionClip, paintSelectionRect]);
673
733
  useEffect(() => {
674
734
  const maxRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
@@ -49,10 +49,6 @@ import {
49
49
  isModifiedEnterSequence,
50
50
  isAnyModifiedEnterSequence,
51
51
  } from './prompt-input/edit-helpers.mjs';
52
- import {
53
- VOICE_TICK_MS,
54
- voiceIndicatorTextFor,
55
- } from './prompt-input/voice-indicator.mjs';
56
52
 
57
53
  // Windows Terminal IME composition can clip a glyph that starts exactly at the
58
54
  // left edge of the editable text node. The rounded prompt box already adds a
@@ -100,8 +96,6 @@ export function PromptInput({
100
96
  onRestoreQueued,
101
97
  onHistoryNavigate,
102
98
  onPasteText,
103
- onVoiceToggle,
104
- voiceIndicatorMode = 'off',
105
99
  selectionRef,
106
100
  valueRef,
107
101
  boxRectRef,
@@ -129,27 +123,8 @@ export function PromptInput({
129
123
  const boxRef = useRef(null);
130
124
  const cursorEnabledRef = useRef(false); // latest enabled state, read by the anchor fn at render time
131
125
  const contentWidthRef = useRef(80);
132
- // Cells reserved on the right for the voice indicator glyph(s) (0 when
133
- // hidden, 1 idle/spinner-only, 2 recording's "◉"+braille). Read by the
134
- // cursor-anchor closure below (installed ONCE, so it must read live state
135
- // via a ref rather than close over a render-local variable) to keep the
136
- // caret/wrap math in sync with the actual reserved column, same pattern as
137
- // IME_LEFT_GUARD_COLUMNS on the left edge.
138
- const voiceIndicatorWidthRef = useRef(0);
139
126
  const preferredColumnRef = useRef(null);
140
127
  const mouseExtendCoalesceRef = useRef({ pendingNext: null, timer: null, t: 0 });
141
- // Voice indicator animation tick. Runs a single ~120ms interval ONLY while
142
- // an ANIMATED mode is active (installing/recording/transcribing) — 'idle'
143
- // renders a static glyph and 'off' renders nothing, so neither keeps a
144
- // timer alive (the old idle "breathing" pulse spun constantly whenever
145
- // voice was merely enabled, reading as phantom activity).
146
- const [voiceTick, setVoiceTick] = useState(0);
147
- useEffect(() => {
148
- if (voiceIndicatorMode === 'off' || voiceIndicatorMode === 'idle') return undefined;
149
- const timer = setInterval(() => setVoiceTick((t) => (t + 1) % 1000000), VOICE_TICK_MS);
150
- timer.unref?.();
151
- return () => clearInterval(timer);
152
- }, [voiceIndicatorMode]);
153
128
  // Undo/redo snapshot stack. Each entry is a { value, cursor, selectionAnchor }
154
129
  // draft snapshot. Continuous typing coalesces (see UNDO_COALESCE_MS) so a run
155
130
  // of characters collapses into one undo step; cursor-only moves are NOT
@@ -171,6 +146,7 @@ export function PromptInput({
171
146
  // the parent chain from our box and firing it flushes the new draft in the same
172
147
  // tick. Guarded so a structure change in the ink fork degrades to throttled
173
148
  // (slower) rendering, never a crash.
149
+ const flushThrottleRef = useRef({ lastAt: 0, timer: null });
174
150
  const flushImmediate = () => {
175
151
  let node = boxRef.current;
176
152
  for (let i = 0; node && i < 64; i++) {
@@ -182,6 +158,30 @@ export function PromptInput({
182
158
  }
183
159
  };
184
160
 
161
+ // commitDraft fires on every keystroke; a raw queueMicrotask(flushImmediate)
162
+ // per commit bypassed ink's maxFps and forced an unthrottled render for each
163
+ // char (a burst of paste/held-key edits = a render storm). Coalesce to a
164
+ // frame budget: flush the first keystroke immediately (no caret lag) and
165
+ // batch the rest to one flush per ~16ms, with a trailing flush so the final
166
+ // char of a burst always lands.
167
+ const FLUSH_INTERVAL_MS = 16;
168
+ const scheduleImmediateFlush = () => {
169
+ const t = flushThrottleRef.current;
170
+ const now = Date.now();
171
+ const elapsed = now - t.lastAt;
172
+ if (elapsed >= FLUSH_INTERVAL_MS) {
173
+ if (t.timer !== null) { clearTimeout(t.timer); t.timer = null; }
174
+ t.lastAt = now;
175
+ queueMicrotask(flushImmediate);
176
+ } else if (t.timer === null) {
177
+ t.timer = setTimeout(() => {
178
+ t.timer = null;
179
+ t.lastAt = Date.now();
180
+ flushImmediate();
181
+ }, FLUSH_INTERVAL_MS - elapsed);
182
+ }
183
+ };
184
+
185
185
  const commitDraft = (next, options = {}) => {
186
186
  const sameDraft = draftStateEqual(draftRef.current, next);
187
187
  if (!options.keepPreferredColumn) preferredColumnRef.current = null;
@@ -189,7 +189,7 @@ export function PromptInput({
189
189
  if (!options.skipHistory) recordUndoSnapshot(draftRef.current, next, options);
190
190
  draftRef.current = next;
191
191
  setDraft(next);
192
- queueMicrotask(flushImmediate);
192
+ scheduleImmediateFlush();
193
193
  if (next.value !== lastReportedValueRef.current) {
194
194
  lastReportedValueRef.current = next.value;
195
195
  onDraftChange?.(next.value);
@@ -231,12 +231,7 @@ export function PromptInput({
231
231
  const d = draftRef.current;
232
232
  const w = yogaNode?.getComputedWidth?.() ?? 0;
233
233
  const guardColumns = w > IME_LEFT_GUARD_COLUMNS ? IME_LEFT_GUARD_COLUMNS : 0;
234
- // Reserve the voice-indicator's own cells (+1 gap column when visible)
235
- // out of the SAME width the caret/wrap math uses, mirroring the left
236
- // guard above — otherwise a visible indicator would silently overlap
237
- // the caret/typed text at the right edge instead of pushing content in.
238
- const voiceReserve = voiceIndicatorWidthRef.current > 0 ? voiceIndicatorWidthRef.current + 1 : 0;
239
- const contentWidth = Math.max(1, (w ? w - guardColumns - voiceReserve : contentWidthRef.current) || 80);
234
+ const contentWidth = Math.max(1, (w ? w - guardColumns : contentWidthRef.current) || 80);
240
235
  contentWidthRef.current = contentWidth;
241
236
  const caret = w > 0
242
237
  // PromptInput renders a trailing space cell when the cursor is at
@@ -962,21 +957,15 @@ export function PromptInput({
962
957
  return;
963
958
  }
964
959
 
965
- // Ctrl+Space voice-record toggle. Two encodings observed across
966
- // terminals: a lone NUL byte (legacy Ctrl+Space, many terminals send
967
- // 0x00 directly) or the kitty CSI-u form `\x1b[32;5u` (codepoint 32
968
- // = space, modifier 5 = 1+4 = ctrl). Consumed here — never typed into
969
- // the prompt — and forwarded to the App-level recorder state machine
970
- // via onVoiceToggle; a no-op when the prop isn't wired (e.g. embedded
971
- // pickers) so this never throws.
972
- if (rawInput === '\x00' || /^(?:\x1b)?\[32;5u$/.test(rawInput)) {
973
- onVoiceToggle?.();
974
- return;
975
- }
976
-
977
960
  // Printable input (ignore other control keys). Strip any embedded SGR mouse
978
961
  // sequences as a belt-and-suspenders guard (the early return above catches
979
962
  // whole-sequence inputs; this removes partials that rode in with real text).
963
+ // Swallow Ctrl+Space raw encodings (lone NUL, or the kitty CSI-u form
964
+ // \x1b[32;5u) as a no-op so they never fall through into the prompt as
965
+ // garbage. No voice behavior — just discarded.
966
+ if (rawInput === '\x00' || /^(?:\x1b)?\[32;5u$/.test(rawInput)) {
967
+ return;
968
+ }
980
969
  const printable = rawInput
981
970
  .replace(/(?:\x1b)?\[<\d+;\d+;\d+[Mm]/g, '')
982
971
  .replace(/[\r\n]/g, '');
@@ -1019,15 +1008,6 @@ export function PromptInput({
1019
1008
  const displayValue = mask ? value.replace(/[^\n]/g, '*') : value;
1020
1009
  const renderedValue = renderSelectedText(displayValue, selectionRange(draft), cursor === value.length);
1021
1010
  const hintMeta = hintStyle(hintTone);
1022
- // Monotone voice indicator glyph text — zero-width (rendered as null) when
1023
- // mode is 'off'. Braille frame drives installing/recording/transcribing;
1024
- // 'idle' is a static ○ (no animation, no timer).
1025
- const voiceIndicatorText = voiceIndicatorTextFor(voiceIndicatorMode, voiceTick);
1026
- // stringWidth is unnecessary here — every glyph above is a single
1027
- // 1-column codepoint (○ ● and the braille frames), and 'recording'
1028
- // concatenates exactly two 1-cell glyphs, so plain .length gives the
1029
- // correct cell count without pulling in emoji-width special-casing.
1030
- voiceIndicatorWidthRef.current = voiceIndicatorText.length;
1031
1011
 
1032
1012
  return (
1033
1013
  <Box ref={boxRef} flexDirection="row" width="100%" flexGrow={1} flexShrink={1} backgroundColor={surfaceBackground()}>
@@ -1038,17 +1018,6 @@ export function PromptInput({
1038
1018
  <Text color={hintMeta.textColor}>{hint}</Text>
1039
1019
  </Box>
1040
1020
  ) : null}
1041
- {voiceIndicatorText ? (
1042
- <>
1043
- {/* flexGrow spacer pins the indicator to the RIGHT edge of the row
1044
- even when the typed text is short; without it the glyph would
1045
- ride directly after the caret instead of the box edge. */}
1046
- <Box flexGrow={1} backgroundColor={surfaceBackground()} />
1047
- <Box flexShrink={0} width={voiceIndicatorText.length + 1} justifyContent="flex-end" backgroundColor={surfaceBackground()}>
1048
- <Text color={theme.subtle}>{voiceIndicatorText}</Text>
1049
- </Box>
1050
- </>
1051
- ) : null}
1052
1021
  </Box>
1053
1022
  );
1054
1023
  }
@@ -254,7 +254,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
254
254
  // Keep the aggregate card at a fixed height (header + one detail row) for
255
255
  // its whole lifecycle. Pending cards have no result yet, so reserve the
256
256
  // detail row up front instead of growing from 1→2 rows when the summary
257
- // lands on completion — that late row push is the "줄 튐" jump. The empty
257
+ // lands on completion — that late row push is the "line-jump" jump. The empty
258
258
  // placeholder renders as a blank line under the ⎿ gutter; the final summary
259
259
  // simply fills it in place. This matches estimateTranscriptItemRows (always
260
260
  // 2 + resultRows), so windowing/scroll stay in lockstep too.
@@ -381,7 +381,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
381
381
  // (collapsedDetail = ''), so the card rendered as a single header row. But
382
382
  // estimateTranscriptItemRows() in App.jsx reserves 2 rows for a collapsed
383
383
  // non-aggregate tool (1 only for skill surfaces). That left a 1-row gap that
384
- // closed the instant the result landed — the surviving "". Reserve the same
384
+ // closed the instant the result landed — the surviving "line-jump". Reserve the same
385
385
  // dim placeholder detail row the aggregate card uses (`Running`) for the whole
386
386
  // pending lifecycle so the height stays fixed at header + one detail row and
387
387
  // the final summary just fills in place. Skill surfaces collapse to a single