mixdog 0.9.94 → 0.9.96

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 (259) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +109 -0
  5. package/package.json +26 -13
  6. package/scripts/.tmp-coverage-report.mjs +96 -0
  7. package/scripts/build-tui.mjs +17 -1
  8. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  9. package/scripts/lib/isolated-root-cleanup.mjs +19 -0
  10. package/scripts/run-suite.mjs +100 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/lib/rules-builder.cjs +4 -3
  13. package/src/output-styles/detailed.md +13 -15
  14. package/src/output-styles/extreme-minimal.md +7 -11
  15. package/src/output-styles/minimal.md +4 -7
  16. package/src/output-styles/simple.md +11 -13
  17. package/src/rules/agent/30-explorer.md +7 -4
  18. package/src/rules/lead/01-general.md +1 -2
  19. package/src/rules/lead/lead-brief.md +4 -5
  20. package/src/rules/lead/lead-tool.md +3 -2
  21. package/src/rules/shared/01-tool.md +20 -35
  22. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  23. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  24. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  26. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +13 -2
  27. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
  28. package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
  29. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  30. package/src/runtime/agent/orchestrator/mcp/client.mjs +29 -14
  31. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  33. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
  34. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
  35. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  38. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
  39. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  40. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  41. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  42. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
  43. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
  44. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  45. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  46. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +29 -16
  47. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  49. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
  50. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  51. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
  52. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +13 -14
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
  54. package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
  55. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +82 -22
  56. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  57. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  58. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +40 -36
  59. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
  61. package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
  62. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +5 -33
  63. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
  64. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
  65. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  66. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
  67. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  68. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  69. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
  70. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
  71. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -4
  72. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  73. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
  74. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +168 -83
  75. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  76. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
  77. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
  79. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +34 -18
  80. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  81. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  82. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +89 -11
  83. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
  84. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +6 -39
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -6
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -4
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +56 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +124 -19
  90. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  91. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  92. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  93. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +7 -16
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +14 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
  100. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
  101. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
  102. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
  103. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +24 -10
  104. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  105. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +20 -43
  106. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +76 -3
  107. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +13 -1
  108. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -5
  109. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +38 -13
  110. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  111. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -2
  113. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +8 -6
  114. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +138 -25
  115. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
  116. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
  117. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
  118. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  119. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
  120. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  121. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +2 -2
  122. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -2
  123. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
  124. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +177 -16
  125. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +21 -1
  126. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +217 -25
  127. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
  128. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +14 -15
  129. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +60 -8
  130. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  131. package/src/runtime/channels/backends/discord.mjs +5 -14
  132. package/src/runtime/channels/backends/telegram.mjs +0 -5
  133. package/src/runtime/channels/lib/config.mjs +2 -2
  134. package/src/runtime/channels/lib/inbound-handler.mjs +1 -2
  135. package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
  136. package/src/runtime/channels/lib/scheduler.mjs +3 -3
  137. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  138. package/src/runtime/media/renditions.mjs +21 -2
  139. package/src/runtime/memory/index.mjs +5 -38
  140. package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
  141. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  142. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  143. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
  144. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  145. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  146. package/src/runtime/memory/lib/http-router.mjs +8 -0
  147. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  148. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  149. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  150. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  151. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  152. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  153. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  154. package/src/runtime/memory/tool-defs.mjs +7 -11
  155. package/src/runtime/search/tool-defs.mjs +2 -2
  156. package/src/runtime/shared/agent-route-config.mjs +119 -0
  157. package/src/runtime/shared/atomic-file.mjs +133 -4
  158. package/src/runtime/shared/background-tasks.mjs +6 -3
  159. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  160. package/src/runtime/shared/child-guardian.mjs +204 -108
  161. package/src/runtime/shared/child-spawn-gate.mjs +60 -31
  162. package/src/runtime/shared/config.mjs +130 -44
  163. package/src/runtime/shared/resource-admission.mjs +146 -58
  164. package/src/runtime/shared/stream-progress.mjs +6 -0
  165. package/src/runtime/shared/tool-status.mjs +10 -1
  166. package/src/runtime/shared/tool-surface.mjs +2 -7
  167. package/src/runtime/shared/turn-snapshot.mjs +411 -21
  168. package/src/runtime/shared/turn-worktree-snapshot.mjs +552 -0
  169. package/src/session-runtime/config-helpers.mjs +39 -37
  170. package/src/session-runtime/context-status.mjs +9 -3
  171. package/src/session-runtime/lifecycle-api.mjs +35 -5
  172. package/src/session-runtime/mcp-glue.mjs +11 -6
  173. package/src/session-runtime/model-route-api.mjs +7 -5
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +94 -43
  176. package/src/session-runtime/runtime-core.mjs +57 -19
  177. package/src/session-runtime/runtime-tunables.mjs +4 -0
  178. package/src/session-runtime/self-update.mjs +33 -4
  179. package/src/session-runtime/session-lifecycle.mjs +11 -2
  180. package/src/session-runtime/session-text.mjs +2 -1
  181. package/src/session-runtime/session-turn-api.mjs +139 -23
  182. package/src/session-runtime/settings-api.mjs +5 -17
  183. package/src/session-runtime/tool-defs.mjs +3 -3
  184. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  185. package/src/session-runtime/workflow.mjs +25 -28
  186. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  187. package/src/standalone/agent-tool/helpers.mjs +2 -2
  188. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  189. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  190. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  191. package/src/standalone/agent-tool.mjs +4 -4
  192. package/src/standalone/backend-daemon.mjs +631 -0
  193. package/src/standalone/channel-admin.mjs +12 -12
  194. package/src/standalone/channel-daemon-client.mjs +17 -1
  195. package/src/standalone/channel-daemon-transport.mjs +231 -9
  196. package/src/standalone/channel-worker.mjs +3 -2
  197. package/src/standalone/engine-daemon-client.mjs +1069 -0
  198. package/src/standalone/engine-daemon-protocol.mjs +32 -0
  199. package/src/standalone/engine-daemon-service.mjs +1108 -0
  200. package/src/standalone/engine-daemon-transport.mjs +794 -0
  201. package/src/standalone/explore-tool.mjs +187 -70
  202. package/src/standalone/fair-call-scheduler.mjs +264 -0
  203. package/src/standalone/session-protocol.mjs +188 -0
  204. package/src/tui/App.jsx +92 -64
  205. package/src/tui/app/app-format.mjs +4 -2
  206. package/src/tui/app/app-view.jsx +10 -2
  207. package/src/tui/app/channel-pickers.mjs +7 -6
  208. package/src/tui/app/core-memory-picker.mjs +20 -20
  209. package/src/tui/app/doctor.mjs +5 -11
  210. package/src/tui/app/extension-pickers.mjs +20 -18
  211. package/src/tui/app/maintenance-pickers.mjs +27 -27
  212. package/src/tui/app/message-selector.mjs +103 -0
  213. package/src/tui/app/onboarding-steps.mjs +24 -20
  214. package/src/tui/app/project-picker.mjs +80 -56
  215. package/src/tui/app/prompt-submit.mjs +59 -43
  216. package/src/tui/app/resume-picker.mjs +2 -2
  217. package/src/tui/app/route-pickers.mjs +16 -9
  218. package/src/tui/app/settings-picker.mjs +60 -65
  219. package/src/tui/app/slash-dispatch.mjs +32 -28
  220. package/src/tui/app/transcript-window.mjs +57 -0
  221. package/src/tui/app/usage-context-panels.mjs +22 -7
  222. package/src/tui/app/use-mouse-input.mjs +25 -3
  223. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  224. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  225. package/src/tui/app/use-prompt-hint.mjs +3 -2
  226. package/src/tui/app/use-prompt-queue-history.mjs +39 -17
  227. package/src/tui/app/use-transcript-scroll.mjs +39 -10
  228. package/src/tui/app/use-transcript-window.mjs +62 -14
  229. package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
  230. package/src/tui/components/PromptInput.jsx +81 -38
  231. package/src/tui/components/Spinner.jsx +89 -95
  232. package/src/tui/components/TextEntryPanel.jsx +14 -0
  233. package/src/tui/components/ToolExecution.jsx +2 -2
  234. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  235. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  236. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  237. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  238. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  239. package/src/tui/dist/index.mjs +1694 -10633
  240. package/src/tui/engine/agent-job-feed.mjs +2 -2
  241. package/src/tui/engine/live-share.mjs +110 -3
  242. package/src/tui/engine/session-api-ext.mjs +24 -5
  243. package/src/tui/engine/session-api.mjs +165 -55
  244. package/src/tui/engine/session-flow.mjs +50 -5
  245. package/src/tui/engine/tool-card-results.mjs +10 -2
  246. package/src/tui/engine/tool-result-text.mjs +10 -0
  247. package/src/tui/engine/turn.mjs +11 -64
  248. package/src/tui/engine-local-session.mjs +1124 -0
  249. package/src/tui/engine.mjs +16 -1065
  250. package/src/tui/index.jsx +41 -4
  251. package/src/tui/markdown/stream-fence.mjs +1 -1
  252. package/src/tui/spinner-meta.mjs +80 -0
  253. package/src/tui/spinner-verbs.mjs +83 -0
  254. package/src/ui/statusline-segments.mjs +43 -10
  255. package/src/ui/statusline.mjs +10 -1
  256. package/scripts/tmp-cdp-errors.mjs +0 -41
  257. package/scripts/tmp-cdp-inspect.mjs +0 -41
  258. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
  259. package/src/standalone/channel-daemon.mjs +0 -226
