mixdog 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/package.json +9 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  6. package/scripts/background-task-meta-smoke.mjs +1 -1
  7. package/scripts/bench-run.mjs +262 -0
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/compact-smoke.mjs +12 -0
  10. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  11. package/scripts/explore-bench.mjs +124 -0
  12. package/scripts/hook-bus-test.mjs +191 -0
  13. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  14. package/scripts/internal-comms-bench.mjs +727 -0
  15. package/scripts/internal-comms-smoke.mjs +75 -0
  16. package/scripts/lead-workflow-smoke.mjs +4 -4
  17. package/scripts/live-worker-smoke.mjs +9 -9
  18. package/scripts/output-style-bench.mjs +285 -0
  19. package/scripts/output-style-smoke.mjs +13 -10
  20. package/scripts/patch-replay.mjs +90 -0
  21. package/scripts/path-suffix-test.mjs +57 -0
  22. package/scripts/provider-stream-stall-test.mjs +276 -0
  23. package/scripts/provider-toolcall-test.mjs +599 -1
  24. package/scripts/recall-bench.mjs +207 -0
  25. package/scripts/routing-corpus.mjs +281 -0
  26. package/scripts/session-bench.mjs +1526 -0
  27. package/scripts/session-diag.mjs +595 -0
  28. package/scripts/task-bench.mjs +207 -0
  29. package/scripts/tool-failures.mjs +6 -6
  30. package/scripts/tool-smoke.mjs +310 -67
  31. package/scripts/toolcall-args-test.mjs +81 -0
  32. package/src/agents/debugger/AGENT.md +4 -4
  33. package/src/agents/heavy-worker/AGENT.md +20 -9
  34. package/src/agents/reviewer/AGENT.md +4 -4
  35. package/src/agents/worker/AGENT.md +17 -9
  36. package/src/app.mjs +10 -6
  37. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  38. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  39. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  40. package/src/headless-role.mjs +14 -14
  41. package/src/help.mjs +1 -0
  42. package/src/lib/rules-builder.cjs +32 -54
  43. package/src/mixdog-session-runtime.mjs +1040 -2036
  44. package/src/output-styles/default.md +12 -7
  45. package/src/output-styles/minimal.md +25 -0
  46. package/src/output-styles/oneline.md +21 -0
  47. package/src/output-styles/simple.md +10 -9
  48. package/src/repl.mjs +17 -7
  49. package/src/rules/agent/00-common.md +7 -5
  50. package/src/rules/agent/30-explorer.md +8 -12
  51. package/src/rules/lead/01-general.md +3 -1
  52. package/src/rules/lead/lead-tool.md +13 -2
  53. package/src/rules/shared/01-tool.md +23 -12
  54. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  55. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  59. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  60. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  61. package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
  62. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  63. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
  65. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  66. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
  70. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
  71. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
  72. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  73. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  74. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  75. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  76. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
  78. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
  79. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
  80. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  82. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
  83. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  84. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
  85. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  86. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  87. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  88. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  89. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  90. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  91. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  93. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  94. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  97. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  98. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  99. package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
  100. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  101. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  102. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  103. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  104. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  105. package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
  106. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  107. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  108. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  109. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  110. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  113. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  116. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  118. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  119. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  121. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
  123. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  124. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  125. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
  126. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  127. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  129. package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
  130. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  131. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  132. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  134. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  135. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  136. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  137. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  138. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  139. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  140. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  141. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  142. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  148. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  149. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  150. package/src/runtime/channels/backends/discord.mjs +99 -9
  151. package/src/runtime/channels/backends/telegram.mjs +501 -0
  152. package/src/runtime/channels/index.mjs +437 -1439
  153. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  154. package/src/runtime/channels/lib/config.mjs +54 -2
  155. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  156. package/src/runtime/channels/lib/format.mjs +4 -2
  157. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  158. package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
  159. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  160. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  161. package/src/runtime/channels/lib/telegram-format.mjs +280 -0
  162. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  164. package/src/runtime/channels/lib/webhook.mjs +59 -31
  165. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  166. package/src/runtime/channels/tool-defs.mjs +1 -1
  167. package/src/runtime/memory/index.mjs +465 -345
  168. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  169. package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
  170. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  171. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  172. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  173. package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
  174. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  175. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  176. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  177. package/src/runtime/memory/lib/memory.mjs +121 -4
  178. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  179. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  180. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  181. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  182. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  183. package/src/runtime/memory/tool-defs.mjs +10 -7
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/background-tasks.mjs +2 -3
  186. package/src/runtime/shared/buffered-appender.mjs +149 -0
  187. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  188. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  189. package/src/runtime/shared/config.mjs +9 -0
  190. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  191. package/src/runtime/shared/schedules-store.mjs +21 -19
  192. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  193. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  194. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  195. package/src/runtime/shared/tool-surface.mjs +98 -13
  196. package/src/runtime/shared/transcript-writer.mjs +156 -0
  197. package/src/runtime/shared/update-checker.mjs +214 -0
  198. package/src/session-runtime/config-helpers.mjs +209 -0
  199. package/src/session-runtime/effort.mjs +128 -0
  200. package/src/session-runtime/fs-utils.mjs +10 -0
  201. package/src/session-runtime/model-capabilities.mjs +130 -0
  202. package/src/session-runtime/output-styles.mjs +124 -0
  203. package/src/session-runtime/plugin-mcp.mjs +114 -0
  204. package/src/session-runtime/session-text.mjs +100 -0
  205. package/src/session-runtime/statusline-route.mjs +35 -0
  206. package/src/session-runtime/tool-catalog.mjs +720 -0
  207. package/src/session-runtime/workflow.mjs +358 -0
  208. package/src/standalone/agent-tool.mjs +302 -117
  209. package/src/standalone/channel-admin.mjs +133 -40
  210. package/src/standalone/channel-worker.mjs +10 -292
  211. package/src/standalone/explore-tool.mjs +12 -5
  212. package/src/standalone/hook-bus.mjs +165 -8
  213. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  214. package/src/standalone/opencode-go-login.mjs +121 -0
  215. package/src/standalone/provider-admin.mjs +25 -3
  216. package/src/standalone/usage-dashboard.mjs +1 -1
  217. package/src/tui/App.jsx +2883 -778
  218. package/src/tui/components/ConfirmBar.jsx +47 -0
  219. package/src/tui/components/ContextPanel.jsx +5 -3
  220. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  221. package/src/tui/components/Markdown.jsx +22 -98
  222. package/src/tui/components/Message.jsx +14 -35
  223. package/src/tui/components/Picker.jsx +87 -12
  224. package/src/tui/components/PromptInput.jsx +355 -22
  225. package/src/tui/components/QueuedCommands.jsx +1 -1
  226. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  227. package/src/tui/components/Spinner.jsx +7 -7
  228. package/src/tui/components/StatusLine.jsx +40 -21
  229. package/src/tui/components/TextEntryPanel.jsx +51 -7
  230. package/src/tui/components/ToolExecution.jsx +183 -101
  231. package/src/tui/components/TurnDone.jsx +4 -4
  232. package/src/tui/components/UsagePanel.jsx +1 -1
  233. package/src/tui/components/tool-output-format.mjs +161 -23
  234. package/src/tui/components/tool-output-format.test.mjs +87 -0
  235. package/src/tui/display-width.mjs +69 -0
  236. package/src/tui/display-width.test.mjs +35 -0
  237. package/src/tui/dist/index.mjs +6731 -2333
  238. package/src/tui/engine/agent-envelope.mjs +296 -0
  239. package/src/tui/engine/boot-profile.mjs +21 -0
  240. package/src/tui/engine/labels.mjs +67 -0
  241. package/src/tui/engine/notice-text.mjs +112 -0
  242. package/src/tui/engine/queue-helpers.mjs +161 -0
  243. package/src/tui/engine/session-stats.mjs +46 -0
  244. package/src/tui/engine/tool-call-fields.mjs +23 -0
  245. package/src/tui/engine/tool-result-text.mjs +126 -0
  246. package/src/tui/engine.mjs +569 -948
  247. package/src/tui/index.jsx +117 -7
  248. package/src/tui/input-editing.mjs +58 -8
  249. package/src/tui/input-editing.selection.test.mjs +75 -0
  250. package/src/tui/keyboard-protocol.mjs +42 -0
  251. package/src/tui/lib/voice-recorder.mjs +469 -0
  252. package/src/tui/markdown/format-token.mjs +128 -76
  253. package/src/tui/markdown/format-token.test.mjs +61 -19
  254. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  255. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  256. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  257. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  258. package/src/tui/markdown/table-layout.mjs +9 -9
  259. package/src/tui/paste-attachments.mjs +36 -9
  260. package/src/tui/paste-fix.test.mjs +119 -0
  261. package/src/tui/prompt-history-store.mjs +129 -0
  262. package/src/tui/prompt-history-store.test.mjs +52 -0
  263. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  264. package/src/tui/theme.mjs +41 -657
  265. package/src/tui/themes/base.mjs +86 -0
  266. package/src/tui/themes/basic.mjs +85 -0
  267. package/src/tui/themes/catppuccin.mjs +72 -0
  268. package/src/tui/themes/dracula.mjs +70 -0
  269. package/src/tui/themes/everforest.mjs +71 -0
  270. package/src/tui/themes/gruvbox.mjs +71 -0
  271. package/src/tui/themes/index.mjs +71 -0
  272. package/src/tui/themes/indigo.mjs +78 -0
  273. package/src/tui/themes/kanagawa.mjs +80 -0
  274. package/src/tui/themes/light.mjs +81 -0
  275. package/src/tui/themes/nord.mjs +72 -0
  276. package/src/tui/themes/onedark.mjs +16 -0
  277. package/src/tui/themes/rosepine.mjs +70 -0
  278. package/src/tui/themes/teal.mjs +80 -0
  279. package/src/tui/themes/tokyonight.mjs +79 -0
  280. package/src/tui/themes/utils.mjs +106 -0
  281. package/src/tui/themes/warm.mjs +79 -0
  282. package/src/tui/transcript-tool-failures.mjs +13 -2
  283. package/src/ui/markdown.mjs +1 -1
  284. package/src/ui/model-display.mjs +2 -2
  285. package/src/ui/statusline.mjs +75 -27
  286. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  287. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  288. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  289. package/src/workflows/default/WORKFLOW.md +46 -12
  290. package/src/workflows/sequential/WORKFLOW.md +51 -0
  291. package/src/workflows/solo/WORKFLOW.md +12 -1
  292. package/vendor/ink/build/display-width.js +62 -0
  293. package/vendor/ink/build/ink.js +100 -12
  294. package/vendor/ink/build/measure-text.js +4 -1
  295. package/vendor/ink/build/output.js +115 -9
  296. package/vendor/ink/build/render-node-to-output.js +4 -1
  297. package/vendor/ink/build/render.js +4 -0
  298. package/src/output-styles/extreme-simple.md +0 -20
  299. package/src/rules/lead/04-workflow.md +0 -51
  300. package/src/workflows/default/workflow.json +0 -13
  301. package/src/workflows/solo/workflow.json +0 -7
