mixdog 0.9.95 → 0.9.97

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 (282) 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 +46 -9
  5. package/README.md +0 -17
  6. package/package.json +16 -12
  7. package/scripts/.tmp-coverage-report.mjs +96 -0
  8. package/scripts/build-tui.mjs +17 -1
  9. package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
  10. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/defaults/skills/setup/SKILL.md +1 -1
  13. package/src/lib/keychain-cjs.cjs +1 -1
  14. package/src/lib/mixdog-debug.cjs +2 -3
  15. package/src/rules/agent/30-explorer.md +7 -4
  16. package/src/rules/lead/01-general.md +1 -2
  17. package/src/rules/lead/lead-tool.md +0 -3
  18. package/src/rules/shared/01-tool.md +21 -29
  19. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  23. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  24. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +104 -41
  26. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  27. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  39. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  41. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  42. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  43. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +36 -10
  44. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -33
  46. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  49. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +21 -29
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +7 -10
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +45 -25
  52. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  53. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  54. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  55. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  57. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  59. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  62. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  63. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  64. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +71 -42
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +4 -33
  66. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  67. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +116 -26
  71. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  72. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  73. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  77. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +12 -26
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +5 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -33
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +42 -45
  81. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +27 -9
  82. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  84. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  86. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  87. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  88. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  89. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  90. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +142 -26
  91. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  92. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +47 -19
  93. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -2
  94. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  95. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  97. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -20
  99. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  100. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  101. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +12 -11
  102. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +63 -9
  104. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  105. package/src/runtime/channels/lib/config.mjs +34 -34
  106. package/src/runtime/channels/lib/inbound-handler.mjs +8 -8
  107. package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
  108. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  109. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  110. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  111. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  112. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  113. package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
  114. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  115. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  116. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  117. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  118. package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
  119. package/src/runtime/channels/lib/worker-main.mjs +38 -38
  120. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  121. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  122. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  123. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/memory/index.mjs +76 -248
  126. package/src/runtime/memory/lib/agent-ipc.mjs +146 -81
  127. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  128. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  129. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +13 -34
  130. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  131. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  132. package/src/runtime/memory/lib/http-router.mjs +9 -6
  133. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  134. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  135. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  136. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  137. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  138. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  139. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  140. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  141. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  142. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  143. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  144. package/src/runtime/memory/lib/memory.mjs +1 -1
  145. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  146. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  147. package/src/runtime/memory/tool-defs.mjs +1 -5
  148. package/src/runtime/search/index.mjs +2 -2
  149. package/src/runtime/search/tool-defs.mjs +2 -2
  150. package/src/runtime/shared/abort-race.mjs +70 -0
  151. package/src/runtime/shared/agent-route-config.mjs +119 -0
  152. package/src/runtime/shared/atomic-file.mjs +80 -4
  153. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  154. package/src/runtime/shared/child-guardian.mjs +204 -108
  155. package/src/runtime/shared/child-spawn-gate.mjs +139 -72
  156. package/src/runtime/shared/config.mjs +120 -47
  157. package/src/runtime/shared/llm/index.mjs +1 -1
  158. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  159. package/src/runtime/shared/resource-admission.mjs +150 -100
  160. package/src/runtime/shared/stream-progress.mjs +6 -0
  161. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  162. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  163. package/src/runtime/shared/tool-surface.mjs +91 -27
  164. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  165. package/src/runtime/shared/turn-snapshot.mjs +47 -6
  166. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  167. package/src/session-runtime/channel-config-api.mjs +5 -5
  168. package/src/session-runtime/config-helpers.mjs +39 -37
  169. package/src/session-runtime/config-lifecycle.mjs +27 -27
  170. package/src/session-runtime/lifecycle-api.mjs +22 -3
  171. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  172. package/src/session-runtime/model-route-api.mjs +7 -5
  173. package/src/session-runtime/prewarm.mjs +1 -1
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +1 -1
  176. package/src/session-runtime/remote-control.mjs +6 -7
  177. package/src/session-runtime/runtime-core.mjs +63 -39
  178. package/src/session-runtime/runtime-tunables.mjs +4 -4
  179. package/src/session-runtime/self-update.mjs +1 -1
  180. package/src/session-runtime/session-lifecycle.mjs +45 -17
  181. package/src/session-runtime/session-title.mjs +1 -1
  182. package/src/session-runtime/session-turn-api.mjs +96 -24
  183. package/src/session-runtime/settings-api.mjs +12 -24
  184. package/src/session-runtime/tool-defs.mjs +3 -3
  185. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  186. package/src/session-runtime/workflow.mjs +14 -24
  187. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  188. package/src/standalone/agent-tool/helpers.mjs +2 -2
  189. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  190. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  191. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  192. package/src/standalone/channel-admin.mjs +53 -64
  193. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +33 -19
  194. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +127 -46
  195. package/src/standalone/channel-worker.mjs +124 -647
  196. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +270 -154
  197. package/src/standalone/explore-tool.mjs +187 -70
  198. package/src/standalone/fair-call-scheduler.mjs +264 -0
  199. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  200. package/src/standalone/session-client.mjs +1134 -0
  201. package/src/standalone/session-protocol.mjs +192 -0
  202. package/src/standalone/session-runtime-pool.mjs +393 -0
  203. package/src/standalone/session-runtime-record.mjs +20 -0
  204. package/src/standalone/session-runtime-worker.mjs +203 -0
  205. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +481 -224
  206. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +390 -121
  207. package/src/standalone/session-wire.mjs +55 -0
  208. package/src/tui/App.jsx +34 -21
  209. package/src/tui/app/app-format.mjs +1 -1
  210. package/src/tui/app/app-view.jsx +5 -2
  211. package/src/tui/app/channel-pickers.mjs +41 -41
  212. package/src/tui/app/core-memory-picker.mjs +19 -19
  213. package/src/tui/app/doctor.mjs +5 -11
  214. package/src/tui/app/input-parsers.mjs +2 -2
  215. package/src/tui/app/message-selector.mjs +103 -0
  216. package/src/tui/app/onboarding-steps.mjs +3 -4
  217. package/src/tui/app/project-picker.mjs +80 -56
  218. package/src/tui/app/prompt-submit.mjs +48 -43
  219. package/src/tui/app/resume-picker.mjs +2 -2
  220. package/src/tui/app/route-pickers.mjs +3 -1
  221. package/src/tui/app/settings-picker.mjs +31 -33
  222. package/src/tui/app/transcript-window.mjs +38 -0
  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 +10 -3
  227. package/src/tui/app/use-transcript-scroll.mjs +10 -3
  228. package/src/tui/app/use-transcript-window.mjs +50 -17
  229. package/src/tui/components/PromptInput.jsx +71 -38
  230. package/src/tui/components/Spinner.jsx +1 -1
  231. package/src/tui/components/ToolExecution.jsx +1 -1
  232. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  233. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  234. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  235. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  236. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  237. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  238. package/src/tui/dist/index.mjs +1031 -1676
  239. package/src/tui/hooks/useSession.mjs +16 -0
  240. package/src/tui/index.jsx +12 -13
  241. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  242. package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
  243. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  244. package/src/tui/{engine → session}/context-state.mjs +3 -3
  245. package/src/tui/{engine → session}/labels.mjs +2 -2
  246. package/src/tui/{engine → session}/live-share.mjs +38 -4
  247. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  248. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  249. package/src/tui/session/oauth-flows.mjs +140 -0
  250. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  251. package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
  252. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  253. package/src/tui/{engine → session}/session-api-ext.mjs +30 -20
  254. package/src/tui/{engine → session}/session-api.mjs +110 -93
  255. package/src/tui/{engine → session}/session-flow.mjs +8 -2
  256. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  257. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  258. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  259. package/src/tui/{engine → session}/tool-card-results.mjs +8 -6
  260. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  261. package/src/tui/{engine → session}/tool-result-text.mjs +13 -3
  262. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  263. package/src/tui/{engine → session}/turn.mjs +38 -176
  264. package/src/tui/{engine-local-session.mjs → session-local.mjs} +103 -103
  265. package/src/tui/session.mjs +22 -0
  266. package/src/tui/spinner-meta.mjs +4 -4
  267. package/src/tui/spinner-verbs.mjs +51 -3
  268. package/src/ui/statusline-segments.mjs +1 -1
  269. package/src/ui/statusline.mjs +1 -1
  270. package/vendor/ink/build/components/App.js +10 -1
  271. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  272. package/src/standalone/engine-daemon-client.mjs +0 -894
  273. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
  274. package/src/standalone/engine-daemon-protocol.mjs +0 -33
  275. package/src/tui/engine/turn-watchdog.mjs +0 -92
  276. package/src/tui/engine.mjs +0 -22
  277. package/src/tui/hooks/useEngine.mjs +0 -16
  278. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  279. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  280. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  281. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  282. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -69,6 +69,22 @@ export const SELECTION_PAINT_INTERVAL_MS = positiveIntEnv('MIXDOG_TUI_SELECTION_
69
69
  export const SCROLL_COALESCE_MS = positiveIntEnv('MIXDOG_TUI_SCROLL_COALESCE_MS', 16);
70
70
  export const PROMPT_HISTORY_LIMIT = 50;
71
71
 
72
+ // Accumulate same-direction wheel/edge-drag deltas, but never retain movement
73
+ // from the opposite direction. Without this reset a quick wheel reversal could
74
+ // apply an older queued delta one frame later and visibly roll the transcript
75
+ // back against the user's latest gesture. Returns true when the direction
76
+ // changed so the caller can cancel its old coalescing timer and flush now.
77
+ export function accumulateDirectionalScrollDelta(state, deltaRows) {
78
+ const delta = Number(deltaRows) || 0;
79
+ if (!delta) return false;
80
+ const direction = Math.sign(delta);
81
+ const reversed = Boolean(state.direction && state.direction !== direction);
82
+ if (reversed) state.pendingRows = 0;
83
+ state.direction = direction;
84
+ state.pendingRows += delta;
85
+ return reversed;
86
+ }
87
+
72
88
  // Parse a boolean env var that DEFAULTS ON. Any of 0/false/off/no (case-
73
89
  // insensitive, trimmed) disables it; everything else (including unset) leaves it
74
90
  // on. Used as the kill switch for the app-level measured-height feature below.
@@ -647,6 +663,28 @@ export function transcriptRowAt(values, index) {
647
663
  : (Number(values[index]) || 0);
648
664
  }
649
665
 
666
+ /** True when a transcript items swap destroyed the reader's position and the
667
+ * viewport must return to the live tail with follow re-armed.
668
+ *
669
+ * A bulk swap (session load/clear, mid-turn COMPACTION) drops the history the
670
+ * reader was anchored to. Compaction pushes its status row in the SAME commit
671
+ * that trims the head, so the item count can stay level or even grow: a
672
+ * count-drop test alone left auto-scroll released with an anchor lock on a
673
+ * deleted row, and every new row piled up below the fold for the rest of the
674
+ * session (user: 컴팩션된 이후로 자동 스크롤이 풀린다). Live appends never
675
+ * touch index 0, so a changed HEAD id is the bulk-swap signal. */
676
+ export function transcriptSwapReturnsToTail({
677
+ count = 0,
678
+ previousCount = 0,
679
+ firstId = null,
680
+ previousFirstId = null,
681
+ } = {}) {
682
+ if (previousCount === 0) return true;
683
+ if (count < previousCount) return true;
684
+ if (count === 0) return false;
685
+ return firstId != null && previousFirstId != null && firstId !== previousFirstId;
686
+ }
687
+
650
688
  function appendTranscriptRow(prefix, tailValue) {
651
689
  return Object.freeze({
652
690
  length: prefix.length + 1,
@@ -146,13 +146,11 @@ export function usePromptDraftFlow({
146
146
  }, [slashCommands, slashIndex]);
147
147
 
148
148
  const cancelSlashPalette = useCallback((value = '') => {
149
- // Esc clears the slash draft, so the dismissal marker must not survive.
150
- // If it stays as "/" then typing "/" again is treated as the same
151
- // dismissed query and the palette never re-opens.
152
- setSlashDismissedFor('');
153
- setPromptDraft('');
154
- setPromptDraftOverride({ id: Date.now(), value: '' });
155
- }, []);
149
+ // Claude Code's autocomplete:dismiss closes suggestions without changing
150
+ // the draft. Remember this exact value so the palette does not immediately
151
+ // reopen; the next edit clears the marker in onPromptDraftChange.
152
+ setSlashDismissedFor(String(value ?? ''));
153
+ }, [setSlashDismissedFor]);
156
154
 
157
155
  return {
158
156
  onPromptDraftChange,
@@ -9,7 +9,7 @@
9
9
  * byte-identical to the original inline hooks. Import-level helpers (paste
10
10
  * attachments) are imported directly here.
11
11
  */
12
- import { useCallback } from 'react';
12
+ import { useCallback, useEffect, useRef } from 'react';
13
13
  import {
14
14
  readClipboardImageAttachment,
15
15
  readClipboardText,
@@ -20,6 +20,8 @@ import {
20
20
  shouldFoldPastedText,
21
21
  } from '../paste-attachments.mjs';
22
22
  import { promptHistoryKey } from '../prompt-history-store.mjs';
23
+ import { promptInterruptRestoreText } from '../components/prompt-input/interrupt-policy.mjs';
24
+ import { PROMPT_ESCAPE_CLEAR_WINDOW_MS } from '../components/prompt-input/escape-policy.mjs';
23
25
 
24
26
  export function usePromptHandlers({
25
27
  store,
@@ -44,6 +46,7 @@ export function usePromptHandlers({
44
46
  recentPromptHistory,
45
47
  resetPromptHistoryNav,
46
48
  restoreQueuedToPrompt,
49
+ openMessageSelector,
47
50
  usagePanel,
48
51
  closeUsagePanel,
49
52
  contextPanel,
@@ -55,6 +58,8 @@ export function usePromptHandlers({
55
58
  clearPastedTextsSnapshot,
56
59
  registerPastedText,
57
60
  }) {
61
+ const interruptGenerationRef = useRef(0);
62
+ const pendingInterruptRestoreRef = useRef(null);
58
63
  const handlePromptPaste = useCallback((text, meta = {}) => {
59
64
  const source = String(meta?.source || 'paste');
60
65
  const value = String(text ?? '');
@@ -197,41 +202,101 @@ export function usePromptHandlers({
197
202
 
198
203
  // ESC / Up handling (prompt input):
199
204
  // - prompt-local overlays such as the slash palette close first.
200
- // - queued editable messages pop back into the prompt before clear/interrupt.
201
- // - non-empty prompt text is cleared by PromptInput and must never interrupt
202
- // the active turn on the same Esc press.
203
- // - empty prompt + active turn interrupts the active turn.
205
+ // - active work is cancelled before queue/draft handling.
206
+ // - idle non-empty text uses Claude Code's "Esc again to clear" guard.
207
+ // - idle empty input restores queued editable messages, and a double press
208
+ // opens the message selector (jump back to a previous prompt).
204
209
  const handlePromptEscape = useCallback((text = '', meta = {}) => {
205
210
  if (usagePanel) { closeUsagePanel(); return true; }
206
211
  if (contextPanel) { setContextPanel(null); return true; }
207
212
 
213
+ if (meta.phase === 'clear-arm') {
214
+ showPromptHint('Esc again to clear', 'plain', PROMPT_ESCAPE_CLEAR_WINDOW_MS);
215
+ return true;
216
+ }
217
+ if (meta.phase === 'select-arm') {
218
+ showPromptHint('Esc again to pick a message', 'plain', PROMPT_ESCAPE_CLEAR_WINDOW_MS);
219
+ return true;
220
+ }
221
+ if (meta.phase === 'select') {
222
+ clearPromptHint();
223
+ // Queue first (Claude Code order): a stale queue projection must not let
224
+ // the selector shadow an editable follow-up that is still waiting.
225
+ if (restoreQueuedToPrompt({ restoreDraft: true, showHint: false, currentText: text })) return true;
226
+ return openMessageSelector?.() === true;
227
+ }
208
228
  if (meta.phase === 'clear') {
229
+ try {
230
+ const remembered = store.rememberPromptHistory?.(text);
231
+ if (remembered?.catch) void remembered.catch(() => {});
232
+ } catch { /* best-effort history parity */ }
209
233
  clearPastedImagesSnapshot();
234
+ clearPastedTextsSnapshot();
210
235
  clearPromptHint();
211
236
  return false;
212
237
  }
213
238
  if (meta.phase === 'empty') {
214
239
  return restoreQueuedToPrompt({ restoreDraft: true, showHint: false, currentText: text });
215
240
  }
216
- // Idle + empty + nothing to restore: nothing (double-press from empty
217
- // opens message selector, but we don't have that feature yet).
241
+ // Idle + empty + no transcript to jump back into: nothing to do.
218
242
  return false;
219
- }, [contextPanel, usagePanel, closeUsagePanel, restoreQueuedToPrompt, clearPromptHint, clearPastedImagesSnapshot, store]);
243
+ }, [contextPanel, usagePanel, closeUsagePanel, restoreQueuedToPrompt, openMessageSelector, showPromptHint, clearPromptHint, clearPastedImagesSnapshot, clearPastedTextsSnapshot, store]);
220
244
 
221
- const handlePromptInterrupt = useCallback((currentText = '') => {
222
- const result = store.abort?.();
223
- if (result?.aborted === false) return undefined;
224
- if (result?.pastedImages) installPastedImages(result.pastedImages, { merge: true });
225
- if (result?.discardPastedImages) clearPastedImagesSnapshot(result.discardPastedImages);
226
- if (result?.pastedTexts) installPastedTexts(result.pastedTexts, { merge: true });
227
- if (result?.discardPastedTexts) clearPastedTextsSnapshot(result.discardPastedTexts);
228
- const restoreText = String(result?.restoreText || '').trim();
229
- if (!restoreText) return undefined;
230
- const existingText = String(currentText || '').trim();
231
- const nextText = [restoreText, existingText].filter(Boolean).join('\n');
245
+ const commitAsyncInterruptRestore = useCallback((pending) => {
246
+ if (!pending || pending.generation !== interruptGenerationRef.current) return true;
247
+ if (store.getState?.().busy) return false;
248
+ const restoreText = promptInterruptRestoreText(pending.result, promptValueRef.current);
249
+ if (!restoreText) return true;
250
+ if (pending.result?.pastedImages) installPastedImages(pending.result.pastedImages, { merge: true });
251
+ if (pending.result?.pastedTexts) installPastedTexts(pending.result.pastedTexts, { merge: true });
232
252
  clearPromptHint();
233
- return nextText;
234
- }, [store, clearPromptHint, installPastedImages, clearPastedImagesSnapshot]);
253
+ syncPromptLayoutRows(restoreText);
254
+ setPromptDraftOverride({ id: Date.now(), value: restoreText });
255
+ return true;
256
+ }, [store, promptValueRef, installPastedImages, installPastedTexts, clearPromptHint, syncPromptLayoutRows, setPromptDraftOverride]);
257
+
258
+ useEffect(() => {
259
+ const pending = pendingInterruptRestoreRef.current;
260
+ if (!pending) return;
261
+ if (commitAsyncInterruptRestore(pending)) pendingInterruptRestoreRef.current = null;
262
+ }, [state.busy, commitAsyncInterruptRestore]);
263
+
264
+ const handlePromptInterrupt = useCallback((currentText = '') => {
265
+ const generation = ++interruptGenerationRef.current;
266
+ pendingInterruptRestoreRef.current = null;
267
+ const applyResult = (result, draftText, asyncResult = false) => {
268
+ if (generation !== interruptGenerationRef.current || result?.aborted === false) return undefined;
269
+ if (result?.discardPastedImages) clearPastedImagesSnapshot(result.discardPastedImages);
270
+ if (result?.discardPastedTexts) clearPastedTextsSnapshot(result.discardPastedTexts);
271
+ if (asyncResult) {
272
+ const pending = { generation, result };
273
+ if (!commitAsyncInterruptRestore(pending)) pendingInterruptRestoreRef.current = pending;
274
+ return undefined;
275
+ }
276
+ const restoreText = promptInterruptRestoreText(result, draftText);
277
+ if (!restoreText) return undefined;
278
+ if (result?.pastedImages) installPastedImages(result.pastedImages, { merge: true });
279
+ if (result?.pastedTexts) installPastedTexts(result.pastedTexts, { merge: true });
280
+ clearPromptHint();
281
+ return restoreText;
282
+ };
283
+
284
+ let result;
285
+ try {
286
+ const options = { restorePrompt: String(currentText ?? '') === '' };
287
+ result = typeof store.abortAsync === 'function' ? store.abortAsync(options) : store.abort?.(options);
288
+ } catch (error) {
289
+ store.pushNotice?.(`interrupt failed: ${error?.message || error}`, 'error');
290
+ return undefined;
291
+ }
292
+ if (result && typeof result.then === 'function') {
293
+ void Promise.resolve(result)
294
+ .then((resolved) => applyResult(resolved, promptValueRef.current, true))
295
+ .catch((error) => store.pushNotice?.(`interrupt failed: ${error?.message || error}`, 'error'));
296
+ return undefined;
297
+ }
298
+ return applyResult(result, currentText, false);
299
+ }, [store, commitAsyncInterruptRestore, clearPromptHint, installPastedImages, installPastedTexts, clearPastedImagesSnapshot, clearPastedTextsSnapshot]);
235
300
 
236
301
  return {
237
302
  handlePromptPaste,
@@ -12,17 +12,18 @@ export function usePromptHint() {
12
12
  const promptHintTimerRef = useRef(null);
13
13
  const promptHintActiveRef = useRef(false);
14
14
 
15
- const showPromptHint = useCallback((text, tone = 'info') => {
15
+ const showPromptHint = useCallback((text, tone = 'info', dismissMs = PROMPT_HINT_DISMISS_MS) => {
16
16
  if (promptHintTimerRef.current) clearTimeout(promptHintTimerRef.current);
17
17
  promptHintActiveRef.current = true;
18
18
  setPromptHint(String(text || ''));
19
19
  setPromptHintTone(tone);
20
+ const timeoutMs = Number(dismissMs) > 0 ? Number(dismissMs) : PROMPT_HINT_DISMISS_MS;
20
21
  promptHintTimerRef.current = setTimeout(() => {
21
22
  promptHintTimerRef.current = null;
22
23
  promptHintActiveRef.current = false;
23
24
  setPromptHint('');
24
25
  setPromptHintTone('info');
25
- }, PROMPT_HINT_DISMISS_MS);
26
+ }, timeoutMs);
26
27
  }, []);
27
28
 
28
29
  // Same band and timer; only the resting tone differs for copy feedback.
@@ -5,6 +5,7 @@
5
5
  import { useCallback, useMemo } from 'react';
6
6
  import { PROMPT_HISTORY_LIMIT } from './transcript-window.mjs';
7
7
  import { promptHistoryKey } from './app-format.mjs';
8
+ import { mergeQueuedRestoreText } from '../components/prompt-input/restore-policy.mjs';
8
9
 
9
10
  export function usePromptQueueHistory({
10
11
  store,
@@ -60,8 +61,12 @@ export function usePromptQueueHistory({
60
61
  if (restoreDraft) {
61
62
  if (restored.pastedImages) installPastedImages(restored.pastedImages, { merge: true });
62
63
  if (restored.pastedTexts) installPastedTexts(restored.pastedTexts, { merge: true });
63
- syncPromptLayoutRows(restored.text);
64
- setPromptDraftOverride({ id: Date.now(), value: restored.text });
64
+ const restoredText = mergeQueuedRestoreText(
65
+ restored.text,
66
+ promptValueRef.current ?? currentText,
67
+ );
68
+ syncPromptLayoutRows(restoredText);
69
+ setPromptDraftOverride({ id: Date.now(), value: restoredText });
65
70
  }
66
71
  if (showHint) {
67
72
  showPromptHint(`restored ${restored.count} queued message${restored.count === 1 ? '' : 's'}`, 'info');
@@ -70,7 +75,9 @@ export function usePromptQueueHistory({
70
75
  }
71
76
  return true;
72
77
  };
73
- const restored = store.restoreQueued?.(currentText);
78
+ // Ask the session service for the queued portion only. The live draft remains
79
+ // renderer-owned and is merged in apply(), after a daemon promise settles.
80
+ const restored = store.restoreQueued?.('');
74
81
  // A daemon-backed store answers this as an ASYNC remote call, so the
75
82
  // payload (and with it the queued text) only exists a tick later. Reading
76
83
  // `.count`/`.text` off the promise dropped the popped entries on the floor —
@@ -13,13 +13,14 @@ import { theme } from '../theme.mjs';
13
13
  import {
14
14
  SELECTION_PAINT_INTERVAL_MS,
15
15
  SCROLL_COALESCE_MS,
16
+ accumulateDirectionalScrollDelta,
16
17
  selectionRectsEqual,
17
18
  shiftSelectionRectY,
18
19
  comparePoints,
19
20
  upperBound,
20
21
  transcriptRowAt,
21
22
  } from './transcript-window.mjs';
22
- import { yieldToRenderer } from '../engine/render-timing.mjs';
23
+ import { yieldToRenderer } from '../session/render-timing.mjs';
23
24
 
24
25
  export function useTranscriptScroll({
25
26
  store,
@@ -45,7 +46,7 @@ export function useTranscriptScroll({
45
46
  const selectionPaintRef = useRef({ t: 0, rect: null, pending: null, timer: null });
46
47
  // Coalescer for edge-drag auto-scroll + wheel scroll deltas (see
47
48
  // SCROLL_COALESCE_MS). Both call sites accumulate into pendingRows.
48
- const scrollCoalesceRef = useRef({ pendingRows: 0, timer: null });
49
+ const scrollCoalesceRef = useRef({ pendingRows: 0, direction: 0, timer: null });
49
50
  const selectionTextCaptureRef = useRef(0);
50
51
  // Stitch buffer: accumulates harvested transcript selection rows across scroll
51
52
  // positions so Ctrl+C copies the FULL drag even after it auto-scrolled past the
@@ -446,6 +447,7 @@ export function useTranscriptScroll({
446
447
  if (coalesceState.timer) clearTimeout(coalesceState.timer);
447
448
  coalesceState.timer = null;
448
449
  coalesceState.pendingRows = 0;
450
+ coalesceState.direction = 0;
449
451
  }, []);
450
452
 
451
453
  const scrollTranscriptRows = useCallback((deltaRows, options = {}) => {
@@ -653,13 +655,18 @@ export function useTranscriptScroll({
653
655
  // calling scrollTranscriptRows directly.
654
656
  const queueScrollCoalesced = useCallback((deltaRows) => {
655
657
  const state = scrollCoalesceRef.current;
656
- state.pendingRows += deltaRows;
658
+ const reversed = accumulateDirectionalScrollDelta(state, deltaRows);
659
+ if (reversed && state.timer) {
660
+ clearTimeout(state.timer);
661
+ state.timer = null;
662
+ }
657
663
  if (state.timer) return;
658
664
  const rows = state.pendingRows;
659
665
  state.pendingRows = 0;
660
666
  scrollTranscriptRows(rows);
661
667
  state.timer = setTimeout(() => {
662
668
  state.timer = null;
669
+ state.direction = 0;
663
670
  if (state.pendingRows !== 0) {
664
671
  const remaining = state.pendingRows;
665
672
  state.pendingRows = 0;
@@ -207,8 +207,6 @@ export function useTranscriptWindow({
207
207
  const streamingTailItem = streamingTail?.kind === 'assistant' && streamingTail.streaming
208
208
  ? streamingTail
209
209
  : null;
210
- const scrolledUpRowsForPin = Math.max(0, Number(scrollTargetRef.current) || 0);
211
- const transcriptPinnedForStreaming = followingRef.current || scrolledUpRowsForPin === 0;
212
210
  const tailRows = streamingTailItem
213
211
  ? estimateTranscriptItemRowsCached(streamingTailItem, frameColumns, toolOutputExpanded)
214
212
  : 0;
@@ -226,17 +224,12 @@ export function useTranscriptWindow({
226
224
  revision,
227
225
  streamingTailItem,
228
226
  );
229
- const transcriptStreamingActive = !!streamingTailItem;
230
- const scrolledUpForStreamingMeasure = scrolledUpRowsForPin > 0;
231
- const prevEstimateGeometry = transcriptGeomRef.current?.suppressMeasuredRowHeights === true;
232
- const hasStreamingReadingAnchor = !!transcriptAnchorRef.current
233
- || transcriptAnchorDirtyRef.current;
234
- const bottomPinnedForMeasure = transcriptPinnedForStreaming;
235
- const suppressMeasuredRowHeights = bottomPinnedForMeasure
236
- || (transcriptStreamingActive && (
237
- (scrolledUpForStreamingMeasure && hasStreamingReadingAnchor)
238
- || (scrolledUpForStreamingMeasure && prevEstimateGeometry && !transcriptPinnedForStreaming)
239
- ));
227
+ // The live assistant already has one deterministic geometry authority in
228
+ // estimateTranscriptItemRowsCached. Settled rows always keep their measured
229
+ // geometry, whether following, reading, or resizing around a popup/picker.
230
+ // Switching the entire prefix back to estimates on the first upward wheel
231
+ // changed the row table during that gesture and caused rollback/jitter.
232
+ const suppressMeasuredRowHeights = false;
240
233
  // Incremental builder: on a streaming flush where only the trailing assistant
241
234
  // item's text grew, it recomputes just the tail row and appends to a cached
242
235
  // settled-prefix row-index (O(1) prefix) instead of re-walking all N items.
@@ -680,9 +673,14 @@ export function useTranscriptWindow({
680
673
  // off-screen. Ref: render-node-to-output's
681
674
  // `atBottom = sticky || (grew && scrollTop >= prevMaxScroll)`, which
682
675
  // likewise flips a user-broken sticky flag back on when the position says
683
- // bottom. Growth-gated (rowDelta > 0) for the same reason the ref gates on
684
- // `grew`: a shrink can put the offset at 0 as an artifact.
685
- if (!followingRef.current && rowDelta > 0 && currentTarget === 0) {
676
+ // bottom. Gated on `rowDelta >= 0` in exact parity with the reference's
677
+ // `grew = scrollHeight >= prevScrollHeight`: only a SHRINK is excluded
678
+ // (it can put the offset at 0 as an artifact). Demanding real growth was
679
+ // stricter than the reference and left the equal-height commits (a text
680
+ // replaced in place, a spinner/mode swap) falling through to the anchor
681
+ // capture below — which then locked the viewport at the tail with follow
682
+ // off, and every later row piled up below the fold.
683
+ if (!followingRef.current && rowDelta >= 0 && currentTarget === 0) {
686
684
  followingRef.current = true;
687
685
  }
688
686
  // Follow ownership is explicit. target=0 can also mean the first manual
@@ -774,7 +772,31 @@ export function useTranscriptWindow({
774
772
  maxRows,
775
773
  });
776
774
  if (desired == null) {
777
- // Anchor item gone (rare: removal/compaction). Re-capture next frame.
775
+ // Anchor item gone (removal/compaction). On GROWTH this is a transient
776
+ // identity gap — re-capture next frame. On a SHRINK the anchored rows
777
+ // were deleted: the reading position no longer exists, and leaving the
778
+ // lock in place pinned the target above zero forever, so the positional
779
+ // follow restore above (which needs target === 0) could never fire and
780
+ // auto-scroll stayed released for the rest of the session
781
+ // (user: 컴팩션된 이후로 자동 스크롤이 풀린다).
782
+ // A compaction commit can also NET-GROW the frame it lands in (it pushes
783
+ // its status row and keeps the streaming tail growing while it trims the
784
+ // head), so the row delta alone missed exactly the case it was written
785
+ // for. A bottom-relative target that no longer fits the transcript
786
+ // (currentTarget > maxRows) is the same deletion signal without the sign
787
+ // assumption: a transient identity gap during growth still fits, and the
788
+ // clamp effect below would otherwise silently park the target with follow
789
+ // left off.
790
+ if (rowDelta < 0 || currentTarget > maxRows) {
791
+ stopSmoothScroll();
792
+ transcriptAnchorRef.current = null;
793
+ transcriptAnchorDirtyRef.current = false;
794
+ scrollTargetRef.current = 0;
795
+ scrollPositionRef.current = 0;
796
+ followingRef.current = true;
797
+ if (currentOffset !== 0) setScrollOffset(0);
798
+ return;
799
+ }
778
800
  transcriptAnchorDirtyRef.current = true;
779
801
  return;
780
802
  }
@@ -837,6 +859,17 @@ export function useTranscriptWindow({
837
859
  }, [themeEpoch, withSelectionClip, paintSelectionRect]);
838
860
  useEffect(() => {
839
861
  const maxRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
862
+ // Opencode parity (createAutoScroll: `if (!canScroll(el)) userScrolled =
863
+ // false`, applied both on scroll and on the content resize; our desktop
864
+ // hook mirrors it in handleScroll/handleAutoScroll): a transcript that no
865
+ // longer OVERFLOWS holds no reading position, so a compaction or clear
866
+ // that shrinks it inside the viewport re-arms follow instead of leaving
867
+ // auto-scroll released with nothing left to scroll.
868
+ if (maxRows === 0 && !followingRef.current) {
869
+ transcriptAnchorRef.current = null;
870
+ transcriptAnchorDirtyRef.current = false;
871
+ followingRef.current = true;
872
+ }
840
873
  if (scrollTargetRef.current <= maxRows && scrollPositionRef.current <= maxRows && scrollOffset <= maxRows) return;
841
874
  stopSmoothScroll();
842
875
  const next = Math.max(0, Math.min(maxRows, scrollTargetRef.current));