@@ -53,6 +53,7 @@ import {
53
53
  cancelPromptImmediateFlush,
54
54
  schedulePromptImmediateFlush,
55
55
  } from './prompt-input/immediate-render.mjs';
56
+ import { classifyPromptEscape } from './prompt-input/escape-policy.mjs';
56
57
 
57
58
  // Windows Terminal IME composition can clip a glyph that starts exactly at the
58
59
  // left edge of the editable text node. The rounded prompt box already adds a
@@ -86,6 +87,7 @@ export function PromptInput({
86
87
  interruptActive = false,
87
88
  onInterrupt,
88
89
  commandPaletteActive = false,
90
+ commandPaletteOpen = commandPaletteActive,
89
91
  mask = false,
90
92
  hint = '',
91
93
  hintTone = 'info',
@@ -98,6 +100,8 @@ export function PromptInput({
98
100
  onCommandPaletteCancel,
99
101
  onCommandPaletteComplete,
100
102
  onRestoreQueued,
103
+ hasQueuedMessages = false,
104
+ hasMessages = false,
101
105
  onHistoryNavigate,
102
106
  onPasteText,
103
107
  selectionRef,
@@ -112,13 +116,16 @@ export function PromptInput({
112
116
  });
113
117
  const [, bumpCursorAnchorEpoch] = useState(0);
114
118
  const draftRef = useRef(draft);
115
- const interruptActiveRef = useRef(interruptActive);
116
119
  const lastReportedValueRef = useRef(draft.value);
117
120
  // Bumped on every submit/draftOverride replace/unmount so an async paste
118
121
  // (clipboard read or onPasteText promise) that resolves after the draft
119
122
  // moved on can detect staleness and drop its result instead of mutating
120
123
  // whatever prompt is now in the box.
121
124
  const pasteGenerationRef = useRef(0);
125
+ // One physical Enter must not enqueue twice when the terminal delivers the
126
+ // chord as multiple input events before the draft clears (Windows CR/LF).
127
+ const submitGateRef = useRef(false);
128
+ const escapeClearAtRef = useRef(0);
122
129
  if (valueRef) valueRef.current = draftRef.current.value;
123
130
  const { isRawModeSupported } = useStdin();
124
131
  // The text box's ink DOM node. We mark it as the cursor anchor (forked ink
@@ -138,7 +145,6 @@ export function PromptInput({
138
145
  const undoRef = useRef({ past: [], future: [], lastPushAt: 0, lastValue: null });
139
146
  const { value, cursor } = draft;
140
147
  draftRef.current = draft;
141
- interruptActiveRef.current = interruptActive;
142
148
  if (selectionRef) {
143
149
  const range = selectionRange(draft);
144
150
  selectionRef.current = range
@@ -171,14 +177,12 @@ export function PromptInput({
171
177
  }
172
178
  };
173
179
 
174
- // During an active turn, ink is already painting streaming updates at its
175
- // 60fps budget. A second unthrottled input render competes with those frames
176
- // and makes key echo slower, so busy input stays on ink's normal render path.
177
- // Idle input keeps the leading+trailing immediate flush for crisp caret echo.
180
+ // Keep prompt echo on the same leading+trailing immediate cadence while a
181
+ // turn is active. Claude Code keeps mid-turn typing responsive; suppressing
182
+ // this path made the draft visibly trail the keyboard under streaming load.
178
183
  const scheduleImmediateFlush = () => {
179
184
  schedulePromptImmediateFlush({
180
185
  throttle: flushThrottleRef.current,
181
- isSuppressed: () => interruptActiveRef.current,
182
186
  flush: flushImmediate,
183
187
  });
184
188
  };
@@ -188,6 +192,7 @@ export function PromptInput({
188
192
  }, []);
189
193
 
190
194
  const commitDraft = (next, options = {}) => {
195
+ escapeClearAtRef.current = 0;
191
196
  const sameDraft = draftStateEqual(draftRef.current, next);
192
197
  if (!options.keepPreferredColumn) preferredColumnRef.current = null;
193
198
  if (sameDraft) {
@@ -446,8 +451,12 @@ export function PromptInput({
446
451
  return true;
447
452
  };
448
453
 
449
- const restoreQueuedToDraft = () => {
450
- return onRestoreQueued?.(draftRef.current.value) === true;
454
+ const restoreQueuedToDraft = ({ showHint = false } = {}) => {
455
+ return onRestoreQueued?.({
456
+ restoreDraft: true,
457
+ showHint,
458
+ currentText: draftRef.current.value,
459
+ }) === true;
451
460
  };
452
461
 
453
462
  const applyHistoryNavigation = (direction, meta = {}) => {
@@ -504,15 +513,22 @@ export function PromptInput({
504
513
  }, [selectionRef]);
505
514
 
506
515
  const submitDraft = (next) => {
516
+ if (submitGateRef.current) return;
507
517
  const text = next.value;
518
+ if (!String(text || '').trim()) return;
519
+ submitGateRef.current = true;
508
520
  const accepted = onSubmit?.(text) !== false;
509
521
  if (!accepted) {
522
+ submitGateRef.current = false;
510
523
  commitDraft(next);
511
524
  return;
512
525
  }
513
526
  pasteGenerationRef.current += 1;
514
527
  commitDraft({ value: '', cursor: 0, selectionAnchor: null }, { skipHistory: true });
515
528
  resetUndo();
529
+ // Unlock after this input batch drains so a second return event in the
530
+ // same chord cannot re-submit the pre-clear draft.
531
+ queueMicrotask(() => { submitGateRef.current = false; });
516
532
  };
517
533
 
518
534
  const submitEnterChunk = (prefix = '') => {
@@ -542,6 +558,7 @@ export function PromptInput({
542
558
 
543
559
  const rawInput = String(input ?? '');
544
560
  const inputKey = rawInput.toLowerCase();
561
+ if (!key.escape) escapeClearAtRef.current = 0;
545
562
  const rawShiftArrowForGrid =
546
563
  rawInput === '\x1b[1;2A' || rawInput === '\x1b[a' || rawInput === '[1;2A'
547
564
  || rawInput === '\x1b[1;2B' || rawInput === '\x1b[b' || rawInput === '[1;2B'
@@ -618,8 +635,8 @@ export function PromptInput({
618
635
  const lineBreakIndex = rawInput.search(/[\r\n]/);
619
636
  const rawEnter = rawInput === '\r' || rawInput === '\n' || rawInput === '\r\n';
620
637
  const trailingEnterPrefix = singleTrailingLineBreakPrefix(rawInput);
621
- const rawCtrlEnter = isModifiedEnterSequence(rawInput);
622
- const modifiedLineBreak = key.shift || key.meta || key.ctrl || rawCtrlEnter;
638
+ const rawModifiedEnter = isModifiedEnterSequence(rawInput);
639
+ const modifiedLineBreak = key.shift || key.meta || key.ctrl || rawModifiedEnter;
623
640
 
624
641
  // Ctrl+J is the protocol-INDEPENDENT newline that works on every terminal.
625
642
  // • Legacy / modifyOtherKeys terminals: Ctrl+J is a lone '\n' (0x0A). A real
@@ -636,12 +653,9 @@ export function PromptInput({
636
653
  return;
637
654
  }
638
655
 
639
- // A modified Enter that is NOT a newline chord (e.g. Alt+Enter \x1b[13;3u or
640
- // \x1b[27;3;13~). isModifiedEnterSequence already handled shift/ctrl above
641
- // (→ newline); here we CONSUME any other modified Enter so its raw CSI bytes
642
- // never type into the prompt under modifyOtherKeys. Plain Enter (mod=1) is
643
- // not matched and still submits below.
644
- if (!rawCtrlEnter && isAnyModifiedEnterSequence(rawInput)) {
656
+ // Consume uncommon modified-Enter combinations outside the normal
657
+ // Shift/Alt/Ctrl newline set so raw CSI bytes never enter the prompt.
658
+ if (!rawModifiedEnter && isAnyModifiedEnterSequence(rawInput)) {
645
659
  return;
646
660
  }
647
661
 
@@ -666,7 +680,7 @@ export function PromptInput({
666
680
  return;
667
681
  }
668
682
 
669
- if (rawCtrlEnter) {
683
+ if (rawModifiedEnter) {
670
684
  updateDraft((d) => replaceSelection(d, '\n'));
671
685
  return;
672
686
  }
@@ -735,12 +749,10 @@ export function PromptInput({
735
749
  if (!moveDraftVertically(-1, { extend: true })) {
736
750
  updateDraft((d) => moveCursor(d, 0, { extend: true }));
737
751
  }
738
- } else {
739
- const hasDraftText = String(draftRef.current.value || '').trim().length > 0;
740
- if (!hasDraftText) {
741
- if (!restoreQueuedToDraft()) applyHistoryNavigation('up', { emptyDraft: true });
742
- } else if (!moveDraftVertically(-1, { extend: false })) {
743
- applyHistoryNavigation('up', { emptyDraft: false });
752
+ } else if (!moveDraftVertically(-1, { extend: false })) {
753
+ const emptyDraft = String(draftRef.current.value || '').length === 0;
754
+ if (!hasQueuedMessages || !restoreQueuedToDraft()) {
755
+ applyHistoryNavigation('up', { emptyDraft });
744
756
  }
745
757
  }
746
758
  }
@@ -758,7 +770,7 @@ export function PromptInput({
758
770
  updateDraft((d) => moveCursor(d, d.value.length, { extend: true }));
759
771
  }
760
772
  } else if (!moveDraftVertically(1, { extend: false })) {
761
- applyHistoryNavigation('down', { emptyDraft: String(draftRef.current.value || '').trim().length === 0 });
773
+ applyHistoryNavigation('down', { emptyDraft: String(draftRef.current.value || '').length === 0 });
762
774
  }
763
775
  }
764
776
  return;
@@ -796,9 +808,8 @@ export function PromptInput({
796
808
  }
797
809
 
798
810
  if (key.escape) {
799
- if (commandPaletteActive) {
811
+ if (commandPaletteOpen) {
800
812
  onCommandPaletteCancel?.(draftRef.current.value);
801
- commitDraft({ value: '', cursor: 0, selectionAnchor: null });
802
813
  return;
803
814
  }
804
815
  if (selectionRange(draftRef.current)) {
@@ -809,23 +820,55 @@ export function PromptInput({
809
820
  if (onEscape?.(currentValue, { phase: 'before' }) === true) {
810
821
  return;
811
822
  }
812
- if (currentValue) {
823
+ let escape = classifyPromptEscape({
824
+ interruptActive,
825
+ hasQueuedMessages,
826
+ hasMessages,
827
+ value: currentValue,
828
+ lastClearPressAt: escapeClearAtRef.current,
829
+ });
830
+ if (escape.action === 'restore-queue') {
831
+ if (restoreQueuedToDraft()) {
832
+ escapeClearAtRef.current = 0;
833
+ return;
834
+ }
835
+ // A stale projected queue can empty between render and key handling.
836
+ // Fall through to the normal draft/idle action in that case.
837
+ escape = classifyPromptEscape({
838
+ interruptActive,
839
+ hasMessages,
840
+ value: currentValue,
841
+ lastClearPressAt: escapeClearAtRef.current,
842
+ });
843
+ }
844
+ escapeClearAtRef.current = escape.nextClearPressAt;
845
+ // Active work always wins, even if the user has already typed a steering
846
+ // draft. The draft is preserved; the old submitted prompt is restored only
847
+ // when this box is still empty after cancellation.
848
+ if (escape.action === 'interrupt') {
849
+ const restoredText = onInterrupt?.(currentValue);
850
+ if (!currentValue && typeof restoredText === 'string') {
851
+ commitDraft({ value: restoredText, cursor: restoredText.length, selectionAnchor: null });
852
+ }
853
+ return;
854
+ }
855
+ if (escape.action === 'arm-clear') {
856
+ onEscape?.(currentValue, { phase: 'clear-arm' });
857
+ return;
858
+ }
859
+ if (escape.action === 'clear') {
813
860
  onEscape?.(currentValue, { phase: 'clear' });
814
861
  commitDraft({ value: '', cursor: 0, selectionAnchor: null });
815
862
  return;
816
863
  }
817
- // Active turn takes precedence over queue restore: Esc during a
818
- // running turn interrupts the
819
- // turn and leaves queued steering prompts intact to run afterward. Queued
820
- // messages only pop back into the draft when the turn is idle.
821
- if (interruptActive) {
822
- const restoredText = onInterrupt?.('');
823
- if (typeof restoredText === 'string') {
824
- commitDraft({ value: restoredText, cursor: restoredText.length, selectionAnchor: null });
825
- }
864
+ // Empty draft + conversation history: first press arms, the second
865
+ // opens the message selector (Claude Code's jump-back list).
866
+ if (escape.action === 'arm-select') {
867
+ onEscape?.('', { phase: 'select-arm' });
826
868
  return;
827
869
  }
828
- if (restoreQueuedToDraft()) {
870
+ if (escape.action === 'message-selector') {
871
+ onEscape?.('', { phase: 'select' });
829
872
  return;
830
873
  }
831
874
  onEscape?.('', { phase: 'empty' });
@@ -21,7 +21,8 @@ import React, { useRef } from 'react';
21
21
  import { Box, Text } from 'ink';
22
22
  import { useSharedTick } from '../hooks/useSharedTick.mjs';
23
23
  import { theme } from '../theme.mjs';
24
- import { SPINNER_FRAMES } from '../spinner-verbs.mjs';
24
+ import { SPINNER_FRAMES, SPINNER_MODE_OVERRIDE_VERBS, spinnerVerbFor } from '../spinner-verbs.mjs';
25
+ import { buildSpinnerMeta, isReducedMotion } from '../spinner-meta.mjs';
25
26
  import { DOWN_ARROW, UP_ARROW } from '../figures.mjs';
26
27
  import { formatDuration } from '../time-format.mjs';
27
28
 
@@ -32,38 +33,23 @@ const FRAMES = [...SPINNER_FRAMES, ...[...SPINNER_FRAMES].reverse()];
32
33
  // Stall: response must grow within this window or the glyph reddens.
33
34
  const STALL_TIMEOUT_MS = 3000;
34
35
  const STALL_FADE_MS = 2000; // fade red over 2s
35
- // Hide elapsed/token meta on short turns unless verbose/teammates
36
- // are active. Mixdog has no spinner verbose/teammate row here, so mirror the
37
- // default 30s threshold.
38
- const SHOW_TOKENS_AFTER_MS = 30_000;
36
+ // Reduced motion: the glyph freezes, the shimmer stops and the tick drops to
37
+ // one second so only the timer keeps moving.
38
+ const REDUCED_MOTION_TICK_MS = 1000;
39
+ // A running turn always states how to stop it.
40
+ const INTERRUPT_HINT = 'esc to interrupt';
39
41
  // Thinking shimmer starts after this delay.
40
42
  const THINKING_DELAY_MS = 3000;
41
43
 
42
44
  // One-way shimmer. The tail runs past the final character before restarting.
43
45
  const GLIMMER_SPEED_MS = { requesting: 70, reconnecting: 70, compacting: 120, 'auto-clear': 120, resuming: 120, 'tool-use': 120, responding: 120, thinking: 120, 'tool-input': 120 };
44
- const GLIMMER_TRAIL = 4;
46
+ // A wider trail turns the highlight into a wipe travelling across the phrase
47
+ // instead of a single bright character hopping along it.
48
+ const GLIMMER_TRAIL = 6;
45
49
  const THINKING_GLIMMER_SPEED_MS = 120;
46
50
  const THINKING_GLIMMER_TRAIL = 4;
47
- // Fixed 30s cadence (user decision): the verb rotates deterministically every
48
- // 30 seconds instead of the earlier randomized 10–20s / 65% coin flip that
49
- // often never visibly fired.
50
- const VERB_ROTATE_MIN_MS = 30000;
51
- const VERB_ROTATE_SPREAD_MS = 0;
52
- const VERB_CHANGE_PROBABILITY = 1;
53
-
54
- const MODE_VERBS = {
55
- requesting: ['Requesting'],
56
- compacting: ['Compacting conversation'],
57
- 'auto-clear': ['Auto-clearing conversation'],
58
- resuming: ['Resuming conversation'],
59
- // Conservative phrasing (user decision): each pool only rotates through
60
- // synonyms of what the mode ACTUALLY means — never a concrete action the
61
- // engine may not be doing (no "Checking files" during a web search).
62
- thinking: ['Thinking', 'Considering', 'Organizing'],
63
- 'tool-use': ['Working', 'Running tools', 'Reviewing output'],
64
- 'tool-input': ['Working', 'Running tools', 'Reviewing output'],
65
- responding: ['Writing', 'Wrapping up'],
66
- };
51
+ // The phrase itself comes from the shared pool in spinner-verbs.mjs — see
52
+ // spinnerVerbFor(). Mode only drives shimmer speed, token glyph and stall tint.
67
53
 
68
54
  function interpolateColor(a, b, t) {
69
55
  return {
@@ -126,38 +112,9 @@ function spinnerRgb() {
126
112
  };
127
113
  }
128
114
 
129
- const compactNumberFormatter = new Intl.NumberFormat('en-US', {
130
- notation: 'compact',
131
- maximumFractionDigits: 1,
132
- minimumFractionDigits: 0,
133
- });
134
-
135
- function formatNumber(n) {
136
- const value = Math.max(0, Number(n || 0));
137
- if (value >= 1000) return compactNumberFormatter.format(value).toLowerCase();
138
- return String(Math.round(value));
139
- }
140
-
141
115
  const STATUS_SEP = ' · ';
142
116
  const SEP_WIDTH = STATUS_SEP.length;
143
117
 
144
- function stableModeVerb(mode, fallback) {
145
- const phrases = MODE_VERBS[mode] || [fallback || 'Working'];
146
- return phrases[0] || fallback || 'Working';
147
- }
148
-
149
- function nextVerbCheckAt(now) {
150
- return now + VERB_ROTATE_MIN_MS + Math.floor(Math.random() * VERB_ROTATE_SPREAD_MS);
151
- }
152
-
153
- function chooseNextVerb(mode, fallback, current) {
154
- const phrases = MODE_VERBS[mode] || [fallback || 'Working'];
155
- if (phrases.length <= 1 || Math.random() > VERB_CHANGE_PROBABILITY) return current || phrases[0];
156
- const candidates = phrases.filter((phrase) => phrase && phrase !== current);
157
- if (!candidates.length) return current || phrases[0];
158
- return candidates[Math.floor(Math.random() * candidates.length)];
159
- }
160
-
161
118
  // Token-direction glyph by mode:
162
119
  // up while uploading the request, down while receiving a response, and no
163
120
  // glyph at all for non-streaming modes (compacting/resuming/auto-clear) where
@@ -176,24 +133,44 @@ function tokenModeGlyph(mode) {
176
133
  }
177
134
  }
178
135
 
179
- export function Spinner({ verb = 'Working', startedAt, outputTokens = 0, tokens = 0, thinking = false, thinkingActiveSince = 0, mode = 'responding', columns = 80, marginTop = 1 }) {
136
+ export function Spinner({ verb = 'Working', startedAt, outputTokens = 0, tokens = 0, thinking = false, thinkingActiveSince = 0, thinkingMs = 0, effort = '', hasActiveTools = false, paused = false, interruptible = false, mode = 'responding', columns = 80, marginTop = 1 }) {
137
+ const reducedMotion = isReducedMotion();
180
138
  // Re-render at the frame cadence off the shared tick (no dedicated timer).
181
139
  // Glyph/shimmer/token/elapsed values are all derived from Date.now() below.
182
- useSharedTick(FRAME_MS);
140
+ useSharedTick(reducedMotion ? REDUCED_MOTION_TICK_MS : FRAME_MS);
183
141
  const { TEXT_RGB, SHIMMER_RGB, SPINNER_GLYPH_RGB, THINKING_INACTIVE, THINKING_SHIMMER, STALL_RGB } = spinnerRgb();
184
142
  const now = Date.now();
185
- const elapsedMs = startedAt ? Math.max(0, now - startedAt) : 0;
186
- const frame = Math.floor(elapsedMs / FRAME_MS);
143
+ // Animation clock: raw wall time since the turn began. It never pauses, so a
144
+ // frozen timer (below) still animates instead of looking hung.
145
+ const rawElapsedMs = startedAt ? Math.max(0, now - startedAt) : 0;
146
+ const frame = reducedMotion ? 0 : Math.floor(rawElapsedMs / FRAME_MS);
187
147
  const lastGrowRef = useRef(now);
188
148
  const lastTokensRef = useRef(0);
189
149
  const displayedOutputRef = useRef(0);
190
- const displayVerbRef = useRef('');
191
- const displayVerbModeRef = useRef('');
192
- const nextVerbCheckRef = useRef(0);
150
+ // Pause accounting: time spent waiting on the USER (a tool approval prompt)
151
+ // is not turn time. Without this the reported duration counts however long
152
+ // the prompt sat unanswered and the short-turn token gate opens on it.
153
+ const turnAnchorRef = useRef(startedAt);
154
+ const pausedTotalRef = useRef(0);
155
+ const pauseStartRef = useRef(0);
193
156
  // Stall smoothing refs (exponential fade)
194
157
  const stallSmoothRef = useRef(0);
195
158
  const lastStallTickRef = useRef(0);
196
159
 
160
+ if (turnAnchorRef.current !== startedAt) {
161
+ turnAnchorRef.current = startedAt;
162
+ pausedTotalRef.current = 0;
163
+ pauseStartRef.current = 0;
164
+ }
165
+ if (paused && !pauseStartRef.current) {
166
+ pauseStartRef.current = now;
167
+ } else if (!paused && pauseStartRef.current) {
168
+ pausedTotalRef.current += Math.max(0, now - pauseStartRef.current);
169
+ pauseStartRef.current = 0;
170
+ }
171
+ const pausedMs = pausedTotalRef.current + (pauseStartRef.current ? Math.max(0, now - pauseStartRef.current) : 0);
172
+ const elapsedMs = Math.max(0, rawElapsedMs - pausedMs);
173
+
197
174
  const targetOutputTokens = Math.max(0, Number(outputTokens || tokens || 0));
198
175
 
199
176
  // Stall detection — track output growth, because input usually arrives as one
@@ -203,8 +180,14 @@ export function Spinner({ verb = 'Working', startedAt, outputTokens = 0, tokens
203
180
  lastGrowRef.current = now;
204
181
  }
205
182
 
183
+ // Waiting is not stalling: while a tool runs (no tokens can arrive) or while
184
+ // the user owns an approval prompt, hold the stall clock at zero. Otherwise a
185
+ // 30s shell command or a slow approval reddens a perfectly healthy turn.
186
+ const workBlocked = paused || hasActiveTools || mode === 'tool-use' || mode === 'tool-input';
187
+ if (workBlocked) lastGrowRef.current = now;
188
+
206
189
  const stallMs = now - lastGrowRef.current;
207
- const isStalled = targetOutputTokens > 0 && stallMs > STALL_TIMEOUT_MS;
190
+ const isStalled = !reducedMotion && targetOutputTokens > 0 && stallMs > STALL_TIMEOUT_MS;
208
191
  // Stall smoothing: exponential fade toward target
209
192
  const rawIntensity = isStalled
210
193
  ? Math.min(1, (stallMs - STALL_TIMEOUT_MS) / STALL_FADE_MS)
@@ -235,36 +218,31 @@ export function Spinner({ verb = 'Working', startedAt, outputTokens = 0, tokens
235
218
  ))
236
219
  : theme.spinnerGlyph;
237
220
 
238
- // --- Verb shimmer (traveling highlight) ---
239
- if (!displayVerbRef.current || displayVerbModeRef.current !== mode) {
240
- displayVerbRef.current = stableModeVerb(mode, verb);
241
- // Mode flips (thinking→tool-use→responding…) must NOT re-arm the timer:
242
- // frequent flips otherwise reset the countdown forever and the rotation
243
- // never fires. The cadence holds from the first mount of the turn.
244
- if (!nextVerbCheckRef.current) nextVerbCheckRef.current = nextVerbCheckAt(now);
245
- }
246
- displayVerbModeRef.current = mode;
247
- if (now >= nextVerbCheckRef.current) {
248
- displayVerbRef.current = chooseNextVerb(mode, verb, displayVerbRef.current);
249
- nextVerbCheckRef.current = nextVerbCheckAt(now);
250
- }
251
- const displayVerb = displayVerbRef.current;
221
+ // --- Verb (one common pool, one phrase per 30s window) ---
222
+ // Anchored to `startedAt`, so a mode flip cannot rewrite the label and the
223
+ // desktop shows the same word at the same second.
224
+ const displayVerb = SPINNER_MODE_OVERRIDE_VERBS[mode]
225
+ || (mode === 'reconnecting' ? (String(verb || '').trim() || 'Reconnecting') : spinnerVerbFor(startedAt, now));
252
226
  const messageText = mode === 'reconnecting' ? displayVerb : `${displayVerb}…`;
253
227
  const messageLen = messageText.length;
254
228
 
255
229
  // Glimmer speed per mode.
256
230
  const glimmerSpeed = GLIMMER_SPEED_MS[mode] ?? 200;
257
231
  const shimmerSpan = Math.max(1, messageLen + GLIMMER_TRAIL);
258
- const shimmerHead = Math.floor(elapsedMs / glimmerSpeed) % shimmerSpan;
232
+ const shimmerHead = Math.floor(rawElapsedMs / glimmerSpeed) % shimmerSpan;
259
233
 
260
234
  // Keep the verb shimmer moving even during stalls/tool waits. Stall tinting is
261
235
  // limited to the glyph; tinting the whole verb made the sweep disappear after
262
236
  // a few seconds and read as a stuck dark label.
263
- const verbContent = messageLen > 0 && TEXT_RGB && SHIMMER_RGB
237
+ const verbContent = messageLen > 0 && !reducedMotion && TEXT_RGB && SHIMMER_RGB
264
238
  ? renderShimmerText(messageText, shimmerHead, GLIMMER_TRAIL, TEXT_RGB, SHIMMER_RGB, theme.spinnerText, 'verb', shimmerSpan)
265
- : null;
239
+ : (messageLen > 0 ? <Text color={theme.spinnerText}>{messageText}</Text> : null);
266
240
 
267
241
  const advanceCounter = (ref, target) => {
242
+ if (reducedMotion) {
243
+ ref.current = target;
244
+ return Math.round(target);
245
+ }
268
246
  if (ref.current > target) {
269
247
  ref.current = target;
270
248
  } else if (ref.current < target) {
@@ -284,10 +262,19 @@ export function Spinner({ verb = 'Working', startedAt, outputTokens = 0, tokens
284
262
  // thinking, tool-use, tool-input). Input token totals are not shown.
285
263
  const displayedOutputTokens = advanceCounter(displayedOutputRef, targetOutputTokens);
286
264
 
265
+ // Byline text comes from the shared builder so the desktop band reads the
266
+ // same way (token gate, thinking vs "thought for Ns").
267
+ const meta = buildSpinnerMeta({
268
+ elapsedMs,
269
+ outputTokens: displayedOutputTokens,
270
+ thinking,
271
+ thinkingSince: thinkingActiveSince,
272
+ thinkingMs,
273
+ effort,
274
+ });
287
275
  const tokenGlyph = tokenModeGlyph(mode);
288
- const tokenCountText = formatNumber(displayedOutputTokens);
289
- const tokenText = displayedOutputTokens > 0
290
- ? (tokenGlyph ? `${tokenGlyph} ${tokenCountText} tokens` : `${tokenCountText} tokens`)
276
+ const tokenText = meta.tokensText
277
+ ? (tokenGlyph ? `${tokenGlyph} ${meta.tokensText}` : meta.tokensText)
291
278
  : '';
292
279
  const tokenW = tokenText.length;
293
280
 
@@ -299,20 +286,22 @@ export function Spinner({ verb = 'Working', startedAt, outputTokens = 0, tokens
299
286
  const timerText = formatDuration(elapsedMs);
300
287
  const timerLabel = timerText;
301
288
  const timerW = timerLabel.length;
302
- const thinkingActive = Boolean(thinking || thinkingActiveSince);
303
- const thinkingStatusText = thinkingActive
304
- ? 'thinking'
305
- : '';
289
+ const thinkingActive = meta.thinkingActive;
290
+ const thinkingStatusText = meta.thinkingText;
306
291
  const thinkingStatusW = thinkingStatusText.length;
307
292
  // Turn elapsed time is the headline metric here, not a thinking sub-stat, so
308
293
  // it shows from 1s onward (formatDuration returns '' below 1s). Tokens keep
309
294
  // Short-turn gate: tokens only appear once the turn runs long.
310
- const wantsTokens = elapsedMs > SHOW_TOKENS_AFTER_MS;
311
-
312
- // Thinking display priority for narrow widths, but renders
313
- // it after timer/tokens in the final byline.
314
- const showThinkingStatus = Boolean(thinkingStatusText) && avail > thinkingStatusW;
315
- const usedAfterThinking = showThinkingStatus ? thinkingStatusW + SEP_WIDTH : 0;
295
+ const wantsTokens = meta.showTokens;
296
+
297
+ // Interrupt hint and thinking status win the width race (they render LAST in
298
+ // the byline but are gated FIRST), then timer, then tokens.
299
+ const interruptText = interruptible ? INTERRUPT_HINT : '';
300
+ const interruptW = interruptText.length;
301
+ const showInterrupt = Boolean(interruptText) && avail > interruptW;
302
+ const usedAfterInterrupt = showInterrupt ? interruptW + SEP_WIDTH : 0;
303
+ const showThinkingStatus = Boolean(thinkingStatusText) && avail > usedAfterInterrupt + thinkingStatusW;
304
+ const usedAfterThinking = usedAfterInterrupt + (showThinkingStatus ? thinkingStatusW + SEP_WIDTH : 0);
316
305
  const showTimer = Boolean(timerLabel) && avail > usedAfterThinking + timerW;
317
306
  const usedAfterTimer = usedAfterThinking + (showTimer ? timerW + SEP_WIDTH : 0);
318
307
  const showTokens = wantsTokens && tokenText && avail > usedAfterTimer + tokenW;
@@ -331,13 +320,18 @@ export function Spinner({ verb = 'Working', startedAt, outputTokens = 0, tokens
331
320
  }
332
321
  if (showThinkingStatus) {
333
322
  const thinkingSpan = Math.max(1, thinkingStatusText.length + THINKING_GLIMMER_TRAIL);
334
- const thinkingHead = Math.floor(Math.max(0, elapsedMs - THINKING_DELAY_MS) / THINKING_GLIMMER_SPEED_MS) % thinkingSpan;
323
+ const thinkingHead = Math.floor(Math.max(0, rawElapsedMs - THINKING_DELAY_MS) / THINKING_GLIMMER_SPEED_MS) % thinkingSpan;
335
324
  segments.push(
336
- thinkingActive
325
+ thinkingActive && !reducedMotion
337
326
  ? <Text key="thinking-status">{renderShimmerText(thinkingStatusText, thinkingHead, THINKING_GLIMMER_TRAIL, THINKING_INACTIVE, THINKING_SHIMMER, theme.thinkingBase, 'thinking-status', thinkingSpan)}</Text>
338
327
  : <Text key="thinking-status" color={theme.statusSubtle}>{thinkingStatusText}</Text>
339
328
  );
340
329
  }
330
+ if (showInterrupt) {
331
+ segments.push(
332
+ <Text key="interrupt" color={theme.statusSubtle}>{interruptText}</Text>
333
+ );
334
+ }
341
335
  return (
342
336
  <Box marginTop={marginTop} flexDirection="row">
343
337
  <Box flexWrap="wrap" height={1} width={2}>
@@ -180,6 +180,7 @@ export function TextEntryPanel({
180
180
  const [draft, setDraft] = useState(() => ({ value: String(initialValue || ''), cursor: String(initialValue || '').length, selectionAnchor: null }));
181
181
  const [, bumpCursorAnchorEpoch] = useState(0);
182
182
  const draftRef = useRef(draft);
183
+ const submitGateRef = useRef(false);
183
184
  const boxRef = useRef(null);
184
185
  const cursorEnabledRef = useRef(false);
185
186
  const contentWidthRef = useRef(80);
@@ -244,20 +245,33 @@ export function TextEntryPanel({
244
245
  }, [multiline, draft.value, mask, contentCells, maxContentRows, onContentRowsChange]);
245
246
 
246
247
  const submit = () => {
248
+ if (submitGateRef.current) return;
249
+ if (!String(draftRef.current.value || '').trim()) return;
250
+ submitGateRef.current = true;
247
251
  const accepted = onSubmit?.(draftRef.current.value) !== false;
248
252
  if (accepted) {
249
253
  commitDraft({ value: '', cursor: 0, selectionAnchor: null });
254
+ queueMicrotask(() => { submitGateRef.current = false; });
255
+ } else {
256
+ submitGateRef.current = false;
250
257
  }
251
258
  };
252
259
 
253
260
  const submitEnterChunk = (prefix = '') => {
261
+ if (submitGateRef.current) return;
254
262
  const current = draftRef.current;
255
263
  const next = prefix ? insertText(current, prefix) : current;
264
+ if (!String(next.value || '').trim()) return;
265
+ submitGateRef.current = true;
256
266
  const accepted = onSubmit?.(next.value) !== false;
257
267
  if (accepted) {
258
268
  commitDraft({ value: '', cursor: 0, selectionAnchor: null });
269
+ queueMicrotask(() => { submitGateRef.current = false; });
259
270
  } else if (next !== current) {
271
+ submitGateRef.current = false;
260
272
  commitDraft(next);
273
+ } else {
274
+ submitGateRef.current = false;
261
275
  }
262
276
  };
263
277
 
@@ -369,8 +369,8 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
369
369
  let labelOut;
370
370
  let summaryOut;
371
371
  // Shell headers stay label-only ("Ran 1 command") on every surface —
372
- // opencode parity and the desktop card contract: the raw command line never
373
- // rides inline in the header; it stays in the expanded detail body.
372
+ // the desktop card contract: the raw command line never rides inline in
373
+ // the header; it stays in the expanded detail body.
374
374
  const headerSummaryText = isShellSurface ? '' : summaryText;
375
375
  if (stringWidth(labelText) >= avail) {
376
376
  labelOut = truncateToWidth(labelText, avail);
@@ -41,26 +41,24 @@ export function draftStateEqual(a, b) {
41
41
  // Recognize a MODIFIED Enter delivered via the kitty keyboard protocol
42
42
  // (\x1b[13;<mod>u) or modifyOtherKeys (\x1b[27;<mod>;13~). The xterm modifier
43
43
  // param is (1 + bitmask) where the bitmask bits are shift=1, alt=2, ctrl=4. We
44
- // treat Ctrl+Enter AND Shift+Enter (the two common "insert newline" chords) as a
45
- // newline, so we match when the shift OR ctrl bit is set. Ctrl+J is handled
46
- // separately as the protocol-independent fallback.
47
- const MODIFIED_ENTER_SHIFT_OR_CTRL = 1 | 4;
44
+ // treat Shift+Enter, Alt/Meta+Enter, and Ctrl+Enter as newline chords. Claude
45
+ // Code maps Shift/Meta+Enter to newline; Ctrl+Enter remains a compatible Mixdog
46
+ // extension. Ctrl+J is handled separately as the protocol-independent fallback.
47
+ const MODIFIED_ENTER_NEWLINE = 1 | 2 | 4;
48
48
 
49
49
  export function isModifiedEnterSequence(input) {
50
50
  const text = String(input ?? '');
51
51
  const body = text.startsWith('\x1b[') ? text.slice(2) : text.startsWith('[') ? text.slice(1) : '';
52
52
  if (!body) return false;
53
53
  const kitty = /^13;(\d+)(?::\d+)?(?:;[\d:]+)?u$/.exec(body);
54
- if (kitty) return ((Number(kitty[1]) - 1) & MODIFIED_ENTER_SHIFT_OR_CTRL) !== 0;
54
+ if (kitty) return ((Number(kitty[1]) - 1) & MODIFIED_ENTER_NEWLINE) !== 0;
55
55
  const modifyOtherKeys = /^27;(\d+);13~$/.exec(body);
56
- return Boolean(modifyOtherKeys && (((Number(modifyOtherKeys[1]) - 1) & MODIFIED_ENTER_SHIFT_OR_CTRL) !== 0));
56
+ return Boolean(modifyOtherKeys && (((Number(modifyOtherKeys[1]) - 1) & MODIFIED_ENTER_NEWLINE) !== 0));
57
57
  }
58
58
 
59
- // Recognize ANY modified Enter (any modifier bitmask, e.g. Alt+Enter \x1b[13;3u
60
- // / \x1b[27;3;13~). Used to CONSUME modified-Enter sequences we don't map to a
61
- // newline (Alt-only, etc.) so they aren't typed into the prompt as raw CSI text
62
- // under modifyOtherKeys. Plain Enter (mod param = 1, bitmask 0) is intentionally
63
- // NOT matched, so it still submits.
59
+ // Recognize ANY modified Enter. Used to consume uncommon modifier combinations
60
+ // outside the Shift/Alt/Ctrl newline set so raw CSI bytes never reach the draft.
61
+ // Plain Enter (mod param = 1, bitmask 0) intentionally remains a submit.
64
62
  export function isAnyModifiedEnterSequence(input) {
65
63
  const text = String(input ?? '');
66
64
  const body = text.startsWith('\x1b[') ? text.slice(2) : text.startsWith('[') ? text.slice(1) : '';