@@ -18,7 +18,7 @@
18
18
  import React, { useEffect, useLayoutEffect, useState, useRef } from 'react';
19
19
  import { Box, Text, useInput, usePaste, useStdin } from 'ink';
20
20
  import stringWidth from 'string-width';
21
- import { theme } from '../theme.mjs';
21
+ import { theme, surfaceBackground } from '../theme.mjs';
22
22
  import {
23
23
  caretPosition,
24
24
  clearSelection,
@@ -38,6 +38,7 @@ import {
38
38
  replaceSelection,
39
39
  selectionRange,
40
40
  verticalOffset,
41
+ wordRangeAt,
41
42
  } from '../input-editing.mjs';
42
43
 
43
44
  function hintStyle(tone) {
@@ -52,6 +53,26 @@ function hintStyle(tone) {
52
53
  // paddingX of 1, so the typing start can sit directly against that padding
53
54
  // without an extra guard column.
54
55
  const IME_LEFT_GUARD_COLUMNS = 0;
56
+
57
+ // Monotone voice indicator glyphs — right end of the prompt box. All frames
58
+ // are 1-cell-wide (no emoji, no string-width special-casing needed). Modes:
59
+ // 'off' — voice disabled and no install in flight: renders nothing,
60
+ // zero reserved columns.
61
+ // 'installing' — voice-runtime download in progress (progressHint active):
62
+ // braille spinner only.
63
+ // 'idle' — voice enabled, recorder idle: a slow "breathing" pulse
64
+ // (◎ ◍ ◉ ◍) so the indicator isn't a dead static glyph.
65
+ // 'recording' — steady ◉ + a braille spinner riding next to it (2 cells).
66
+ // 'transcribing' — braille spinner only (recording just stopped, awaiting
67
+ // the whisper-server round-trip).
68
+ // A single ~120ms interval drives BOTH the fast braille cycle and the slower
69
+ // idle pulse (idle advances one frame every VOICE_IDLE_FRAME_TICKS ticks) —
70
+ // one timer instead of two, and it only exists while the indicator is
71
+ // visible (mode !== 'off'); it's torn down otherwise, never a permanent timer.
72
+ const VOICE_BRAILLE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
73
+ const VOICE_IDLE_FRAMES = ['◎', '◍', '◉', '◍'];
74
+ const VOICE_TICK_MS = 120;
75
+ const VOICE_IDLE_FRAME_TICKS = 5; // ~600ms per idle frame (5 * 120ms)
55
76
  // Coalesce prompt mouse-drag extend commits (SGR motion can fire faster than ink
56
77
  // needs to immediate-render). Matches transcript selection paint cadence.
57
78
  const MOUSE_EXTEND_COALESCE_MS = 24;
@@ -96,14 +117,36 @@ function draftStateEqual(a, b) {
96
117
  );
97
118
  }
98
119
 
99
- function isCtrlEnterSequence(input) {
120
+ // Recognize a MODIFIED Enter delivered via the kitty keyboard protocol
121
+ // (\x1b[13;<mod>u) or modifyOtherKeys (\x1b[27;<mod>;13~). The xterm modifier
122
+ // param is (1 + bitmask) where the bitmask bits are shift=1, alt=2, ctrl=4. We
123
+ // treat Ctrl+Enter AND Shift+Enter (the two common "insert newline" chords) as a
124
+ // newline, so we match when the shift OR ctrl bit is set. Ctrl+J is handled
125
+ // separately as the protocol-independent fallback.
126
+ const MODIFIED_ENTER_SHIFT_OR_CTRL = 1 | 4;
127
+ function isModifiedEnterSequence(input) {
100
128
  const text = String(input ?? '');
101
129
  const body = text.startsWith('\x1b[') ? text.slice(2) : text.startsWith('[') ? text.slice(1) : '';
102
130
  if (!body) return false;
103
131
  const kitty = /^13;(\d+)(?::\d+)?(?:;[\d:]+)?u$/.exec(body);
104
- if (kitty) return ((Number(kitty[1]) - 1) & 4) !== 0;
132
+ if (kitty) return ((Number(kitty[1]) - 1) & MODIFIED_ENTER_SHIFT_OR_CTRL) !== 0;
105
133
  const modifyOtherKeys = /^27;(\d+);13~$/.exec(body);
106
- return Boolean(modifyOtherKeys && (((Number(modifyOtherKeys[1]) - 1) & 4) !== 0));
134
+ return Boolean(modifyOtherKeys && (((Number(modifyOtherKeys[1]) - 1) & MODIFIED_ENTER_SHIFT_OR_CTRL) !== 0));
135
+ }
136
+
137
+ // Recognize ANY modified Enter (any modifier bitmask, e.g. Alt+Enter \x1b[13;3u
138
+ // / \x1b[27;3;13~). Used to CONSUME modified-Enter sequences we don't map to a
139
+ // newline (Alt-only, etc.) so they aren't typed into the prompt as raw CSI text
140
+ // under modifyOtherKeys. Plain Enter (mod param = 1, bitmask 0) is intentionally
141
+ // NOT matched, so it still submits.
142
+ function isAnyModifiedEnterSequence(input) {
143
+ const text = String(input ?? '');
144
+ const body = text.startsWith('\x1b[') ? text.slice(2) : text.startsWith('[') ? text.slice(1) : '';
145
+ if (!body) return false;
146
+ const kitty = /^13;(\d+)(?::\d+)?(?:;[\d:]+)?u$/.exec(body);
147
+ if (kitty) return (Number(kitty[1]) - 1) !== 0;
148
+ const modifyOtherKeys = /^27;(\d+);13~$/.exec(body);
149
+ return Boolean(modifyOtherKeys && ((Number(modifyOtherKeys[1]) - 1) !== 0));
107
150
  }
108
151
 
109
152
  export function PromptInput({
@@ -127,10 +170,13 @@ export function PromptInput({
127
170
  onRestoreQueued,
128
171
  onHistoryNavigate,
129
172
  onPasteText,
173
+ onVoiceToggle,
174
+ voiceIndicatorMode = 'off',
130
175
  selectionRef,
131
176
  valueRef,
132
177
  boxRectRef,
133
178
  mouseSelectionRef,
179
+ suppressShiftNavRef,
134
180
  }) {
135
181
  const [draft, setDraft] = useState(() => {
136
182
  const value = String(initialValue || '');
@@ -148,8 +194,31 @@ export function PromptInput({
148
194
  const boxRef = useRef(null);
149
195
  const cursorEnabledRef = useRef(false); // latest enabled state, read by the anchor fn at render time
150
196
  const contentWidthRef = useRef(80);
197
+ // Cells reserved on the right for the voice indicator glyph(s) (0 when
198
+ // hidden, 1 idle/spinner-only, 2 recording's "◉"+braille). Read by the
199
+ // cursor-anchor closure below (installed ONCE, so it must read live state
200
+ // via a ref rather than close over a render-local variable) to keep the
201
+ // caret/wrap math in sync with the actual reserved column, same pattern as
202
+ // IME_LEFT_GUARD_COLUMNS on the left edge.
203
+ const voiceIndicatorWidthRef = useRef(0);
151
204
  const preferredColumnRef = useRef(null);
152
205
  const mouseExtendCoalesceRef = useRef({ pendingNext: null, timer: null, t: 0 });
206
+ // Voice indicator animation tick. Runs a single ~120ms interval ONLY while
207
+ // voiceIndicatorMode !== 'off' (installing/idle/recording/transcribing) —
208
+ // torn down the instant the mode goes 'off', so no permanent timer exists
209
+ // when voice is disabled and no install is in flight.
210
+ const [voiceTick, setVoiceTick] = useState(0);
211
+ useEffect(() => {
212
+ if (voiceIndicatorMode === 'off') return undefined;
213
+ const timer = setInterval(() => setVoiceTick((t) => (t + 1) % 1000000), VOICE_TICK_MS);
214
+ timer.unref?.();
215
+ return () => clearInterval(timer);
216
+ }, [voiceIndicatorMode]);
217
+ // Undo/redo snapshot stack. Each entry is a { value, cursor, selectionAnchor }
218
+ // draft snapshot. Continuous typing coalesces (see UNDO_COALESCE_MS) so a run
219
+ // of characters collapses into one undo step; cursor-only moves are NOT
220
+ // snapshotted. Reset on submit / draftOverride.
221
+ const undoRef = useRef({ past: [], future: [], lastPushAt: 0, lastValue: null });
153
222
  const { value, cursor } = draft;
154
223
  draftRef.current = draft;
155
224
  if (selectionRef) {
@@ -181,6 +250,7 @@ export function PromptInput({
181
250
  const sameDraft = draftStateEqual(draftRef.current, next);
182
251
  if (!options.keepPreferredColumn) preferredColumnRef.current = null;
183
252
  if (sameDraft) return;
253
+ if (!options.skipHistory) recordUndoSnapshot(draftRef.current, next, options);
184
254
  draftRef.current = next;
185
255
  setDraft(next);
186
256
  queueMicrotask(flushImmediate);
@@ -225,10 +295,19 @@ export function PromptInput({
225
295
  const d = draftRef.current;
226
296
  const w = yogaNode?.getComputedWidth?.() ?? 0;
227
297
  const guardColumns = w > IME_LEFT_GUARD_COLUMNS ? IME_LEFT_GUARD_COLUMNS : 0;
228
- const contentWidth = Math.max(1, (w ? w - guardColumns : contentWidthRef.current) || 80);
298
+ // Reserve the voice-indicator's own cells (+1 gap column when visible)
299
+ // out of the SAME width the caret/wrap math uses, mirroring the left
300
+ // guard above — otherwise a visible indicator would silently overlap
301
+ // the caret/typed text at the right edge instead of pushing content in.
302
+ const voiceReserve = voiceIndicatorWidthRef.current > 0 ? voiceIndicatorWidthRef.current + 1 : 0;
303
+ const contentWidth = Math.max(1, (w ? w - guardColumns - voiceReserve : contentWidthRef.current) || 80);
229
304
  contentWidthRef.current = contentWidth;
230
305
  const caret = w > 0
231
- ? caretPosition(d.value, d.cursor, contentWidth)
306
+ // PromptInput renders a trailing space cell when the cursor is at
307
+ // end-of-input, so a caret flush on the last column there still has a
308
+ // following cell — pass hasTrailingContent=true so it rolls to row N+1
309
+ // exactly as ink wraps the trailing space.
310
+ ? caretPosition(d.value, d.cursor, contentWidth, d.cursor >= d.value.length ? true : undefined)
232
311
  : { row: 0, col: stringWidth(d.value.slice(0, d.cursor)) };
233
312
  return w > 0
234
313
  ? { ...caret, col: caret.col + guardColumns }
@@ -241,6 +320,70 @@ export function PromptInput({
241
320
  commitDraft(fn(draftRef.current), options);
242
321
  };
243
322
 
323
+ // --- undo/redo -----------------------------------------------------------
324
+ // Continuous-typing coalesce window: successive value-changing edits within
325
+ // this window collapse into a single undo step.
326
+ const UNDO_COALESCE_MS = 500;
327
+ const UNDO_MAX = 100;
328
+
329
+ const snapshotOf = (d) => ({ value: d.value, cursor: d.cursor, selectionAnchor: d.selectionAnchor ?? null });
330
+
331
+ const resetUndo = () => {
332
+ undoRef.current = { past: [], future: [], lastPushAt: 0, lastValue: null };
333
+ };
334
+
335
+ // Record a snapshot of the PREVIOUS state before applying `next`. Cursor-only
336
+ // moves (value unchanged) are never snapshotted. Consecutive value edits
337
+ // within UNDO_COALESCE_MS coalesce (we keep only the first snapshot of the
338
+ // run, so a single undo reverts the whole burst).
339
+ const recordUndoSnapshot = (prev, next, options = {}) => {
340
+ const stack = undoRef.current;
341
+ if (prev.value === next.value) {
342
+ // Cursor/selection-only move: don't snapshot, but BREAK the coalesce run
343
+ // so a following edit starts a fresh undo step (typing→move→typing must
344
+ // not collapse into one undo).
345
+ stack.lastPushAt = 0;
346
+ stack.lastValue = next.value;
347
+ return;
348
+ }
349
+ const now = Date.now();
350
+ const coalesce = !options.undoBreak
351
+ && stack.past.length > 0
352
+ && (now - stack.lastPushAt) < UNDO_COALESCE_MS
353
+ && stack.lastValue === prev.value;
354
+ if (!coalesce) {
355
+ stack.past.push(snapshotOf(prev));
356
+ if (stack.past.length > UNDO_MAX) stack.past.shift();
357
+ }
358
+ stack.lastPushAt = now;
359
+ stack.lastValue = next.value;
360
+ stack.future = [];
361
+ };
362
+
363
+ const performUndo = () => {
364
+ const stack = undoRef.current;
365
+ if (stack.past.length === 0) return false;
366
+ const prev = stack.past.pop();
367
+ stack.future.push(snapshotOf(draftRef.current));
368
+ stack.lastPushAt = 0;
369
+ stack.lastValue = prev.value;
370
+ commitDraft(prev, { skipHistory: true });
371
+ return true;
372
+ };
373
+
374
+ const performRedo = () => {
375
+ const stack = undoRef.current;
376
+ if (stack.future.length === 0) return false;
377
+ const next = stack.future.pop();
378
+ stack.past.push(snapshotOf(draftRef.current));
379
+ if (stack.past.length > UNDO_MAX) stack.past.shift();
380
+ stack.lastPushAt = 0;
381
+ stack.lastValue = next.value;
382
+ commitDraft(next, { skipHistory: true });
383
+ return true;
384
+ };
385
+ // -------------------------------------------------------------------------
386
+
244
387
  const cancelMouseExtendCoalesce = () => {
245
388
  const state = mouseExtendCoalesceRef.current;
246
389
  if (state.timer) {
@@ -309,6 +452,28 @@ export function PromptInput({
309
452
  const anchor = Number.isFinite(d.selectionAnchor) ? d.selectionAnchor : d.cursor;
310
453
  queueMouseExtendCommit({ ...d, cursor: off, selectionAnchor: anchor }, immediate);
311
454
  },
455
+ hasSelection: () => selectionRange(draftRef.current) != null,
456
+ // Double-click word select: pick the word/punctuation run under the
457
+ // clicked offset and set selectionAnchor/cursor to its bounds so it
458
+ // paints via the normal selection highlight. Triple-click line select
459
+ // uses lineStart/lineEnd instead (see selectLineAt).
460
+ selectWordAt: (offset) => {
461
+ cancelMouseExtendCoalesce();
462
+ const value = draftRef.current.value;
463
+ const off = Math.max(0, Math.min(value.length, Math.floor(Number(offset) || 0)));
464
+ const { start, end } = wordRangeAt(value, off);
465
+ commitDraft({ ...draftRef.current, cursor: end, selectionAnchor: start });
466
+ mouseExtendCoalesceRef.current.t = Date.now();
467
+ },
468
+ selectLineAt: (offset) => {
469
+ cancelMouseExtendCoalesce();
470
+ const value = draftRef.current.value;
471
+ const off = Math.max(0, Math.min(value.length, Math.floor(Number(offset) || 0)));
472
+ const start = lineStart(value, off);
473
+ const end = lineEnd(value, off);
474
+ commitDraft({ ...draftRef.current, cursor: end, selectionAnchor: start });
475
+ mouseExtendCoalesceRef.current.t = Date.now();
476
+ },
312
477
  clear: () => {
313
478
  cancelMouseExtendCoalesce();
314
479
  if (selectionRange(draftRef.current)) commitDraft(clearSelection(draftRef.current));
@@ -382,7 +547,8 @@ export function PromptInput({
382
547
 
383
548
  useEffect(() => {
384
549
  if (!draftOverride || typeof draftOverride.value !== 'string') return;
385
- commitDraft({ value: draftOverride.value, cursor: draftOverride.value.length, selectionAnchor: null });
550
+ commitDraft({ value: draftOverride.value, cursor: draftOverride.value.length, selectionAnchor: null }, { skipHistory: true });
551
+ resetUndo();
386
552
  }, [draftOverride?.id]);
387
553
 
388
554
  useEffect(() => () => {
@@ -396,7 +562,8 @@ export function PromptInput({
396
562
  commitDraft(next);
397
563
  return;
398
564
  }
399
- commitDraft({ value: '', cursor: 0, selectionAnchor: null });
565
+ commitDraft({ value: '', cursor: 0, selectionAnchor: null }, { skipHistory: true });
566
+ resetUndo();
400
567
  };
401
568
 
402
569
  const submitEnterChunk = (prefix = '') => {
@@ -427,6 +594,21 @@ export function PromptInput({
427
594
  const rawInput = String(input ?? '');
428
595
  const inputKey = rawInput.toLowerCase();
429
596
 
597
+ // App owns Shift+Arrow when a transcript/status ink-grid selection is live.
598
+ // Because the parent (App) useInput handler fires AFTER this child handler
599
+ // for the same event, a flag SET in App's handler is always one event stale.
600
+ // Instead call a synchronous predicate derived from dragRef at event time.
601
+ const gridSelectionActive = typeof suppressShiftNavRef === 'function'
602
+ ? suppressShiftNavRef()
603
+ : (typeof suppressShiftNavRef?.current === 'function'
604
+ ? suppressShiftNavRef.current()
605
+ : Boolean(suppressShiftNavRef?.current));
606
+ if (gridSelectionActive) {
607
+ const isShiftArrow = key.shift
608
+ && (key.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.home || key.end);
609
+ if (isShiftArrow) return;
610
+ }
611
+
430
612
  // Drop SGR mouse-tracking sequences (wheel/click). When app mouse tracking
431
613
  // is explicitly enabled, App parses these off raw stdin itself;
432
614
  // ink still forwards the bytes here as "input", which would otherwise type
@@ -436,6 +618,17 @@ export function PromptInput({
436
618
  return;
437
619
  }
438
620
 
621
+ // Safety net: drop CSI-private replies/fragments like \x1b[?<n>u / \x1b[?...c
622
+ // (escape may be stripped → `[?7u` / `[?1;0c`). We no longer query the
623
+ // terminal (enables are written unconditionally at raw-mode-on), so these
624
+ // should not normally appear — but a terminal that volunteers such a report
625
+ // must never type it into the prompt. The required `?` after `[` means this
626
+ // never matches a real kitty KEY event (those are \x1b[<codepoint>;<mods>u,
627
+ // no `?`); the optional final byte also discards any partial fragment.
628
+ if (/^(?:\x1b)?\[\?[\d;]*[uc]?$/.test(rawInput)) {
629
+ return;
630
+ }
631
+
439
632
  const rawUpArrow = rawInput === '\x1b[A' || rawInput === '\x1bOA' || rawInput === '[A' || rawInput === 'OA';
440
633
  const rawDownArrow = rawInput === '\x1b[B' || rawInput === '\x1bOB' || rawInput === '[B' || rawInput === 'OB';
441
634
  // Shift+Arrow modifier sequences (xterm `\x1b[1;2<dir>`, rxvt `\x1b[<dir>`
@@ -448,13 +641,52 @@ export function PromptInput({
448
641
  const rawShiftDown = rawInput === '\x1b[1;2B' || rawInput === '\x1b[b' || rawInput === '[1;2B';
449
642
  const rawShiftRight = rawInput === '\x1b[1;2C' || rawInput === '\x1b[c' || rawInput === '[1;2C';
450
643
  const rawShiftLeft = rawInput === '\x1b[1;2D' || rawInput === '\x1b[d' || rawInput === '[1;2D';
451
- const shiftHeld = key.shift || rawShiftUp || rawShiftDown || rawShiftLeft || rawShiftRight;
644
+ // Ctrl+Shift+Arrow modifier sequences: xterm mod=6 (1 + shift(1) + ctrl(4))
645
+ // arrives as `\x1b[1;6<dir>`; kitty keyboard protocol reports the same chord
646
+ // as `\x1b[<code>;6<dir>` (also mod=6). Ink decodes neither the `;6` for
647
+ // arrows, so the bytes arrive raw. Fold into a ctrlShiftHeld signal used to
648
+ // drive whole-word selection-extend below. `\x1b[1;6<dir>` covers both the
649
+ // classic xterm form and kitty's default (which emits the legacy arrow form
650
+ // with the CSI-u modifier param for arrow keys).
651
+ const rawCtrlShiftUp = rawInput === '\x1b[1;6A' || rawInput === '[1;6A';
652
+ const rawCtrlShiftDown = rawInput === '\x1b[1;6B' || rawInput === '[1;6B';
653
+ const rawCtrlShiftRight = rawInput === '\x1b[1;6C' || rawInput === '[1;6C';
654
+ const rawCtrlShiftLeft = rawInput === '\x1b[1;6D' || rawInput === '[1;6D';
655
+ const ctrlShiftHeld =
656
+ rawCtrlShiftUp || rawCtrlShiftDown || rawCtrlShiftLeft || rawCtrlShiftRight
657
+ || (key.shift && key.ctrl && (key.leftArrow || key.rightArrow || key.upArrow || key.downArrow));
658
+ const shiftHeld = key.shift || rawShiftUp || rawShiftDown || rawShiftLeft || rawShiftRight
659
+ || rawCtrlShiftUp || rawCtrlShiftDown || rawCtrlShiftLeft || rawCtrlShiftRight;
452
660
  const lineBreakIndex = rawInput.search(/[\r\n]/);
453
661
  const rawEnter = rawInput === '\r' || rawInput === '\n' || rawInput === '\r\n';
454
662
  const trailingEnterPrefix = singleTrailingLineBreakPrefix(rawInput);
455
- const rawCtrlEnter = isCtrlEnterSequence(rawInput);
663
+ const rawCtrlEnter = isModifiedEnterSequence(rawInput);
456
664
  const modifiedLineBreak = key.shift || key.meta || key.ctrl || rawCtrlEnter;
457
665
 
666
+ // Ctrl+J is the protocol-INDEPENDENT newline that works on every terminal.
667
+ // • Legacy / modifyOtherKeys terminals: Ctrl+J is a lone '\n' (0x0A). A real
668
+ // Enter is CR, which ink marks key.return (name 'return'); a lone '\n'
669
+ // arrives as name 'enter' with key.return false. A multi-char paste that
670
+ // contains '\n' is length > 1 (handled by the paste paths below).
671
+ // • Kitty protocol active: Ctrl+J arrives as \x1b[106;5u, which ink decodes
672
+ // to input 'j' with key.ctrl set.
673
+ // Either way → insert a newline. This MUST run before the trailing-newline/
674
+ // submit paths, since singleTrailingLineBreakPrefix('\n') returns '' (not
675
+ // null) and would otherwise route a bare Ctrl+J to submit.
676
+ if ((rawInput === '\n' && !key.return) || (key.ctrl && inputKey === 'j')) {
677
+ updateDraft((d) => replaceSelection(d, '\n'));
678
+ return;
679
+ }
680
+
681
+ // A modified Enter that is NOT a newline chord (e.g. Alt+Enter \x1b[13;3u or
682
+ // \x1b[27;3;13~). isModifiedEnterSequence already handled shift/ctrl above
683
+ // (→ newline); here we CONSUME any other modified Enter so its raw CSI bytes
684
+ // never type into the prompt under modifyOtherKeys. Plain Enter (mod=1) is
685
+ // not matched and still submits below.
686
+ if (!rawCtrlEnter && isAnyModifiedEnterSequence(rawInput)) {
687
+ return;
688
+ }
689
+
458
690
  const pasteFallback = lineBreakIndex !== -1 && trailingEnterPrefix === null && !rawEnter && (rawInput.length > 1 || !key.return);
459
691
  if (pasteFallback) {
460
692
  handleExternalPaste(rawInput, { source: 'paste-fallback' });
@@ -508,25 +740,58 @@ export function PromptInput({
508
740
  return;
509
741
  }
510
742
 
511
- if (key.upArrow || rawUpArrow || rawShiftUp) {
743
+ // Ctrl+Shift+Left/Right → extend selection whole-word. Kept before the plain
744
+ // shift-arrow branches so the ctrl+shift chord never falls through to a
745
+ // char-wise extend. (Up/Down ctrl+shift extend to line-relative vertical
746
+ // move with extend — same as shift alone; handled in the vertical branch.)
747
+ if (ctrlShiftHeld && (rawCtrlShiftLeft || (key.ctrl && key.shift && key.leftArrow))) {
748
+ if (!commandPaletteActive) {
749
+ updateDraft((d) => moveCursor(d, previousWordOffset(d.value, d.cursor), { extend: true }));
750
+ }
751
+ return;
752
+ }
753
+ if (ctrlShiftHeld && (rawCtrlShiftRight || (key.ctrl && key.shift && key.rightArrow))) {
754
+ if (!commandPaletteActive) {
755
+ updateDraft((d) => moveCursor(d, nextWordOffset(d.value, d.cursor), { extend: true }));
756
+ }
757
+ return;
758
+ }
759
+
760
+ if (key.upArrow || rawUpArrow || rawShiftUp || rawCtrlShiftUp) {
512
761
  if (commandPaletteActive) {
513
762
  onCommandPaletteNavigate?.(-1);
514
763
  } else {
515
- const hasDraftText = String(draftRef.current.value || '').trim().length > 0;
516
- if (!hasDraftText) {
517
- if (!restoreQueuedToDraft()) applyHistoryNavigation('up', { emptyDraft: true });
518
- } else if (!moveDraftVertically(-1, { extend: shiftHeld })) {
519
- applyHistoryNavigation('up', { emptyDraft: false });
764
+ // A Shift-held Up is a SELECTION gesture, never history navigation:
765
+ // extend the selection up one visual line, and if already on the first
766
+ // line extend all the way to document start (offset 0). History
767
+ // navigation (restoreQueued / applyHistoryNavigation) MUST NOT fire.
768
+ if (shiftHeld) {
769
+ if (!moveDraftVertically(-1, { extend: true })) {
770
+ updateDraft((d) => moveCursor(d, 0, { extend: true }));
771
+ }
772
+ } else {
773
+ const hasDraftText = String(draftRef.current.value || '').trim().length > 0;
774
+ if (!hasDraftText) {
775
+ if (!restoreQueuedToDraft()) applyHistoryNavigation('up', { emptyDraft: true });
776
+ } else if (!moveDraftVertically(-1, { extend: false })) {
777
+ applyHistoryNavigation('up', { emptyDraft: false });
778
+ }
520
779
  }
521
780
  }
522
781
  return;
523
782
  }
524
783
 
525
- if (key.downArrow || rawDownArrow || rawShiftDown) {
784
+ if (key.downArrow || rawDownArrow || rawShiftDown || rawCtrlShiftDown) {
526
785
  if (commandPaletteActive) {
527
786
  onCommandPaletteNavigate?.(1);
528
787
  } else {
529
- if (!moveDraftVertically(1, { extend: shiftHeld })) {
788
+ // Shift-held Down: extend selection down one line, or to document end
789
+ // (value.length) when already on the last line. Never history nav.
790
+ if (shiftHeld) {
791
+ if (!moveDraftVertically(1, { extend: true })) {
792
+ updateDraft((d) => moveCursor(d, d.value.length, { extend: true }));
793
+ }
794
+ } else if (!moveDraftVertically(1, { extend: false })) {
530
795
  applyHistoryNavigation('down', { emptyDraft: String(draftRef.current.value || '').trim().length === 0 });
531
796
  }
532
797
  }
@@ -583,8 +848,8 @@ export function PromptInput({
583
848
  commitDraft({ value: '', cursor: 0, selectionAnchor: null });
584
849
  return;
585
850
  }
586
- // Active turn takes precedence over queue restore (matches claude-code
587
- // useCancelRequest priority): Esc during a running turn interrupts the
851
+ // Active turn takes precedence over queue restore: Esc during a
852
+ // running turn interrupts the
588
853
  // turn and leaves queued steering prompts intact to run afterward. Queued
589
854
  // messages only pop back into the draft when the turn is idle.
590
855
  if (interruptActive) {
@@ -644,6 +909,27 @@ export function PromptInput({
644
909
 
645
910
  const editingKey = String(input || '').toLowerCase();
646
911
 
912
+ // Undo / redo. Covered encodings:
913
+ // • kitty protocol: ctrl+z → input 'z' + key.ctrl; ctrl+y → 'y' + key.ctrl;
914
+ // ctrl+shift+z → 'z' + key.ctrl + key.shift (redo).
915
+ // • legacy control bytes: Ctrl+Z is \x1a (SUB, 0x1A), Ctrl+Y is \x19 (EM,
916
+ // 0x19). ink may deliver these as raw input without key.ctrl on some
917
+ // terminals, so match the byte directly too.
918
+ const isCtrlZ = (key.ctrl && editingKey === 'z') || rawInput === '\x1a';
919
+ const isCtrlY = (key.ctrl && editingKey === 'y') || rawInput === '\x19';
920
+ if (isCtrlZ && (key.shift || shiftHeld)) {
921
+ performRedo();
922
+ return;
923
+ }
924
+ if (isCtrlZ) {
925
+ performUndo();
926
+ return;
927
+ }
928
+ if (isCtrlY) {
929
+ performRedo();
930
+ return;
931
+ }
932
+
647
933
  // ctrl+a selects all like a normal text box; ctrl+e keeps readline line-end.
648
934
  if (key.ctrl && editingKey === 'a') {
649
935
  updateDraft((d) => (d.value ? { ...d, cursor: d.value.length, selectionAnchor: 0 } : clearSelection(d)));
@@ -719,6 +1005,18 @@ export function PromptInput({
719
1005
  return;
720
1006
  }
721
1007
 
1008
+ // Ctrl+Space voice-record toggle. Two encodings observed across
1009
+ // terminals: a lone NUL byte (legacy Ctrl+Space, many terminals send
1010
+ // 0x00 directly) or the kitty CSI-u form `\x1b[32;5u` (codepoint 32
1011
+ // = space, modifier 5 = 1+4 = ctrl). Consumed here — never typed into
1012
+ // the prompt — and forwarded to the App-level recorder state machine
1013
+ // via onVoiceToggle; a no-op when the prop isn't wired (e.g. embedded
1014
+ // pickers) so this never throws.
1015
+ if (rawInput === '\x00' || /^(?:\x1b)?\[32;5u$/.test(rawInput)) {
1016
+ onVoiceToggle?.();
1017
+ return;
1018
+ }
1019
+
722
1020
  // Printable input (ignore other control keys). Strip any embedded SGR mouse
723
1021
  // sequences as a belt-and-suspenders guard (the early return above catches
724
1022
  // whole-sequence inputs; this removes partials that rode in with real text).
@@ -739,6 +1037,12 @@ export function PromptInput({
739
1037
  // resetAfterCommit BEFORE React layout effects — that lag made the 2nd+ char
740
1038
  // appear to land behind the caret (the observed scramble). Computing inside
741
1039
  // the fork, from the real layout + current refs, fixes that by construction.
1040
+ // Park the hardware cursor only when the prompt is a real, active edit target.
1041
+ // The anchor stays enabled during an active turn too: suppressing it while
1042
+ // the draft was empty (an earlier guard against a one-frame row-off paint
1043
+ // when the transcript height changed mid-turn) made the cursor vanish for
1044
+ // the whole turn after Enter, which reads as "the caret disappeared". A
1045
+ // momentary one-frame drift is the lesser evil versus a missing caret.
742
1046
  cursorEnabledRef.current = !disabled && isRawModeSupported;
743
1047
  installCursorAnchor();
744
1048
 
@@ -758,16 +1062,45 @@ export function PromptInput({
758
1062
  const displayValue = mask ? value.replace(/[^\n]/g, '*') : value;
759
1063
  const renderedValue = renderSelectedText(displayValue, selectionRange(draft), cursor === value.length);
760
1064
  const hintMeta = hintStyle(hintTone);
1065
+ // Monotone voice indicator glyph text — zero-width (rendered as null) when
1066
+ // mode is 'off'. Braille frame drives installing/recording/transcribing;
1067
+ // the idle pulse advances one frame every VOICE_IDLE_FRAME_TICKS ticks of
1068
+ // the SAME interval (slower "breathing" cadence off one shared timer).
1069
+ const voiceBrailleFrame = VOICE_BRAILLE_FRAMES[voiceTick % VOICE_BRAILLE_FRAMES.length];
1070
+ const voiceIdleFrame = VOICE_IDLE_FRAMES[Math.floor(voiceTick / VOICE_IDLE_FRAME_TICKS) % VOICE_IDLE_FRAMES.length];
1071
+ const voiceIndicatorText = voiceIndicatorMode === 'installing' || voiceIndicatorMode === 'transcribing'
1072
+ ? voiceBrailleFrame
1073
+ : voiceIndicatorMode === 'recording'
1074
+ ? `◉${voiceBrailleFrame}`
1075
+ : voiceIndicatorMode === 'idle'
1076
+ ? voiceIdleFrame
1077
+ : '';
1078
+ // stringWidth is unnecessary here — every glyph above is a single
1079
+ // 1-column codepoint (◎◍◉ and the braille frames), and 'recording'
1080
+ // concatenates exactly two 1-cell glyphs, so plain .length gives the
1081
+ // correct cell count without pulling in emoji-width special-casing.
1082
+ voiceIndicatorWidthRef.current = voiceIndicatorText.length;
761
1083
 
762
1084
  return (
763
- <Box ref={boxRef} flexDirection="row" flexGrow={1} flexShrink={1} backgroundColor={theme.background}>
764
- <Box width={IME_LEFT_GUARD_COLUMNS} flexShrink={0} backgroundColor={theme.background} />
1085
+ <Box ref={boxRef} flexDirection="row" width="100%" flexGrow={1} flexShrink={1} backgroundColor={surfaceBackground()}>
1086
+ <Box width={IME_LEFT_GUARD_COLUMNS} flexShrink={0} backgroundColor={surfaceBackground()} />
765
1087
  <Text color={theme.text} wrap="hard">{renderedValue}</Text>
766
1088
  {!value && hint ? (
767
1089
  <Box marginLeft={-1}>
768
1090
  <Text color={hintMeta.textColor}>{hint}</Text>
769
1091
  </Box>
770
1092
  ) : null}
1093
+ {voiceIndicatorText ? (
1094
+ <>
1095
+ {/* flexGrow spacer pins the indicator to the RIGHT edge of the row
1096
+ even when the typed text is short; without it the glyph would
1097
+ ride directly after the caret instead of the box edge. */}
1098
+ <Box flexGrow={1} backgroundColor={surfaceBackground()} />
1099
+ <Box flexShrink={0} width={voiceIndicatorText.length + 1} justifyContent="flex-end" backgroundColor={surfaceBackground()}>
1100
+ <Text color={theme.subtle}>{voiceIndicatorText}</Text>
1101
+ </Box>
1102
+ </>
1103
+ ) : null}
771
1104
  </Box>
772
1105
  );
773
1106
  }
@@ -22,7 +22,7 @@ export function QueuedCommands({ queued, columns }) {
22
22
  // Windows auto-wrap/scroll that drifts the alt-screen frame (see UserMessage).
23
23
  const bandColumns = Math.max(1, columns - 1);
24
24
  return (
25
- <Box marginTop={1} flexDirection="column">
25
+ <Box flexDirection="column">
26
26
  {queued.map((item) => {
27
27
  // Truncate to 1 line so the row reservation (queued.length in App.jsx)
28
28
  // stays accurate — wrapped text would push the input box off-screen.
@@ -65,6 +65,8 @@ export function SlashCommandPalette({ commands, selectedIndex = 0, title = 'Comm
65
65
  const descriptionWidth = Math.max(0, columns - labelWidth - 12);
66
66
  // Standard panel rhythm: title row, blank, description/hint row, blank, content.
67
67
  const description = truncateText(SLASH_DESCRIPTION, Math.max(0, columns - 4));
68
+ const titleWidth = stringWidth(String(title || ''));
69
+ const help = truncateText(SLASH_HELP, Math.max(0, columns - titleWidth - 7));
68
70
 
69
71
  return (
70
72
  <Box flexDirection="column" flexShrink={0} width="100%">
@@ -76,8 +78,8 @@ export function SlashCommandPalette({ commands, selectedIndex = 0, title = 'Comm
76
78
  width="100%"
77
79
  >
78
80
  <Box flexDirection="row" justifyContent="space-between">
79
- <Text color={theme.panelTitle}>{title}</Text>
80
- <Text color={theme.subtle}>{SLASH_HELP}</Text>
81
+ <Text color={theme.panelTitle} wrap="truncate">{title}</Text>
82
+ <Text color={theme.subtle} wrap="truncate">{help}</Text>
81
83
  </Box>
82
84
  <Text> </Text>
83
85
  <Text color={theme.subtle}>{description || ' '}</Text>
@@ -103,14 +105,15 @@ export function SlashCommandPalette({ commands, selectedIndex = 0, title = 'Comm
103
105
  const CommandRow = React.memo(function CommandRow({ command, isSelected, labelWidth, descriptionWidth, query }) {
104
106
  const label = truncateText(commandDisplayLabel(command, query), labelWidth);
105
107
  const description = truncateText(command.description, descriptionWidth);
108
+ const rowText = isSelected ? theme.selectionText : theme.text;
106
109
 
107
110
  return (
108
- <Box flexDirection="row" width="100%" backgroundColor={isSelected ? theme.userMessageBackground : undefined}>
109
- <Text color={theme.text}>
111
+ <Box flexDirection="row" width="100%" backgroundColor={isSelected ? theme.selectionBackground : undefined}>
112
+ <Text color={rowText}>
110
113
  {padCells(label, labelWidth)}
111
114
  </Text>
112
115
  {description ? (
113
- <Text color={theme.text}>
116
+ <Text color={rowText}>
114
117
  {' '}
115
118
  {description}
116
119
  </Text>
@@ -58,8 +58,6 @@ const MODE_VERBS = {
58
58
  responding: ['Responding', 'Composing', 'Writing', 'Wrapping up'],
59
59
  };
60
60
 
61
- const ERROR_RED = { r: 171, g: 43, b: 63 };
62
-
63
61
  function interpolateColor(a, b, t) {
64
62
  return {
65
63
  r: Math.round(a.r + (b.r - a.r) * t),
@@ -117,6 +115,7 @@ function spinnerRgb() {
117
115
  SPINNER_GLYPH_RGB: parseRgb(theme.spinnerGlyph) ?? { r: 240, g: 240, b: 240 },
118
116
  THINKING_INACTIVE: parseRgb(theme.thinkingBase) ?? parseRgb(theme.thinkingAccent) ?? { r: 153, g: 153, b: 153 },
119
117
  THINKING_SHIMMER: parseRgb(theme.thinkingGlow) ?? { r: 255, g: 205, b: 175 },
118
+ STALL_RGB: parseRgb(theme.error) ?? { r: 171, g: 43, b: 63 },
120
119
  };
121
120
  }
122
121
 
@@ -172,7 +171,7 @@ function tokenModeGlyph(mode) {
172
171
 
173
172
  export function Spinner({ verb = 'Working', startedAt, outputTokens = 0, tokens = 0, thinking = false, thinkingActiveSince = 0, mode = 'responding', columns = 80, marginTop = 1 }) {
174
173
  useAnimation({ interval: FRAME_MS });
175
- const { TEXT_RGB, SHIMMER_RGB, SPINNER_GLYPH_RGB, THINKING_INACTIVE, THINKING_SHIMMER } = spinnerRgb();
174
+ const { TEXT_RGB, SHIMMER_RGB, SPINNER_GLYPH_RGB, THINKING_INACTIVE, THINKING_SHIMMER, STALL_RGB } = spinnerRgb();
176
175
  const now = Date.now();
177
176
  const elapsedMs = startedAt ? Math.max(0, now - startedAt) : 0;
178
177
  const frame = Math.floor(elapsedMs / FRAME_MS);
@@ -222,17 +221,18 @@ export function Spinner({ verb = 'Working', startedAt, outputTokens = 0, tokens
222
221
  const glyphColor = stalledIntensity > 0
223
222
  ? toRgbString(interpolateColor(
224
223
  SPINNER_GLYPH_RGB,
225
- ERROR_RED,
224
+ STALL_RGB,
226
225
  stalledIntensity
227
226
  ))
228
227
  : theme.spinnerGlyph;
229
228
 
230
229
  // --- Verb shimmer (traveling highlight) ---
231
- if (displayVerbModeRef.current !== mode || !displayVerbRef.current) {
232
- displayVerbModeRef.current = mode;
230
+ if (!displayVerbRef.current) {
233
231
  displayVerbRef.current = stableModeVerb(mode, verb);
234
232
  nextVerbCheckRef.current = nextVerbCheckAt(now);
235
- } else if (now >= nextVerbCheckRef.current) {
233
+ }
234
+ displayVerbModeRef.current = mode;
235
+ if (now >= nextVerbCheckRef.current) {
236
236
  displayVerbRef.current = chooseNextVerb(mode, verb, displayVerbRef.current);
237
237
  nextVerbCheckRef.current = nextVerbCheckAt(now);
238
238
  }