mixdog 0.9.3 → 0.9.4

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 (329) hide show
  1. package/package.json +7 -3
  2. package/scripts/bench/lead-review-tasks-r3.json +20 -0
  3. package/scripts/bench/lead-review-tasks.json +20 -0
  4. package/scripts/bench/r4-mixed-tasks.json +20 -0
  5. package/scripts/bench/review-tasks.json +20 -0
  6. package/scripts/bench/round-codex.json +114 -0
  7. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  8. package/scripts/bench/round-mixdog-lead.json +269 -0
  9. package/scripts/bench/round-mixdog.json +126 -0
  10. package/scripts/bench/round-r10-bigsample.json +679 -0
  11. package/scripts/bench/round-r11-codexalign.json +257 -0
  12. package/scripts/bench/round-r4-codex.json +114 -0
  13. package/scripts/bench/round-r4-mixed.json +225 -0
  14. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  15. package/scripts/bench/round-r6-codex.json +114 -0
  16. package/scripts/bench/round-r6-solo.json +257 -0
  17. package/scripts/bench/round-r7-full.json +254 -0
  18. package/scripts/bench/round-r8-fulldefault.json +255 -0
  19. package/scripts/bench-run.mjs +215 -29
  20. package/scripts/freevar-smoke.mjs +95 -0
  21. package/scripts/internal-comms-bench.mjs +1 -0
  22. package/scripts/internal-comms-smoke.mjs +10 -9
  23. package/scripts/mouse-probe.mjs +45 -0
  24. package/scripts/output-style-bench.mjs +13 -6
  25. package/scripts/output-style-smoke.mjs +4 -4
  26. package/scripts/provider-toolcall-test.mjs +7 -3
  27. package/scripts/recall-usecase-cases.json +18 -0
  28. package/scripts/recall-usecase-probe.json +6 -0
  29. package/scripts/session-bench.mjs +152 -6
  30. package/scripts/tool-smoke.mjs +23 -63
  31. package/scripts/tui-render-smoke.mjs +90 -0
  32. package/scripts/webhook-smoke.mjs +208 -0
  33. package/src/agents/debugger/AGENT.md +4 -1
  34. package/src/agents/heavy-worker/AGENT.md +6 -5
  35. package/src/agents/maintainer/AGENT.md +4 -0
  36. package/src/agents/reviewer/AGENT.md +2 -1
  37. package/src/agents/worker/AGENT.md +8 -4
  38. package/src/lib/rules-builder.cjs +4 -0
  39. package/src/mixdog-session-runtime.mjs +632 -2042
  40. package/src/output-styles/default.md +34 -9
  41. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  42. package/src/output-styles/minimal.md +4 -1
  43. package/src/output-styles/simple.md +22 -7
  44. package/src/rules/agent/00-common.md +2 -0
  45. package/src/rules/lead/lead-brief.md +12 -0
  46. package/src/rules/lead/lead-tool.md +0 -11
  47. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  49. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  50. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  51. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  52. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  53. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  54. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +62 -20
  55. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  56. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  57. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +81 -1281
  58. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  59. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -3
  60. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  61. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  62. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  63. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  64. package/src/runtime/agent/orchestrator/providers/gemini.mjs +43 -1013
  65. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -3
  66. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +86 -11
  67. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +348 -0
  68. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  69. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  70. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +40 -1143
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +732 -0
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  75. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +297 -2123
  76. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +130 -1002
  77. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +227 -0
  78. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  79. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +436 -0
  80. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  82. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  83. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  84. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  85. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  86. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  87. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  88. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  89. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  90. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +14 -2
  91. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -3
  93. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +182 -0
  94. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  95. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  97. package/src/runtime/agent/orchestrator/session/loop.mjs +251 -397
  98. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  99. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +7 -4
  100. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +12 -0
  101. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  102. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  103. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  104. package/src/runtime/agent/orchestrator/session/manager.mjs +166 -1087
  105. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  106. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  107. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  108. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  109. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +32 -41
  110. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  111. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  113. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -92
  115. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  116. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +28 -265
  118. package/src/runtime/agent/orchestrator/tools/builtin.mjs +0 -6
  119. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +57 -3
  120. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  121. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +10 -122
  122. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  123. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  124. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  125. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  126. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  127. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  128. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  129. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  130. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  131. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  132. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  133. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  134. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +0 -21
  135. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -72
  136. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  137. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  138. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  139. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  140. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  141. package/src/runtime/channels/backends/discord.mjs +12 -292
  142. package/src/runtime/channels/index.mjs +229 -663
  143. package/src/runtime/channels/lib/backend-dispatch.mjs +44 -0
  144. package/src/runtime/channels/lib/event-pipeline.mjs +18 -1
  145. package/src/runtime/channels/lib/event-queue.mjs +63 -4
  146. package/src/runtime/channels/lib/inbound-routing.mjs +111 -0
  147. package/src/runtime/channels/lib/output-forwarder.mjs +1 -1
  148. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  149. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  150. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  151. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  152. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  153. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  154. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  155. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  156. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  157. package/src/runtime/channels/lib/webhook/deliveries.mjs +312 -0
  158. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  159. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  160. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  161. package/src/runtime/channels/lib/webhook.mjs +36 -570
  162. package/src/runtime/channels/tool-defs.mjs +11 -130
  163. package/src/runtime/memory/index.mjs +201 -1948
  164. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  165. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  166. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  167. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  168. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  169. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  170. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  171. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  172. package/src/runtime/memory/lib/memory-cycle2.mjs +24 -842
  173. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  174. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  175. package/src/runtime/memory/lib/memory-recall-store.mjs +22 -2
  176. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  177. package/src/runtime/memory/lib/query-handlers.mjs +780 -0
  178. package/src/runtime/memory/lib/recall-format.mjs +55 -0
  179. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  180. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  181. package/src/runtime/memory/tool-defs.mjs +5 -13
  182. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  183. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  184. package/src/runtime/search/lib/web-tools.mjs +24 -602
  185. package/src/runtime/shared/atomic-file.mjs +26 -1
  186. package/src/runtime/shared/launcher-control.mjs +2 -2
  187. package/src/runtime/shared/tool-primitives.mjs +308 -0
  188. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  189. package/src/runtime/shared/tool-surface.mjs +80 -898
  190. package/src/runtime/shared/transcript-writer.mjs +23 -0
  191. package/src/runtime/shared/update-checker.mjs +7 -4
  192. package/src/session-runtime/config-helpers.mjs +84 -2
  193. package/src/session-runtime/config-lifecycle.mjs +232 -0
  194. package/src/session-runtime/cwd-plugins.mjs +226 -0
  195. package/src/session-runtime/mcp-glue.mjs +177 -0
  196. package/src/session-runtime/model-recency.mjs +111 -0
  197. package/src/session-runtime/native-search.mjs +247 -0
  198. package/src/session-runtime/output-styles.mjs +11 -9
  199. package/src/session-runtime/prewarm.mjs +142 -0
  200. package/src/session-runtime/provider-models.mjs +278 -0
  201. package/src/session-runtime/provider-usage.mjs +120 -0
  202. package/src/session-runtime/quick-model-rows.mjs +170 -0
  203. package/src/session-runtime/quick-search-models.mjs +46 -0
  204. package/src/session-runtime/session-hooks.mjs +93 -0
  205. package/src/session-runtime/settings-api.mjs +319 -0
  206. package/src/session-runtime/tool-catalog.mjs +29 -29
  207. package/src/session-runtime/tool-defs.mjs +84 -0
  208. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  209. package/src/standalone/agent-tool/helpers.mjs +237 -0
  210. package/src/standalone/agent-tool/notify.mjs +107 -0
  211. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  212. package/src/standalone/agent-tool/render.mjs +152 -0
  213. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  214. package/src/standalone/agent-tool.mjs +110 -671
  215. package/src/standalone/channel-worker.mjs +4 -7
  216. package/src/standalone/explore-tool.mjs +30 -9
  217. package/src/standalone/hook-bus/config.mjs +207 -0
  218. package/src/standalone/hook-bus/constants.mjs +90 -0
  219. package/src/standalone/hook-bus/handlers.mjs +481 -0
  220. package/src/standalone/hook-bus/payload.mjs +31 -0
  221. package/src/standalone/hook-bus/rules.mjs +77 -0
  222. package/src/standalone/hook-bus.mjs +77 -870
  223. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  224. package/src/standalone/opencode-go-login.mjs +5 -1
  225. package/src/standalone/provider-admin.mjs +1 -16
  226. package/src/standalone/usage-dashboard.mjs +3 -1
  227. package/src/tui/App.jsx +945 -8094
  228. package/src/tui/app/app-format.mjs +206 -0
  229. package/src/tui/app/channel-pickers.mjs +510 -0
  230. package/src/tui/app/clipboard.mjs +67 -0
  231. package/src/tui/app/core-memory-picker.mjs +210 -0
  232. package/src/tui/app/extension-pickers.mjs +506 -0
  233. package/src/tui/app/input-parsers.mjs +193 -0
  234. package/src/tui/app/maintenance-pickers.mjs +324 -0
  235. package/src/tui/app/model-options.mjs +330 -0
  236. package/src/tui/app/model-picker.mjs +365 -0
  237. package/src/tui/app/onboarding-steps.mjs +400 -0
  238. package/src/tui/app/project-picker.mjs +247 -0
  239. package/src/tui/app/provider-setup-picker.mjs +580 -0
  240. package/src/tui/app/resume-picker.mjs +55 -0
  241. package/src/tui/app/route-pickers.mjs +419 -0
  242. package/src/tui/app/settings-picker.mjs +490 -0
  243. package/src/tui/app/slash-commands.mjs +101 -0
  244. package/src/tui/app/slash-dispatch.mjs +427 -0
  245. package/src/tui/app/text-layout.mjs +46 -0
  246. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  247. package/src/tui/app/transcript-window.mjs +671 -0
  248. package/src/tui/app/use-mouse-input.mjs +460 -0
  249. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  250. package/src/tui/app/use-transcript-scroll.mjs +510 -0
  251. package/src/tui/app/use-transcript-window.mjs +589 -0
  252. package/src/tui/components/ConfirmBar.jsx +1 -1
  253. package/src/tui/components/Picker.jsx +32 -4
  254. package/src/tui/components/PromptInput.jsx +23 -101
  255. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  256. package/src/tui/components/StatusLine.jsx +63 -12
  257. package/src/tui/components/TextEntryPanel.jsx +11 -0
  258. package/src/tui/components/ToolExecution.jsx +52 -594
  259. package/src/tui/components/TranscriptItem.jsx +105 -0
  260. package/src/tui/components/UsagePanel.jsx +18 -4
  261. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  262. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  263. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  264. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  265. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  266. package/src/tui/display-width.mjs +20 -3
  267. package/src/tui/dist/index.mjs +19652 -18630
  268. package/src/tui/engine/agent-job-feed.mjs +133 -0
  269. package/src/tui/engine/notification-plan.mjs +76 -0
  270. package/src/tui/engine/render-timing.mjs +17 -0
  271. package/src/tui/engine/tool-approval.mjs +94 -0
  272. package/src/tui/engine/tool-card-results.mjs +234 -0
  273. package/src/tui/engine/tool-result-status.mjs +135 -0
  274. package/src/tui/engine.mjs +122 -562
  275. package/src/tui/figures.mjs +5 -0
  276. package/src/tui/index.jsx +105 -0
  277. package/src/tui/input-editing.mjs +2 -2
  278. package/src/tui/markdown/format-token.mjs +4 -1
  279. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  280. package/src/tui/theme.mjs +6 -0
  281. package/src/ui/statusline-agents.mjs +213 -0
  282. package/src/ui/statusline-format.mjs +146 -0
  283. package/src/ui/statusline-segments.mjs +148 -0
  284. package/src/ui/statusline.mjs +67 -501
  285. package/src/ui/tool-card.mjs +0 -1
  286. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  287. package/src/workflows/default/WORKFLOW.md +1 -1
  288. package/src/workflows/sequential/WORKFLOW.md +1 -1
  289. package/vendor/ink/build/display-width.js +19 -3
  290. package/vendor/ink/build/ink.js +103 -6
  291. package/vendor/ink/build/log-update.js +17 -3
  292. package/vendor/ink/build/wrap-text.js +125 -0
  293. package/scripts/_test-folder-dialog.mjs +0 -30
  294. package/scripts/fix-brief-fn.mjs +0 -35
  295. package/scripts/fix-format-tool-surface.mjs +0 -24
  296. package/scripts/fix-tool-exec-visible.mjs +0 -42
  297. package/scripts/patch-agent-brief.mjs +0 -48
  298. package/scripts/patch-app.mjs +0 -21
  299. package/scripts/patch-app2.mjs +0 -18
  300. package/scripts/patch-dist-brief.mjs +0 -96
  301. package/scripts/patch-tool-exec.mjs +0 -70
  302. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  303. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  304. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  305. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  306. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  307. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +0 -162
  308. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  309. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  310. package/src/runtime/shared/task-notification-envelope.test.mjs +0 -107
  311. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  312. package/src/standalone/agent-task-status.test.mjs +0 -76
  313. package/src/tui/components/tool-output-format.test.mjs +0 -399
  314. package/src/tui/display-width.test.mjs +0 -35
  315. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  316. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  317. package/src/tui/input-editing.selection.test.mjs +0 -75
  318. package/src/tui/markdown/format-token.test.mjs +0 -354
  319. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  320. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  321. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  322. package/src/tui/paste-fix.test.mjs +0 -119
  323. package/src/tui/prompt-history-store.test.mjs +0 -52
  324. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  325. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  326. package/src/ui/markdown.test.mjs +0 -70
  327. package/src/ui/statusline-context-label.test.mjs +0 -15
  328. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  329. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -0,0 +1,589 @@
1
+ /**
2
+ * use-transcript-window.mjs — transcript row-index/window memo chain + the
3
+ * measured-height harvest and reading-anchor lock effects.
4
+ *
5
+ * Extracted verbatim from App.jsx: structure-signature/row-index/render-window
6
+ * memos, the same-frame anchor lock + capture, the per-commit Yoga height
7
+ * harvest, bottom-follow/anchor post-commit sync, selection layout shift and
8
+ * the scroll clamp. Scroll/anchor/drag refs stay App-owned (injected); this
9
+ * hook owns the measurement maps, measuredRowsVersion and the totalRows/
10
+ * preserved-delta bookkeeping refs.
11
+ */
12
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
13
+ import {
14
+ TRANSCRIPT_MEASURED_ROWS,
15
+ transcriptMeasuredRowsCache,
16
+ transcriptItemVariantKey,
17
+ buildTranscriptRowIndex,
18
+ transcriptStructureSignature,
19
+ transcriptRenderWindow,
20
+ resolveAnchorScrollOffset,
21
+ upperBound,
22
+ shiftSelectionRectY,
23
+ } from './transcript-window.mjs';
24
+ import { shouldSuppressFullyFailedToolItem } from '../transcript-tool-failures.mjs';
25
+
26
+ export function useTranscriptWindow({
27
+ items,
28
+ themeEpoch,
29
+ frameColumns,
30
+ toolOutputExpanded,
31
+ transcriptContentHeight,
32
+ transcriptBottomSlackRows,
33
+ transcriptGuardRows,
34
+ floatingPanelRows,
35
+ overlayHintRequested,
36
+ scrollOffset,
37
+ setScrollOffset,
38
+ transcriptAnchorRef,
39
+ transcriptAnchorDirtyRef,
40
+ scrollTargetRef,
41
+ scrollPositionRef,
42
+ maxScrollRowsRef,
43
+ transcriptGeomRef,
44
+ followingRef,
45
+ dragRef,
46
+ transcriptViewportRef,
47
+ selectionLayoutRef,
48
+ withSelectionClip,
49
+ paintSelectionRect,
50
+ stopSmoothScroll,
51
+ measuredRowsVersion,
52
+ setMeasuredRowsVersion,
53
+ }) {
54
+ const transcriptTotalRowsRef = useRef(0);
55
+ const preservedScrollDeltaRef = useRef(0);
56
+ // Previous frame's viewport-only geometry (content height + floating-panel
57
+ // reservation). A floating panel / view (picker/context/usage/text-entry)
58
+ // open-close changes transcriptContentHeight WITHOUT changing `items`, so the
59
+ // transcript window would re-slice against the new viewport height while the
60
+ // scroll offset still reflects the old one — the visible top row jumps, then
61
+ // the post-commit anchor effect snaps it back a frame later (the 2-frame
62
+ // re-settle). We detect that viewport-only change at render time and freeze
63
+ // the visible top anchor from the PREVIOUS geometry, resolving the scroll
64
+ // offset for THIS render so the anchored row stays put in a single frame.
65
+ const prevViewportGeomRef = useRef({ contentHeight: 0, floatingPanelRows: 0 });
66
+ // App-level measured row heights (real per-item height cache). The map of
67
+ // mounted item id → ink DOM element is read every commit to harvest each
68
+ // row's REAL Yoga height into transcriptMeasuredRowsCache. measuredRowsVersion
69
+ // is bumped whenever a height actually changes so the row-index/window memos
70
+ // recompute against the corrected heights (one-frame lag, absorbed by the
71
+ // overscan band).
72
+ const transcriptItemElsRef = useRef(new Map());
73
+ const transcriptMeasureRefCache = useRef(new Map());
74
+ // id → latest item object for this render. The callback ref reads from here so
75
+ // a reused (stable) callback never captures a stale item across patches.
76
+ const transcriptMeasureItemsRef = useRef(new Map());
77
+ // Stable per-item callback-ref factory: storing the element under the item id
78
+ // (and reading the live item object from transcriptMeasureItemsRef) avoids the
79
+ // ref-swap churn React would otherwise cause with an inline closure each
80
+ // render, while never serving a stale item: the callback resolves the current
81
+ // item by id at call time. The ref(null) path drops the element; the harvest
82
+ // reads getComputedHeight from whatever is mounted, so an unmount simply stops
83
+ // contributing (its last measurement stays cached on the item object).
84
+ const transcriptMeasureRef = useCallback((item) => {
85
+ if (!TRANSCRIPT_MEASURED_ROWS || !item || item.id == null) return undefined;
86
+ if (shouldSuppressFullyFailedToolItem(item)) {
87
+ transcriptMeasuredRowsCache.delete(item);
88
+ transcriptItemElsRef.current.delete(item.id);
89
+ transcriptMeasureItemsRef.current.delete(item.id);
90
+ return undefined;
91
+ }
92
+ const key = item.id;
93
+ transcriptMeasureItemsRef.current.set(key, item);
94
+ let fn = transcriptMeasureRefCache.current.get(key);
95
+ if (!fn) {
96
+ fn = (el) => {
97
+ if (el) {
98
+ transcriptItemElsRef.current.set(key, el);
99
+ } else {
100
+ transcriptItemElsRef.current.delete(key);
101
+ }
102
+ };
103
+ transcriptMeasureRefCache.current.set(key, fn);
104
+ }
105
+ return fn;
106
+ }, []);
107
+
108
+ // Key the heavy O(n) row-index + windowing memos on a STRUCTURE signature
109
+ // instead of the `items` array identity. The engine swaps `items`
110
+ // for a new array on every streaming flush (~8ms) while only the final
111
+ // assistant item's text grows; depending on array identity re-ran both memos
112
+ // each delta frame and visibly throttled the stream. The signature changes
113
+ // only when transcript structure or the streaming item's estimated height
114
+ // changes, so steady per-character growth keeps both memos warm.
115
+ //
116
+ // The signature itself is memoized on `items` identity (+columns/
117
+ // expanded) so re-renders that DO NOT touch items — drag motion, scroll,
118
+ // input typing — skip the O(n) signature walk entirely. During streaming the
119
+ // engine hands us a fresh `items` array each flush, so this memo
120
+ // recomputes and still tracks the streaming item's height correctly.
121
+ const transcriptStructureSig = useMemo(
122
+ () => transcriptStructureSignature(items, frameColumns, toolOutputExpanded),
123
+ [items, frameColumns, toolOutputExpanded],
124
+ );
125
+ const transcriptRowIndex = useMemo(() => buildTranscriptRowIndex(items, {
126
+ columns: frameColumns,
127
+ toolOutputExpanded,
128
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: sig captures the relevant item changes; measuredRowsVersion folds in app-level measured height corrections
129
+ }), [transcriptStructureSig, measuredRowsVersion]);
130
+ // ── Same-frame anchor lock ───────────────────────────────────────────────
131
+ // While the user reads older transcript (anchor captured, not dirty), resolve
132
+ // the scroll offset that keeps the anchored viewport-top row fixed for THIS
133
+ // frame's prefix table — SYNCHRONOUSLY, before windowing/marginBottom use it.
134
+ // Previously this correction lived only in the post-commit layout effect, so
135
+ // the frame that grew the streaming tail rendered with the STALE offset first
136
+ // (a blank band opened at the top) and only the NEXT frame snapped it shut.
137
+ // Computing it here folds the new totalRows and the corrected offset into the
138
+ // SAME render, so the completed line fills its space with no one-frame gap.
139
+ // Falls back to the live scrollOffset state when there is no active anchor
140
+ // (bottom-follow / pinned) or it cannot be aligned (anchor item gone).
141
+ const hasReadingAnchor = !!transcriptAnchorRef.current && !transcriptAnchorDirtyRef.current;
142
+ const scrolledUpRows = Math.max(0, Number(scrollTargetRef.current) || 0);
143
+ // "Genuinely scrolled up" = the viewport is above the bottom slack band. The
144
+ // bottom-follow / pinned path owns everything at-or-below the slack; anything
145
+ // above it is the user reading older transcript.
146
+ const scrolledUp = scrolledUpRows > transcriptBottomSlackRows;
147
+ // A genuine reading anchor wins even if followingRef is stale-true while the
148
+ // user is scrolled up. The follow-arm SHOULD have been cleared when the anchor
149
+ // was captured, but if it lingers true we must not fall through to the stale-
150
+ // offset render path (that is one half of the newline-jump bug). Keep the
151
+ // plain !following gate for the anchor-less follow case.
152
+ const anchorLockActive = hasReadingAnchor && !followingRef.current && scrolledUp;
153
+ const targetNearBottom = followingRef.current || !scrolledUp;
154
+ const nearBottomWithoutAnchor = !transcriptAnchorRef.current
155
+ && !transcriptAnchorDirtyRef.current
156
+ && targetNearBottom;
157
+ let renderScrollOffset = targetNearBottom ? 0 : scrollOffset;
158
+ const lockViewRows = Math.max(1, Number(transcriptContentHeight) || 1);
159
+ const lockTotalRows = Math.max(0, Number(transcriptRowIndex?.totalRows) || 0);
160
+ const lockMaxRows = Math.max(0, lockTotalRows - lockViewRows);
161
+ const curPrefixForLock = transcriptRowIndex?.prefixRows || null;
162
+ if (anchorLockActive) {
163
+ const locked = resolveAnchorScrollOffset({
164
+ anchor: transcriptAnchorRef.current,
165
+ items: items,
166
+ curPrefix: curPrefixForLock,
167
+ totalRows: lockTotalRows,
168
+ viewRows: lockViewRows,
169
+ maxRows: lockMaxRows,
170
+ });
171
+ if (locked != null) renderScrollOffset = locked;
172
+ } else if (!followingRef.current && !nearBottomWithoutAnchor && scrolledUp) {
173
+ // ── Same-frame anchor CAPTURE for the missing/dirty-anchor case ─────────
174
+ // The viewport is genuinely scrolled up but there is NO usable same-frame
175
+ // anchor: it is missing, or dirtied by a manual scroll whose synchronous
176
+ // capture failed, or a stale-true follow-arm already dropped it. Without an
177
+ // anchor this frame would render with the STALE bottom-relative
178
+ // scrollOffset, so any row growth THIS frame (a streaming newline
179
+ // completing a line, a transcript row expanding) shifts the visible top by
180
+ // the delta BEFORE the post-commit rowDelta effect can capture/correct — and
181
+ // that effect would then capture from the ALREADY-shifted totalRows. That is
182
+ // the confirmed one-frame jump/jitter.
183
+ //
184
+ // Fix it at render time: capture an anchor from the PREVIOUS published
185
+ // geometry (transcriptGeomRef still holds the prior frame here — THIS
186
+ // frame's geom is published a few lines below), identifying the item id +
187
+ // row offset that sat at the previous visible-TOP edge, then resolve the
188
+ // offset against the CURRENT prefix table with the same pure helper so that
189
+ // exact top row stays put THIS frame. Persist the captured anchor so the
190
+ // post-commit effect keeps the identical anchor stable instead of
191
+ // re-deriving one from the already-shifted totalRows.
192
+ const geom = transcriptGeomRef.current || {};
193
+ const prevPrefix = geom.prefixRows;
194
+ if (Array.isArray(prevPrefix) && prevPrefix.length > 1) {
195
+ const prevTotal = Math.max(0, Number(geom.totalRows) || 0);
196
+ const prevView = Math.max(1, Number(geom.viewRows) || 1);
197
+ const prevOffset = Math.max(0, Number(geom.renderOffset) || 0);
198
+ const prevItems = geom.items || [];
199
+ // Bottom-relative window math (same as transcriptRenderWindow): the top
200
+ // edge sits `offset + viewRows` rows up from the previous total.
201
+ const prevTopRow = Math.max(0, Math.min(prevTotal, prevTotal - prevOffset - prevView));
202
+ let idx = upperBound(prevPrefix, prevTopRow) - 1;
203
+ if (idx < 0) idx = 0;
204
+ if (idx > prevPrefix.length - 2) idx = prevPrefix.length - 2;
205
+ const anchorItem = prevItems[idx];
206
+ if (anchorItem && anchorItem.id != null) {
207
+ const captured = { id: anchorItem.id, offset: Math.max(0, prevTopRow - (prevPrefix[idx] || 0)) };
208
+ const locked = resolveAnchorScrollOffset({
209
+ anchor: captured,
210
+ items: items,
211
+ curPrefix: curPrefixForLock,
212
+ totalRows: lockTotalRows,
213
+ viewRows: lockViewRows,
214
+ maxRows: lockMaxRows,
215
+ });
216
+ if (locked != null) {
217
+ renderScrollOffset = locked;
218
+ transcriptAnchorRef.current = captured;
219
+ transcriptAnchorDirtyRef.current = false;
220
+ }
221
+ }
222
+ }
223
+ }
224
+ // ── Viewport-only transition freeze (floating panel / view open-close) ─────
225
+ // When a picker/context/usage/text-entry panel opens or closes, only the
226
+ // transcript viewport height changes (transcriptContentHeight and/or the
227
+ // floating-panel reservation) — `items` and the row-index are unchanged. The
228
+ // bottom-follow / anchor-lock paths above only fire when the user is reading
229
+ // older transcript; a bottom-pinned transcript falls through with
230
+ // renderScrollOffset resolved against the NEW viewport while the on-screen
231
+ // top edge still reflects the OLD one, producing the visible jump that the
232
+ // post-commit effect then corrects one frame later. Freeze it here: when the
233
+ // ONLY thing that changed is the viewport (not the anchor state, not items),
234
+ // reconstruct the previous visible-top row from the prior published geometry
235
+ // and resolve the offset against the current prefix table so the same top row
236
+ // renders THIS frame. Bottom-pinned/following views are already stable
237
+ // (flex-end owns them), so restrict the freeze to the scrolled-up, no-active-
238
+ // lock case that the branches above did not already resolve.
239
+ const prevViewport = prevViewportGeomRef.current || {};
240
+ const viewportOnlyChanged = (Number(prevViewport.contentHeight) || 0) !== lockViewRows
241
+ || (Number(prevViewport.floatingPanelRows) || 0) !== (Number(floatingPanelRows) || 0);
242
+ if (viewportOnlyChanged
243
+ && !anchorLockActive
244
+ && !followingRef.current
245
+ && !nearBottomWithoutAnchor
246
+ && scrolledUp) {
247
+ const geom = transcriptGeomRef.current || {};
248
+ const prevPrefix = geom.prefixRows;
249
+ if (Array.isArray(prevPrefix) && prevPrefix.length > 1) {
250
+ const prevTotal = Math.max(0, Number(geom.totalRows) || 0);
251
+ // Use the PREVIOUS viewport rows to reconstruct where the top edge sat on
252
+ // screen before the panel changed the height.
253
+ const prevView = Math.max(1, Number(prevViewport.contentHeight) || Number(geom.viewRows) || 1);
254
+ const prevOffset = Math.max(0, Number(geom.renderOffset) || 0);
255
+ const prevItems = geom.items || [];
256
+ const prevTopRow = Math.max(0, Math.min(prevTotal, prevTotal - prevOffset - prevView));
257
+ let idx = upperBound(prevPrefix, prevTopRow) - 1;
258
+ if (idx < 0) idx = 0;
259
+ if (idx > prevPrefix.length - 2) idx = prevPrefix.length - 2;
260
+ const anchorItem = prevItems[idx];
261
+ if (anchorItem && anchorItem.id != null) {
262
+ const captured = { id: anchorItem.id, offset: Math.max(0, prevTopRow - (prevPrefix[idx] || 0)) };
263
+ const locked = resolveAnchorScrollOffset({
264
+ anchor: captured,
265
+ items: items,
266
+ curPrefix: curPrefixForLock,
267
+ totalRows: lockTotalRows,
268
+ viewRows: lockViewRows,
269
+ maxRows: lockMaxRows,
270
+ });
271
+ if (locked != null) {
272
+ renderScrollOffset = locked;
273
+ // Persist so the post-commit effect keeps this exact anchor stable
274
+ // rather than re-deriving one from the already-resized geometry.
275
+ transcriptAnchorRef.current = captured;
276
+ transcriptAnchorDirtyRef.current = false;
277
+ }
278
+ }
279
+ }
280
+ }
281
+ prevViewportGeomRef.current = {
282
+ contentHeight: lockViewRows,
283
+ floatingPanelRows: Number(floatingPanelRows) || 0,
284
+ };
285
+ const transcriptWindow = useMemo(() => transcriptRenderWindow(items, {
286
+ scrollOffset: renderScrollOffset,
287
+ viewportHeight: transcriptContentHeight,
288
+ columns: frameColumns,
289
+ toolOutputExpanded,
290
+ rowIndex: transcriptRowIndex,
291
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: sig+scroll/viewport capture the relevant changes
292
+ }), [transcriptStructureSig, renderScrollOffset, transcriptContentHeight, transcriptRowIndex]);
293
+ maxScrollRowsRef.current = transcriptWindow.maxScrollRows;
294
+ // Publish this frame's geometry so a manual scroll can capture the reading
295
+ // anchor synchronously (see captureTranscriptAnchorAt).
296
+ transcriptGeomRef.current = {
297
+ prefixRows: transcriptRowIndex?.prefixRows || null,
298
+ totalRows: Math.max(0, Number(transcriptWindow.totalRows) || 0),
299
+ viewRows: Math.max(1, Number(transcriptContentHeight) || 1),
300
+ items: items || null,
301
+ // The offset THIS frame actually rendered with. The same-frame anchor
302
+ // CAPTURE for a missing/dirty anchor (above) reads this from the PREVIOUS
303
+ // frame to reconstruct the exact top-edge row that was on screen, so the
304
+ // capture matches what the user saw rather than the stale scrollOffset
305
+ // state. Bottom-relative, matching transcriptRenderWindow's window math.
306
+ renderOffset: Math.max(0, Number(renderScrollOffset) || 0),
307
+ };
308
+ // The window memo is keyed on a structure signature that intentionally
309
+ // ignores per-character growth of the streaming assistant text, so its
310
+ // `items` slice can hold a STALE reference to the streaming item between
311
+ // height changes. Re-slice the live `items` over the memo's stable
312
+ // [startIndex, endIndex) bounds so the on-screen text is always current
313
+ // while the expensive indexing/windowing stays warm.
314
+ const transcriptVisibleItems = (items || []).slice(
315
+ transcriptWindow.startIndex,
316
+ transcriptWindow.endIndex,
317
+ );
318
+ // The bottom meta band is spinner-only, so nothing is pulled out of the
319
+ // transcript for it. A finished turn's done row (turndone/statusdone) renders
320
+ // inline in scrollback like any other item — no filtering, no double-paint.
321
+ const renderedTranscriptItems = transcriptVisibleItems;
322
+ let overlayHintAttachItemIndex = -1;
323
+ for (let i = renderedTranscriptItems.length - 1; i >= 0; i--) {
324
+ const item = renderedTranscriptItems[i];
325
+ if (item?.kind === 'tool' && shouldSuppressFullyFailedToolItem(item)) continue;
326
+ overlayHintAttachItemIndex = i;
327
+ break;
328
+ }
329
+ const transcriptTailPinned = Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0) <= transcriptBottomSlackRows;
330
+ const overlayHintOnLastItem = overlayHintRequested
331
+ && floatingPanelRows <= 0
332
+ && transcriptWindow.bottomSpacerRows === 0
333
+ && transcriptTailPinned
334
+ && overlayHintAttachItemIndex >= 0;
335
+ const overlayHintFallbackRow = overlayHintRequested
336
+ && floatingPanelRows <= 0
337
+ && transcriptGuardRows > 0
338
+ && !overlayHintOnLastItem;
339
+ // ── App-level measured height harvest ───────────────────────────────────
340
+ // Runs after EVERY commit (no deps): Yoga has just laid out the mounted rows,
341
+ // so each tracked item Box's getComputedHeight() is its REAL terminal height.
342
+ // Fold those into transcriptMeasuredRowsCache (validated on the same variant
343
+ // key the estimate caches use) and bump measuredRowsVersion only when a height
344
+ // actually changed — that re-runs the row-index/window memos against corrected
345
+ // heights, then the harvest finds nothing new and the loop settles (one frame,
346
+ // overscan-absorbed). Streaming
347
+ // assistant rows are skipped: their height churns every flush and the bottom-
348
+ // follow path already keeps them visually stable.
349
+ useLayoutEffect(() => {
350
+ if (!TRANSCRIPT_MEASURED_ROWS) return;
351
+ // Skip the per-row Yoga harvest while a drag is in progress. Edge auto-
352
+ // scroll commits setScrollOffset on every pointer motion, but the mounted
353
+ // rows' real heights do not change during a drag — only their scroll
354
+ // position does. Re-measuring every motion ran this O(mounted) loop (plus a
355
+ // variantKey check per row) for no height change, which is pure drag
356
+ // overhead on a tall transcript. The cached measurements stay authoritative
357
+ // for the row-index math; a single re-measure is forced on release below.
358
+ if (dragRef.current.active) return;
359
+ const els = transcriptItemElsRef.current;
360
+ if (!els || els.size === 0) return;
361
+ const liveItems = transcriptMeasureItemsRef.current;
362
+ const toolExpandedFlag = toolOutputExpanded ? 1 : 0;
363
+ let changed = false;
364
+ for (const [key, el] of els.entries()) {
365
+ const item = liveItems.get(key);
366
+ const yoga = el?.yogaNode;
367
+ if (!item || !yoga) continue;
368
+ if (shouldSuppressFullyFailedToolItem(item)) {
369
+ if (transcriptMeasuredRowsCache.delete(item)) changed = true;
370
+ continue;
371
+ }
372
+ if (item.kind === 'assistant' && item.streaming) continue;
373
+ // Width 0 = Yoga has not laid this node out yet this frame; skip so a
374
+ // transient 0 never poisons the cache (guard on a real positive width).
375
+ if (typeof yoga.getComputedWidth === 'function' && yoga.getComputedWidth() <= 0) continue;
376
+ const rawMeasured = Math.round(Number(yoga.getComputedHeight?.()) || 0);
377
+ if (rawMeasured <= 0) {
378
+ if (transcriptMeasuredRowsCache.delete(item)) changed = true;
379
+ continue;
380
+ }
381
+ const measured = Math.max(1, rawMeasured);
382
+ const variantKey = transcriptItemVariantKey(item);
383
+ const prev = transcriptMeasuredRowsCache.get(item);
384
+ if (prev
385
+ && prev.rows === measured
386
+ && prev.columns === frameColumns
387
+ && prev.toolExpanded === toolExpandedFlag
388
+ && prev.variantKey === variantKey) {
389
+ continue;
390
+ }
391
+ transcriptMeasuredRowsCache.set(item, {
392
+ rows: measured,
393
+ columns: frameColumns,
394
+ toolExpanded: toolExpandedFlag,
395
+ variantKey,
396
+ });
397
+ changed = true;
398
+ }
399
+ if (changed) setMeasuredRowsVersion((v) => (v + 1) % 1000000);
400
+ // Prune the id→item / id→callback maps to the currently-mounted set so they
401
+ // do not grow unbounded over a long session. `els` is the live mounted set
402
+ // (ref(null) deletes on unmount), so anything not in it is gone.
403
+ if (liveItems.size > els.size) {
404
+ for (const key of liveItems.keys()) {
405
+ if (!els.has(key)) liveItems.delete(key);
406
+ }
407
+ }
408
+ const refCache = transcriptMeasureRefCache.current;
409
+ if (refCache.size > els.size) {
410
+ for (const key of refCache.keys()) {
411
+ if (!els.has(key)) refCache.delete(key);
412
+ }
413
+ }
414
+ });
415
+ useLayoutEffect(() => {
416
+ const totalRows = Math.max(0, Number(transcriptWindow.totalRows) || 0);
417
+ const previousTotalRows = Math.max(0, Number(transcriptTotalRowsRef.current) || 0);
418
+ transcriptTotalRowsRef.current = totalRows;
419
+ const rowDelta = totalRows - previousTotalRows;
420
+ const curPrefix = transcriptRowIndex?.prefixRows || null;
421
+ if (previousTotalRows <= 0 || dragRef.current.active) return;
422
+
423
+ const currentTarget = Math.max(0, Number(scrollTargetRef.current) || 0);
424
+ const currentPosition = Math.max(0, Number(scrollPositionRef.current) || 0);
425
+ const currentOffset = Math.max(0, Number(scrollOffset) || 0);
426
+ const maxRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
427
+ const nearBottom = followingRef.current || currentTarget <= transcriptBottomSlackRows;
428
+ const pinnedToBottom = nearBottom;
429
+ // A genuine reading anchor must win over ordinary stream growth, but an
430
+ // explicit follow arm (prompt submit / pinned bottom) must win over stale
431
+ // anchor state. Manual scroll cancels followingRef before anchor capture, so
432
+ // deliberate reading still stays anchored while automatic bottom-follow
433
+ // stays armed across spinner/tool/stream height corrections.
434
+ const activeReadingAnchor = !!transcriptAnchorRef.current
435
+ && !transcriptAnchorDirtyRef.current
436
+ && !followingRef.current
437
+ && !nearBottom;
438
+ const followOnGrowth = followingRef.current && rowDelta > 0 && !activeReadingAnchor;
439
+ const shouldFollowBottom = rowDelta > 0 && (followOnGrowth || pinnedToBottom);
440
+ if (shouldFollowBottom) {
441
+ // Bottom follow: while pinned to the newest output,
442
+ // do NOT animate row growth. The viewport is already bottom-aligned by
443
+ // justifyContent:flex-end; injecting a temporary positive scroll offset
444
+ // during streaming makes the transcript jump down/up and can clip the
445
+ // currently generated assistant text. Keep all scroll refs at zero so
446
+ // character generation stays visually stable.
447
+ stopSmoothScroll();
448
+ scrollTargetRef.current = 0;
449
+ scrollPositionRef.current = 0;
450
+ transcriptAnchorRef.current = null;
451
+ transcriptAnchorDirtyRef.current = false;
452
+ if (currentOffset !== 0) setScrollOffset(0);
453
+ return;
454
+ }
455
+
456
+ // Viewport-only changes, such as swapping TextEntryPanel for a picker, must
457
+ // not turn a bottom-pinned transcript into a reading-anchor lock.
458
+ if (rowDelta <= 0 && nearBottom) {
459
+ stopSmoothScroll();
460
+ scrollTargetRef.current = 0;
461
+ scrollPositionRef.current = 0;
462
+ transcriptAnchorRef.current = null;
463
+ transcriptAnchorDirtyRef.current = false;
464
+ if (currentOffset !== 0) setScrollOffset(0);
465
+ return;
466
+ }
467
+
468
+ // ── ABSOLUTE ANCHOR LOCK ──────────────────────────────────────────────
469
+ // User is reading older transcript. Instead of folding the per-frame row
470
+ // DELTA into scrollOffset (which accumulated streaming-tail estimate jitter
471
+ // and could fall back to the whole-total delta on a misalignment — the
472
+ // "newline makes the screen jump" bug), we pin an ABSOLUTE anchor: the item
473
+ // id + row offset sitting at the viewport TOP edge. Every commit we look that
474
+ // item up in the CURRENT prefix table and re-derive scrollOffset so the
475
+ // anchored row stays at the same screen position. Any height change BELOW the
476
+ // anchor (streaming tail growth, a result landing under the reading position)
477
+ // only moves the bottom — the top is rock-stable. Changes ABOVE the anchor
478
+ // move the item's prefix start and are absorbed the same way. No deltas, no
479
+ // fallback, no drift.
480
+ const viewRows = Math.max(1, Number(transcriptContentHeight) || 1);
481
+ const itemList = items || [];
482
+ let anchor = transcriptAnchorRef.current;
483
+ // (Re)capture the anchor from the current viewport-top edge when missing or
484
+ // invalidated by a manual scroll. anchorRow = absolute row at the top edge.
485
+ if (!followingRef.current && (!anchor || transcriptAnchorDirtyRef.current)) {
486
+ if (curPrefix && curPrefix.length > 1) {
487
+ const anchorRow = Math.max(0, Math.min(totalRows, totalRows - currentTarget - viewRows));
488
+ let idx = upperBound(curPrefix, anchorRow) - 1;
489
+ if (idx < 0) idx = 0;
490
+ if (idx > itemList.length - 1) idx = itemList.length - 1;
491
+ const anchorItem = itemList[idx];
492
+ if (anchorItem && anchorItem.id != null) {
493
+ anchor = { id: anchorItem.id, offset: Math.max(0, anchorRow - (curPrefix[idx] || 0)) };
494
+ transcriptAnchorRef.current = anchor;
495
+ }
496
+ }
497
+ transcriptAnchorDirtyRef.current = false;
498
+ // Just captured at the current offset; nothing to correct this frame.
499
+ return;
500
+ }
501
+ // Resolve the anchored item's CURRENT position with the SAME pure helper the
502
+ // render path uses, so the post-commit state sync can never disagree with the
503
+ // synchronous render correction (which already fixed the screen this frame).
504
+ const desired = resolveAnchorScrollOffset({
505
+ anchor,
506
+ items: itemList,
507
+ curPrefix,
508
+ totalRows,
509
+ viewRows,
510
+ maxRows,
511
+ });
512
+ if (desired == null) {
513
+ // Anchor item gone (rare: removal/compaction). Re-capture next frame.
514
+ transcriptAnchorDirtyRef.current = true;
515
+ return;
516
+ }
517
+ const appliedDelta = desired - currentTarget;
518
+ if (appliedDelta === 0) return;
519
+
520
+ stopSmoothScroll();
521
+ scrollTargetRef.current = desired;
522
+ scrollPositionRef.current = Math.max(0, Math.min(maxRows, currentPosition + appliedDelta));
523
+ preservedScrollDeltaRef.current += appliedDelta;
524
+ setScrollOffset(Math.max(0, Math.round(desired)));
525
+ }, [transcriptWindow.totalRows, transcriptWindow.maxScrollRows, transcriptRowIndex, transcriptContentHeight, transcriptBottomSlackRows, scrollOffset, stopSmoothScroll]);
526
+ useLayoutEffect(() => {
527
+ if (transcriptBottomSlackRows <= 0) return;
528
+ if (transcriptAnchorRef.current || transcriptAnchorDirtyRef.current || followingRef.current) return;
529
+ const currentTarget = Math.max(0, Number(scrollTargetRef.current) || 0);
530
+ const currentPosition = Math.max(0, Number(scrollPositionRef.current) || 0);
531
+ const currentOffset = Math.max(0, Number(scrollOffset) || 0);
532
+ if (Math.max(currentTarget, currentPosition, currentOffset) === 0) return;
533
+ if (Math.max(currentTarget, currentPosition, currentOffset) > transcriptBottomSlackRows) return;
534
+ stopSmoothScroll();
535
+ scrollTargetRef.current = 0;
536
+ scrollPositionRef.current = 0;
537
+ setScrollOffset(0);
538
+ }, [transcriptBottomSlackRows, scrollOffset, stopSmoothScroll]);
539
+ useLayoutEffect(() => {
540
+ const top = Math.max(0, Number(transcriptViewportRef.current?.top) || 0);
541
+ const next = {
542
+ top,
543
+ height: Math.max(1, Number(transcriptContentHeight) || 1),
544
+ totalRows: Math.max(0, Number(transcriptWindow.totalRows) || 0),
545
+ scrollOffset: Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0),
546
+ };
547
+ const preservedDelta = Number(preservedScrollDeltaRef.current) || 0;
548
+ if (preservedDelta !== 0) {
549
+ next.scrollOffset = Math.max(0, next.scrollOffset + preservedDelta);
550
+ preservedScrollDeltaRef.current = 0;
551
+ }
552
+ const previous = selectionLayoutRef.current;
553
+ selectionLayoutRef.current = next;
554
+ if (!previous || !dragRef.current.rect || dragRef.current.active) return;
555
+ const deltaY = (next.top - previous.top)
556
+ + (next.height - previous.height)
557
+ - (next.totalRows - previous.totalRows)
558
+ + (next.scrollOffset - previous.scrollOffset);
559
+ if (deltaY === 0) return;
560
+ const clippedRect = withSelectionClip(shiftSelectionRectY(dragRef.current.rect, deltaY));
561
+ dragRef.current = { ...dragRef.current, rect: clippedRect };
562
+ paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
563
+ }, [transcriptContentHeight, transcriptWindow.totalRows, transcriptWindow.effectiveScrollOffset, withSelectionClip, paintSelectionRect]);
564
+ useEffect(() => {
565
+ if (!dragRef.current.rect) return;
566
+ const clippedRect = withSelectionClip(dragRef.current.rect);
567
+ dragRef.current = { ...dragRef.current, rect: clippedRect };
568
+ paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
569
+ }, [themeEpoch, withSelectionClip, paintSelectionRect]);
570
+ useEffect(() => {
571
+ const maxRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
572
+ if (scrollTargetRef.current <= maxRows && scrollPositionRef.current <= maxRows && scrollOffset <= maxRows) return;
573
+ stopSmoothScroll();
574
+ const next = Math.max(0, Math.min(maxRows, scrollTargetRef.current));
575
+ scrollTargetRef.current = next;
576
+ scrollPositionRef.current = next;
577
+ setScrollOffset(Math.round(next));
578
+ }, [transcriptWindow.maxScrollRows, scrollOffset, stopSmoothScroll]);
579
+
580
+ return {
581
+ transcriptWindow,
582
+ renderedTranscriptItems,
583
+ transcriptTailPinned,
584
+ overlayHintAttachItemIndex,
585
+ overlayHintOnLastItem,
586
+ overlayHintFallbackRow,
587
+ transcriptMeasureRef,
588
+ };
589
+ }
@@ -26,7 +26,7 @@ export function ConfirmBar({ buttons = [], focusedIndex = -1 }) {
26
26
  const list = Array.isArray(buttons) ? buttons.filter(Boolean) : [];
27
27
  if (list.length === 0) return null;
28
28
  return (
29
- <Box flexDirection="row" width="100%" justifyContent="flex-end">
29
+ <Box flexDirection="row" width="100%" justifyContent="flex-start">
30
30
  {list.map((button, index) => {
31
31
  const isFocused = index === focusedIndex;
32
32
  return (
@@ -90,7 +90,7 @@ export function Picker({
90
90
  labelWidth: labelWidthOverride = null,
91
91
  metaWidth: metaWidthOverride = null,
92
92
  footerGapRows = 1,
93
- initialIndex = 0,
93
+ initialIndex = null,
94
94
  indexMode = 'auto',
95
95
  fillHeight = false,
96
96
  visibleCount = MAX_VISIBLE,
@@ -114,13 +114,41 @@ export function Picker({
114
114
  setConfirmFocus(-1);
115
115
  }, [confirmButtons.length, confirmBar]);
116
116
 
117
+ // Selection stability across owner-driven reopens: command pickers
118
+ // (settings/hooks/skills/channels toggles) rebuild their item list and call
119
+ // setPicker() again on every ←/→ toggle. Follow the previously selected
120
+ // item's `value` into the new list instead of snapping back to row 0.
121
+ // (useState-backed ref: mutation must never trigger a re-render.)
122
+ const [selectionMemo] = useState(() => ({ value: null, initialIndex }));
117
123
  useEffect(() => {
118
- setSelectedIndex((i) => Math.min(Math.max(0, i), Math.max(0, items.length - 1)));
119
- }, [items.length]);
124
+ const item = items[selectedIndex];
125
+ if (item && item.value != null) selectionMemo.value = item.value;
126
+ }, [items, selectedIndex, selectionMemo]);
127
+
128
+ useEffect(() => {
129
+ setSelectedIndex((i) => {
130
+ if (selectionMemo.value != null) {
131
+ const found = items.findIndex((entry) => entry && entry.value === selectionMemo.value);
132
+ if (found >= 0) return found;
133
+ // Previous selection no longer exists — this is a different picker
134
+ // (or the row was removed). Start from the owner's initialIndex/top
135
+ // instead of carrying a stale row number across picker transitions.
136
+ selectionMemo.value = null;
137
+ return Math.max(0, Math.min(Number(initialIndex) || 0, Math.max(0, items.length - 1)));
138
+ }
139
+ return Math.min(Math.max(0, i), Math.max(0, items.length - 1));
140
+ });
141
+ }, [items, selectionMemo, initialIndex]);
120
142
 
121
143
  useEffect(() => {
144
+ // Explicit highlight target: honor initialIndex only when the owner
145
+ // actually provides a *new* one. A reopen that drops initialIndex
146
+ // (prop -> null default) must not reset the user's position to row 0.
147
+ if (initialIndex == null) { selectionMemo.initialIndex = null; return; }
148
+ if (selectionMemo.initialIndex === initialIndex) return;
149
+ selectionMemo.initialIndex = initialIndex;
122
150
  setSelectedIndex(Math.max(0, Math.min(Number(initialIndex) || 0, Math.max(0, items.length - 1))));
123
- }, [initialIndex, items.length]);
151
+ }, [initialIndex, items.length, selectionMemo]);
124
152
 
125
153
  // Live-preview hook: notify the owner whenever the highlighted row changes
126
154
  // (arrow keys, paging, initial mount). The /theme picker uses this to apply a