@linxiraos/pi-tui 1.1.27 → 1.2.0

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 (373) hide show
  1. package/CHANGELOG.md +47 -463
  2. package/README.md +7 -5
  3. package/dist/types/app-keybindings.d.ts +1 -1
  4. package/dist/types/apps/cleanse-board.d.ts +1 -1
  5. package/dist/types/apps/debug/log-viewer.d.ts +4 -0
  6. package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
  7. package/dist/types/apps/git/git-tui.d.ts +2 -2
  8. package/dist/types/apps/git/help.d.ts +3 -1
  9. package/dist/types/apps/if-bench-board.d.ts +2 -2
  10. package/dist/types/apps/setup-model-picker.d.ts +1 -1
  11. package/dist/types/autocomplete.d.ts +5 -2
  12. package/dist/types/charts/chart-plan.d.ts +674 -0
  13. package/dist/types/charts/chart-svg.d.ts +59 -0
  14. package/dist/types/charts/table-data.d.ts +175 -0
  15. package/dist/types/chat/assistant-message.d.ts +5 -0
  16. package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
  17. package/dist/types/chat/compaction-summary-message.d.ts +1 -0
  18. package/dist/types/chat/execution-shared.d.ts +6 -5
  19. package/dist/types/chat/fence-figure.d.ts +42 -0
  20. package/dist/types/chat/figure-markdown.d.ts +42 -0
  21. package/dist/types/chat/messages.d.ts +1 -1
  22. package/dist/types/chat/svg-figure.d.ts +33 -0
  23. package/dist/types/chat/svg-source.d.ts +49 -0
  24. package/dist/types/chat/table-chart.d.ts +89 -0
  25. package/dist/types/chat/tool-execution.d.ts +2 -0
  26. package/dist/types/chat/user-message.d.ts +15 -2
  27. package/dist/types/chrome/shared.d.ts +9 -0
  28. package/dist/types/chrome/transcript-container.d.ts +6 -0
  29. package/dist/types/components/box.d.ts +1 -0
  30. package/dist/types/components/disclosure.d.ts +1 -0
  31. package/dist/types/components/editor.d.ts +15 -4
  32. package/dist/types/components/form.d.ts +1 -0
  33. package/dist/types/components/image.d.ts +12 -44
  34. package/dist/types/components/key-value-list.d.ts +1 -0
  35. package/dist/types/components/layout/row.d.ts +1 -0
  36. package/dist/types/components/layout/stack.d.ts +1 -0
  37. package/dist/types/components/markdown.d.ts +36 -3
  38. package/dist/types/components/scroll-view.d.ts +1 -0
  39. package/dist/types/components/section.d.ts +1 -0
  40. package/dist/types/components/table.d.ts +1 -0
  41. package/dist/types/components/text.d.ts +1 -0
  42. package/dist/types/components/tree-view.d.ts +7 -0
  43. package/dist/types/components/truncated-text.d.ts +1 -0
  44. package/dist/types/fuzzy.d.ts +29 -1
  45. package/dist/types/key-hint-format.d.ts +1 -1
  46. package/dist/types/keybinding-matchers.d.ts +3 -0
  47. package/dist/types/keybindings.d.ts +1 -1
  48. package/dist/types/kitty-graphics.d.ts +2 -2
  49. package/dist/types/loop-watchdog.d.ts +8 -7
  50. package/dist/types/multiplexers/cmux.d.ts +16 -0
  51. package/dist/types/multiplexers/herdr.d.ts +26 -0
  52. package/dist/types/multiplexers/notify.d.ts +13 -0
  53. package/dist/types/multiplexers/orca.d.ts +12 -0
  54. package/dist/types/multiplexers/screen.d.ts +9 -0
  55. package/dist/types/multiplexers/tmux.d.ts +29 -0
  56. package/dist/types/multiplexers/types.d.ts +100 -0
  57. package/dist/types/multiplexers/wmux.d.ts +12 -0
  58. package/dist/types/multiplexers/zellij.d.ts +12 -0
  59. package/dist/types/native/backend.d.ts +3 -2
  60. package/dist/types/native/blobs.d.ts +7 -3
  61. package/dist/types/native/encode.d.ts +3 -4
  62. package/dist/types/native/overlay.d.ts +5 -0
  63. package/dist/types/overlays/agent-hub-projection.d.ts +6 -5
  64. package/dist/types/overlays/agent-hub-types.d.ts +9 -0
  65. package/dist/types/overlays/agent-hub.d.ts +2 -0
  66. package/dist/types/overlays/agent-transcript-viewer.d.ts +11 -2
  67. package/dist/types/overlays/agents-hub.d.ts +9 -0
  68. package/dist/types/overlays/annotation-overlay.d.ts +19 -2
  69. package/dist/types/overlays/annotation-types.d.ts +2 -0
  70. package/dist/types/overlays/ask-dialog.d.ts +5 -2
  71. package/dist/types/overlays/history-search.d.ts +10 -0
  72. package/dist/types/overlays/hook-selector.d.ts +9 -4
  73. package/dist/types/overlays/jobs-panel.d.ts +1 -7
  74. package/dist/types/overlays/model-browser.d.ts +54 -7
  75. package/dist/types/overlays/model-hub.d.ts +23 -0
  76. package/dist/types/overlays/oauth-selector.d.ts +0 -1
  77. package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
  78. package/dist/types/overlays/session-observer-registry.d.ts +2 -0
  79. package/dist/types/overlays/session-selector.d.ts +6 -2
  80. package/dist/types/overlays/settings-defs.d.ts +1 -0
  81. package/dist/types/overlays/usage-dashboard.d.ts +8 -4
  82. package/dist/types/overlays/usage-display.d.ts +3 -1
  83. package/dist/types/prompt/composer.d.ts +1 -1
  84. package/dist/types/prompt/custom-editor.d.ts +11 -8
  85. package/dist/types/prompt/image-source.d.ts +2 -1
  86. package/dist/types/prompt/interactive-context-helpers.d.ts +4 -1
  87. package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
  88. package/dist/types/prompt/welcome.d.ts +6 -1
  89. package/dist/types/render/composite.d.ts +2 -0
  90. package/dist/types/render/output-pane.d.ts +1 -0
  91. package/dist/types/render/render-utils.d.ts +1 -0
  92. package/dist/types/render/tool-card.d.ts +6 -0
  93. package/dist/types/render/width-aware-text.d.ts +2 -0
  94. package/dist/types/setup/scenes/types.d.ts +1 -0
  95. package/dist/types/status-line/component.d.ts +3 -0
  96. package/dist/types/status-line/host.d.ts +4 -1
  97. package/dist/types/status-line/segments.d.ts +2 -0
  98. package/dist/types/status-line/types.d.ts +14 -3
  99. package/dist/types/terminal-capabilities.d.ts +27 -35
  100. package/dist/types/terminal-multiplexer.d.ts +127 -14
  101. package/dist/types/terminal.d.ts +22 -6
  102. package/dist/types/theme/active-symbols.d.ts +1 -1
  103. package/dist/types/theme/schema.d.ts +11 -2
  104. package/dist/types/theme/symbols.d.ts +1 -1
  105. package/dist/types/theme/theme-class.d.ts +7 -0
  106. package/dist/types/theme/theme.d.ts +1 -1
  107. package/dist/types/theme/tui-adapters.d.ts +3 -0
  108. package/dist/types/tmux.d.ts +11 -4
  109. package/dist/types/tools/daemon.d.ts +1 -1
  110. package/dist/types/tools/eval.d.ts +22 -3
  111. package/dist/types/tools/glob.d.ts +4 -0
  112. package/dist/types/tools/native-view.d.ts +47 -0
  113. package/dist/types/tools/output-meta.d.ts +11 -1
  114. package/dist/types/tools/renderer.d.ts +37 -2
  115. package/dist/types/tools/streaming-output.d.ts +20 -1
  116. package/dist/types/tools/task.d.ts +0 -4
  117. package/dist/types/tools/write.d.ts +9 -2
  118. package/dist/types/tools/yield.d.ts +22 -0
  119. package/dist/types/tui.d.ts +31 -0
  120. package/dist/types/utils.d.ts +6 -0
  121. package/dist/types/vim.d.ts +19 -5
  122. package/package.json +13 -9
  123. package/src/app-keybindings.d.ts +1 -1
  124. package/src/app-keybindings.ts +1 -1
  125. package/src/apps/autoresearch-dashboard.ts +7 -3
  126. package/src/apps/cleanse-board.ts +2 -2
  127. package/src/apps/cleanse-picker.ts +1 -1
  128. package/src/apps/debug/log-viewer.ts +188 -72
  129. package/src/apps/debug/raw-sse-buffer.ts +8 -0
  130. package/src/apps/debug/raw-sse.ts +39 -5
  131. package/src/apps/git/diff-pane.ts +26 -5
  132. package/src/apps/git/git-tui.ts +2 -2
  133. package/src/apps/git/help.ts +29 -20
  134. package/src/apps/if-bench-board.ts +2 -2
  135. package/src/apps/ps-top.ts +2 -2
  136. package/src/apps/setup-model-picker.ts +1 -1
  137. package/src/autocomplete.d.ts +5 -2
  138. package/src/autocomplete.ts +67 -43
  139. package/src/charts/chart-plan.d.ts +674 -0
  140. package/src/charts/chart-plan.ts +5081 -0
  141. package/src/charts/chart-svg.d.ts +59 -0
  142. package/src/charts/chart-svg.ts +4657 -0
  143. package/src/charts/table-data.d.ts +175 -0
  144. package/src/charts/table-data.ts +1478 -0
  145. package/src/chat/advisor-message.ts +11 -0
  146. package/src/chat/assistant-message.d.ts +5 -0
  147. package/src/chat/assistant-message.ts +219 -50
  148. package/src/chat/chat-transcript-builder.d.ts +2 -0
  149. package/src/chat/chat-transcript-builder.ts +5 -3
  150. package/src/chat/compaction-summary-message.d.ts +1 -0
  151. package/src/chat/compaction-summary-message.ts +8 -0
  152. package/src/chat/execution-shared.d.ts +6 -5
  153. package/src/chat/execution-shared.ts +42 -25
  154. package/src/chat/fence-figure.d.ts +42 -0
  155. package/src/chat/fence-figure.ts +80 -0
  156. package/src/chat/figure-markdown.d.ts +42 -0
  157. package/src/chat/figure-markdown.ts +174 -0
  158. package/src/chat/messages.d.ts +1 -1
  159. package/src/chat/messages.ts +1 -1
  160. package/src/chat/read-tool-group.ts +6 -4
  161. package/src/chat/skill-message.ts +6 -0
  162. package/src/chat/svg-figure.d.ts +33 -0
  163. package/src/chat/svg-figure.ts +399 -0
  164. package/src/chat/svg-source.d.ts +49 -0
  165. package/src/chat/svg-source.ts +188 -0
  166. package/src/chat/table-chart.d.ts +89 -0
  167. package/src/chat/table-chart.ts +294 -0
  168. package/src/chat/tool-execution.d.ts +2 -0
  169. package/src/chat/tool-execution.ts +80 -6
  170. package/src/chat/user-message.d.ts +15 -2
  171. package/src/chat/user-message.ts +55 -3
  172. package/src/chrome/shared.ts +53 -0
  173. package/src/chrome/transcript-container.d.ts +6 -0
  174. package/src/chrome/transcript-container.ts +93 -13
  175. package/src/components/box.d.ts +1 -0
  176. package/src/components/box.ts +5 -0
  177. package/src/components/disclosure.d.ts +1 -0
  178. package/src/components/disclosure.ts +8 -0
  179. package/src/components/editor.d.ts +15 -4
  180. package/src/components/editor.ts +294 -46
  181. package/src/components/form.d.ts +1 -0
  182. package/src/components/form.ts +8 -0
  183. package/src/components/image.d.ts +12 -44
  184. package/src/components/image.ts +47 -151
  185. package/src/components/key-value-list.d.ts +1 -0
  186. package/src/components/key-value-list.ts +4 -0
  187. package/src/components/layout/row.d.ts +1 -0
  188. package/src/components/layout/row.ts +7 -0
  189. package/src/components/layout/stack.d.ts +1 -0
  190. package/src/components/layout/stack.ts +7 -0
  191. package/src/components/markdown.d.ts +36 -3
  192. package/src/components/markdown.ts +433 -318
  193. package/src/components/scroll-view.d.ts +1 -0
  194. package/src/components/scroll-view.ts +5 -0
  195. package/src/components/section.d.ts +1 -0
  196. package/src/components/section.ts +5 -0
  197. package/src/components/settings-list.ts +2 -0
  198. package/src/components/table.d.ts +1 -0
  199. package/src/components/table.ts +4 -0
  200. package/src/components/text.d.ts +1 -0
  201. package/src/components/text.ts +7 -0
  202. package/src/components/tree-view.d.ts +7 -0
  203. package/src/components/tree-view.ts +38 -8
  204. package/src/components/truncated-text.d.ts +1 -0
  205. package/src/components/truncated-text.ts +5 -0
  206. package/src/desktop-notify.ts +1 -1
  207. package/src/fuzzy.d.ts +29 -1
  208. package/src/fuzzy.ts +115 -30
  209. package/src/key-hint-format.d.ts +1 -1
  210. package/src/key-hint-format.ts +1 -1
  211. package/src/keybinding-matchers.d.ts +3 -0
  212. package/src/keybinding-matchers.ts +6 -2
  213. package/src/keybindings.d.ts +1 -1
  214. package/src/keybindings.ts +1 -1
  215. package/src/kitty-graphics.d.ts +2 -2
  216. package/src/kitty-graphics.ts +4 -4
  217. package/src/loop-watchdog.d.ts +8 -7
  218. package/src/loop-watchdog.ts +18 -13
  219. package/src/multiplexers/cmux.d.ts +16 -0
  220. package/src/multiplexers/cmux.ts +29 -0
  221. package/src/multiplexers/herdr.d.ts +26 -0
  222. package/src/multiplexers/herdr.ts +64 -0
  223. package/src/multiplexers/notify.d.ts +13 -0
  224. package/src/multiplexers/notify.ts +24 -0
  225. package/src/multiplexers/orca.d.ts +12 -0
  226. package/src/multiplexers/orca.ts +16 -0
  227. package/src/multiplexers/screen.d.ts +9 -0
  228. package/src/multiplexers/screen.ts +15 -0
  229. package/src/multiplexers/tmux.d.ts +29 -0
  230. package/src/multiplexers/tmux.ts +40 -0
  231. package/src/multiplexers/types.d.ts +100 -0
  232. package/src/multiplexers/types.ts +110 -0
  233. package/src/multiplexers/wmux.d.ts +12 -0
  234. package/src/multiplexers/wmux.ts +16 -0
  235. package/src/multiplexers/zellij.d.ts +12 -0
  236. package/src/multiplexers/zellij.ts +24 -0
  237. package/src/native/backend.d.ts +3 -2
  238. package/src/native/backend.ts +174 -18
  239. package/src/native/blobs.d.ts +7 -3
  240. package/src/native/blobs.ts +47 -12
  241. package/src/native/encode.d.ts +3 -4
  242. package/src/native/encode.ts +58 -31
  243. package/src/native/overlay.d.ts +5 -0
  244. package/src/native/overlay.ts +13 -0
  245. package/src/native/reconcile.ts +4 -0
  246. package/src/overlays/agent-hub-projection.d.ts +6 -5
  247. package/src/overlays/agent-hub-projection.ts +124 -13
  248. package/src/overlays/agent-hub-types.d.ts +9 -0
  249. package/src/overlays/agent-hub-types.ts +6 -0
  250. package/src/overlays/agent-hub.ts +44 -29
  251. package/src/overlays/agent-transcript-viewer.ts +91 -23
  252. package/src/overlays/agents-hub.ts +28 -8
  253. package/src/overlays/annotation-overlay.ts +293 -90
  254. package/src/overlays/annotation-types.ts +2 -0
  255. package/src/overlays/ask-dialog.ts +7 -10
  256. package/src/overlays/btw-history-panel.ts +12 -1
  257. package/src/overlays/copy-selector.ts +30 -8
  258. package/src/overlays/extensions/state-manager.ts +52 -12
  259. package/src/overlays/history-search.ts +100 -7
  260. package/src/overlays/hook-selector.ts +12 -5
  261. package/src/overlays/jobs-panel.ts +28 -46
  262. package/src/overlays/model-browser.ts +341 -110
  263. package/src/overlays/model-hub.ts +300 -49
  264. package/src/overlays/move-overlay.ts +28 -14
  265. package/src/overlays/oauth-selector.ts +54 -70
  266. package/src/overlays/plan-review-overlay.ts +194 -70
  267. package/src/overlays/plugin-settings.ts +3 -3
  268. package/src/overlays/report-panel.ts +2 -2
  269. package/src/overlays/rewind-selector.ts +86 -17
  270. package/src/overlays/session-observer-registry.d.ts +2 -0
  271. package/src/overlays/session-observer-registry.ts +10 -2
  272. package/src/overlays/session-selector.ts +64 -26
  273. package/src/overlays/settings-defs.ts +4 -3
  274. package/src/overlays/settings-selector.ts +33 -8
  275. package/src/overlays/snapcompact-shape-preview.ts +2 -3
  276. package/src/overlays/stats-notice.ts +1 -1
  277. package/src/overlays/tree-selector.ts +41 -20
  278. package/src/overlays/usage-dashboard.ts +100 -50
  279. package/src/overlays/usage-display.ts +10 -4
  280. package/src/prompt/attachment-chips.ts +15 -11
  281. package/src/prompt/composer-attachments.ts +6 -4
  282. package/src/prompt/composer.ts +4 -15
  283. package/src/prompt/custom-editor.ts +54 -26
  284. package/src/prompt/image-source.ts +2 -1
  285. package/src/prompt/interactive-context-helpers.ts +30 -18
  286. package/src/prompt/macos-spelling.ts +7 -0
  287. package/src/prompt/magic-keywords.ts +11 -2
  288. package/src/prompt/model-mention-autocomplete.ts +14 -14
  289. package/src/prompt/tips.txt +6 -5
  290. package/src/prompt/welcome.ts +55 -12
  291. package/src/prompt/word-completion.ts +38 -5
  292. package/src/render/composite.d.ts +2 -0
  293. package/src/render/composite.ts +64 -0
  294. package/src/render/output-pane.d.ts +1 -0
  295. package/src/render/output-pane.ts +6 -0
  296. package/src/render/render-utils.d.ts +1 -0
  297. package/src/render/render-utils.ts +11 -0
  298. package/src/render/tool-card.d.ts +6 -0
  299. package/src/render/tool-card.ts +15 -0
  300. package/src/render/width-aware-text.d.ts +2 -0
  301. package/src/render/width-aware-text.ts +5 -0
  302. package/src/setup/scenes/model.ts +2 -1
  303. package/src/setup/scenes/types.ts +1 -0
  304. package/src/status-line/component.ts +292 -78
  305. package/src/status-line/host.ts +4 -1
  306. package/src/status-line/segments.ts +40 -22
  307. package/src/status-line/types.ts +15 -3
  308. package/src/terminal-capabilities.d.ts +27 -35
  309. package/src/terminal-capabilities.ts +75 -189
  310. package/src/terminal-multiplexer.d.ts +127 -14
  311. package/src/terminal-multiplexer.ts +112 -33
  312. package/src/terminal.d.ts +22 -6
  313. package/src/terminal.ts +139 -37
  314. package/src/theme/active-symbols.d.ts +1 -1
  315. package/src/theme/active-symbols.ts +1 -1
  316. package/src/theme/schema-validation.ts +1 -0
  317. package/src/theme/schema.d.ts +11 -2
  318. package/src/theme/schema.ts +14 -1
  319. package/src/theme/session-color.ts +1 -7
  320. package/src/theme/symbols.d.ts +1 -1
  321. package/src/theme/symbols.ts +6 -1
  322. package/src/theme/theme-class.d.ts +7 -0
  323. package/src/theme/theme-class.ts +26 -1
  324. package/src/theme/theme-schema.json +5 -1
  325. package/src/theme/theme.d.ts +1 -1
  326. package/src/theme/theme.ts +8 -4
  327. package/src/theme/tui-adapters.d.ts +3 -0
  328. package/src/theme/tui-adapters.ts +68 -0
  329. package/src/tmux.d.ts +11 -4
  330. package/src/tmux.ts +47 -11
  331. package/src/tools/ast-edit.ts +59 -35
  332. package/src/tools/ast-grep.ts +35 -16
  333. package/src/tools/bash.ts +92 -42
  334. package/src/tools/daemon.d.ts +1 -1
  335. package/src/tools/daemon.ts +1 -1
  336. package/src/tools/default-renderer.ts +35 -19
  337. package/src/tools/edit.ts +142 -120
  338. package/src/tools/eval-format/index.ts +28 -2
  339. package/src/tools/eval-format/javascript.ts +7 -5
  340. package/src/tools/eval.d.ts +22 -3
  341. package/src/tools/eval.ts +364 -208
  342. package/src/tools/fetch.ts +5 -6
  343. package/src/tools/glob.d.ts +4 -0
  344. package/src/tools/glob.ts +33 -65
  345. package/src/tools/grep.ts +81 -48
  346. package/src/tools/index.ts +2 -0
  347. package/src/tools/lsp.ts +81 -59
  348. package/src/tools/mcp.ts +111 -68
  349. package/src/tools/native-view.d.ts +47 -0
  350. package/src/tools/native-view.ts +102 -1
  351. package/src/tools/output-meta.d.ts +11 -1
  352. package/src/tools/output-meta.ts +23 -3
  353. package/src/tools/read.ts +7 -5
  354. package/src/tools/renderer.d.ts +37 -2
  355. package/src/tools/renderer.ts +42 -2
  356. package/src/tools/streaming-output.d.ts +20 -1
  357. package/src/tools/streaming-output.ts +149 -50
  358. package/src/tools/task.d.ts +0 -4
  359. package/src/tools/task.ts +112 -59
  360. package/src/tools/todo.ts +119 -30
  361. package/src/tools/wait.ts +34 -24
  362. package/src/tools/web-search.ts +1 -1
  363. package/src/tools/write.d.ts +9 -2
  364. package/src/tools/write.ts +147 -95
  365. package/src/tools/yield.d.ts +22 -0
  366. package/src/tools/yield.ts +225 -0
  367. package/src/tui.d.ts +31 -0
  368. package/src/tui.ts +309 -160
  369. package/src/utils.d.ts +6 -0
  370. package/src/utils.ts +19 -0
  371. package/src/vim.d.ts +19 -5
  372. package/src/vim.ts +333 -18
  373. package/src/windows-input-mode.ts +8 -0
@@ -14,7 +14,7 @@ import { BracketedPasteHandler, decodeReencodedPasteControls } from "../brackete
14
14
  import { canonicalKeyId, getKeybindings, type KeybindingsManager } from "../keybindings";
15
15
  import { extractPrintableText, matchesKey, parseKey } from "../keys";
16
16
  import { KillRing } from "../kill-ring";
17
- import type { TspEditorDecoration, TspEditorProps, TspTone } from "@linxiraos/pi-wire";
17
+ import type { TspEditorDecoration, TspEditorProps, TspText, TspTone } from "@linxiraos/pi-wire";
18
18
  import { col, node } from "../native/describe";
19
19
  import { sameItems, sameProps } from "../native/memo";
20
20
  import { plainText } from "../native/spans";
@@ -74,12 +74,16 @@ export interface NativeEditorLayout {
74
74
  readonly caret: string;
75
75
  }
76
76
 
77
- /** Vim mode as the TSP `editor.mode` label. */
78
- const VIM_MODE_LABELS: Record<VimMode, string> = {
79
- insert: "INSERT",
77
+ /**
78
+ * Vim mode as the TSP `editor.mode` label. Insert carries none: Insert edits like a plain field,
79
+ * and a host such as Tern stops native editing (selection, ⌘A/⌘C/⌘X) for any labelled mode.
80
+ */
81
+ const VIM_MODE_LABELS: Record<VimMode, string | undefined> = {
82
+ insert: undefined,
80
83
  normal: "NORMAL",
81
84
  visual: "VISUAL",
82
85
  "visual-line": "VISUAL",
86
+ replace: "REPLACE",
83
87
  };
84
88
 
85
89
  const PASSTHROUGH_COLOR = (text: string): string => text;
@@ -625,8 +629,11 @@ export class Editor implements Component, Focusable {
625
629
  * dialog's text field; only the prompt composer claims `zeta.editor`).
626
630
  */
627
631
  describeLayout: ((input: NativeNode, cx: DescribeContext) => NativeEditorLayout) | undefined;
628
- /** TSP placeholder for the empty buffer; natively it replaces the rotating ANSI {@link placeholder} hints. */
629
- describePlaceholder: (() => string) | undefined;
632
+ /**
633
+ * TSP placeholder for the empty buffer; natively it replaces the rotating ANSI {@link placeholder} hints.
634
+ * Return a stable value (the same spans array) while it is unchanged: the editor node is reused by identity.
635
+ */
636
+ describePlaceholder: (() => TspText) | undefined;
630
637
  #promptGutter: string | undefined;
631
638
  /** Bumped by {@link invalidate}: host-side decoration inputs (spelling results, settings) changed. */
632
639
  #nativeGeneration = 0;
@@ -666,7 +673,9 @@ export class Editor implements Component, Focusable {
666
673
  // Emacs-style kill ring
667
674
  #killRing = new KillRing();
668
675
  /** Previous edit, for kill/yank chaining, undo coalescing, and the provisional space after a Tab word accept. */
669
- #lastAction: "kill" | "yank" | "type-word" | "accept-word" | null = null;
676
+ #lastAction: "kill" | "yank" | "type-word" | "accept-word" | "replace" | null = null;
677
+ /** Graphemes overwritten in the current `R` session, so Backspace can restore them. */
678
+ #replaceLog: { line: number; col: number; removed: string; text: string }[] = [];
670
679
 
671
680
  // Character jump mode
672
681
  #jumpMode: "forward" | "backward" | null = null;
@@ -713,6 +722,7 @@ export class Editor implements Component, Focusable {
713
722
 
714
723
  // Host-registered atomic chip tokens: exact buffer label → expansion emitted on submit.
715
724
  #atoms: Map<string, string> = new Map();
725
+ #atomsRevision = 0;
716
726
 
717
727
  /** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or
718
728
  * `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete
@@ -737,9 +747,6 @@ export class Editor implements Component, Focusable {
737
747
  #undoStack: EditorState[] = [];
738
748
  #suspendUndo = false;
739
749
 
740
- // Debounce timer for autocomplete updates
741
- #autocompleteTimeout?: NodeJS.Timeout;
742
-
743
750
  onSubmit?: (text: string) => void | Promise<void>;
744
751
  onAltEnter?: (text: string) => void;
745
752
  onChange?: (text: string) => void;
@@ -764,6 +771,12 @@ export class Editor implements Component, Focusable {
764
771
  * cursor. The host styles it (ANSI allowed). Re-evaluated on every render, so hosts can derive
765
772
  * it from live state. */
766
773
  placeholder?: () => string | undefined;
774
+ /** Whole-message prediction for the next prompt, painted as ghost text after the cursor while
775
+ * the single-line buffer is a strict prefix of it, the cursor sits at its end, and no
776
+ * autocomplete is open. A shown prediction replaces the {@link placeholder}; Tab or Right inserts
777
+ * the rest without submitting. Must be a single line. Re-evaluated on every render, so hosts can
778
+ * derive it from live state. */
779
+ prediction?: () => string | undefined;
767
780
 
768
781
  // Custom top border (for status line integration). Either an eager `content`
769
782
  // (set once, reused every frame) or a `provider` that recomputes lazily just
@@ -1028,6 +1041,7 @@ export class Editor implements Component, Focusable {
1028
1041
  this.#pastes.clear();
1029
1042
  this.#pasteCounter = 0;
1030
1043
  this.#atoms.clear();
1044
+ this.#atomsRevision++;
1031
1045
  this.#historyDraftActive = false;
1032
1046
  }
1033
1047
 
@@ -1067,6 +1081,7 @@ export class Editor implements Component, Focusable {
1067
1081
  if (entry?.draft || this.#historyDraftActive) {
1068
1082
  this.#pastes = new Map(entry?.draft?.pastes);
1069
1083
  this.#atoms = new Map(entry?.draft?.atoms);
1084
+ this.#atomsRevision++;
1070
1085
  this.#pasteCounter = entry?.draft?.pasteCounter ?? 0;
1071
1086
  this.restoreHistoryState(entry?.draft?.restore);
1072
1087
  this.#historyDraftActive = entry?.draft !== undefined;
@@ -1669,7 +1684,10 @@ export class Editor implements Component, Focusable {
1669
1684
 
1670
1685
  const placeholder = this.#getPlaceholder();
1671
1686
  const nativePlaceholder =
1672
- this.describePlaceholder !== undefined && !this.#autocompleteState && this.#isEditorEmpty()
1687
+ this.describePlaceholder !== undefined &&
1688
+ !this.#autocompleteState &&
1689
+ this.#isEditorEmpty() &&
1690
+ !this.#getPredictionSuffix()
1673
1691
  ? this.describePlaceholder()
1674
1692
  : placeholder && plainText(placeholder).trim();
1675
1693
  const atLineEnd = cursorCol >= (lines[cursorLine]?.length ?? 0);
@@ -1912,7 +1930,7 @@ export class Editor implements Component, Focusable {
1912
1930
  if (kb.matchesCanonical(canonical, "tui.input.tab") || rightArrowAccepts) {
1913
1931
  const selected = this.#autocompleteList.getSelectedItem();
1914
1932
  // Check for stale autocomplete state due to buffer edits since last refresh
1915
- // (destructive keys or paste can outrun the debounced update).
1933
+ // (destructive keys or paste can outrun the async refresh).
1916
1934
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1917
1935
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1918
1936
  if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
@@ -1922,7 +1940,7 @@ export class Editor implements Component, Focusable {
1922
1940
  }
1923
1941
  if (!selected) {
1924
1942
  // An `@` popup whose narrowing filter matched nothing stays open with no
1925
- // candidate (see #debouncedUpdateAutocomplete). Nothing to accept: cancel the
1943
+ // candidate (see #refreshAutocomplete). Nothing to accept: cancel the
1926
1944
  // popup and fall through so Tab keeps its normal completion role and a right
1927
1945
  // arrow at end of line moves the cursor.
1928
1946
  this.#cancelAutocomplete();
@@ -1944,7 +1962,7 @@ export class Editor implements Component, Focusable {
1944
1962
  !this.#selectedCompletionIsSkillNamespace()
1945
1963
  ) {
1946
1964
  const selected = this.#autocompleteList.getSelectedItem();
1947
- // Check for stale autocomplete state due to debounce
1965
+ // Check for stale autocomplete state while an async refresh is pending
1948
1966
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1949
1967
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1950
1968
  if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
@@ -2121,15 +2139,18 @@ export class Editor implements Component, Focusable {
2121
2139
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2122
2140
  if (
2123
2141
  findLeadingSlashCommandStart(textBeforeCursor) !== null &&
2142
+ textBeforeCursor.trim() !== "/" &&
2124
2143
  this.#isInSubmittedSlashCommandContext() &&
2125
2144
  this.#autocompleteProvider?.trySyncSlashCompletion
2126
2145
  ) {
2127
2146
  const syncResult = this.#autocompleteProvider.trySyncSlashCompletion(textBeforeCursor);
2128
- if (syncResult && syncResult.items.length > 0) {
2147
+ // The collapsed `/skill:` namespace row is not a runnable command; Enter submits
2148
+ // immediately after applying, so only a real command may be completed here.
2149
+ const selected = syncResult?.items.find(item => item.value !== SKILL_NAMESPACE);
2150
+ if (syncResult && selected) {
2129
2151
  // Invalidate any pending async autocomplete so its stale results are discarded
2130
2152
  this.#autocompleteRequestId += 1;
2131
2153
  // Apply the best match and submit the completed command
2132
- const selected = syncResult.items[0]!;
2133
2154
  const result = this.#autocompleteProvider.applyCompletion(
2134
2155
  this.#state.lines,
2135
2156
  this.#state.cursorLine,
@@ -2204,8 +2225,8 @@ export class Editor implements Component, Focusable {
2204
2225
  this.#moveCursor(1, 0); // Cursor movement (within text or history entry)
2205
2226
  }
2206
2227
  } else if (kb.matchesCanonical(canonical, "tui.editor.cursorRight")) {
2207
- // Right walks over the ghost word completion as if it were typed: no trailing space.
2208
- if (this.#acceptWordCompletion({ space: false })) return;
2228
+ // Right walks over the ghost prediction or word completion as if it were typed: no trailing space.
2229
+ if (this.#acceptPrediction() || this.#acceptWordCompletion({ space: false })) return;
2209
2230
  this.#moveCursor(0, 1);
2210
2231
  } else if (kb.matchesCanonical(canonical, "tui.editor.cursorLeft")) {
2211
2232
  // Left
@@ -2248,18 +2269,43 @@ export class Editor implements Component, Focusable {
2248
2269
  return this.isShowingAutocomplete() ? false : this.#runVimKey("escape", vim);
2249
2270
  }
2250
2271
  if (vim.mode === "insert") return false;
2272
+ if (vim.mode === "replace") {
2273
+ if (canonical === "backspace" || matchesKey(data, "backspace") || matchesKey(data, "shift+backspace")) {
2274
+ this.#replaceBackspace();
2275
+ return true;
2276
+ }
2277
+ const replacing = extractPrintableText(data);
2278
+ if (replacing) {
2279
+ for (const seg of segmenter.segment(replacing)) this.#overwriteReplaceGrapheme(seg.segment);
2280
+ return true;
2281
+ }
2282
+ }
2251
2283
 
2284
+ // `f`/`t`/`r` are waiting for a character. Space is that character; arrows and other named
2285
+ // keys cancel, instead of being rewritten to `h`/`l` and inserted or searched for.
2286
+ const awaitingTarget = /[fFtTr]$/.test(vim.pendingText);
2287
+ if (awaitingTarget && (canonical === "space" || data === " ")) return this.#runVimKey(" ", vim);
2252
2288
  const mapped = canonical === undefined ? undefined : VIM_NAV_KEYS[canonical];
2289
+ if (awaitingTarget && mapped !== undefined) return this.#runVimKey("escape", vim);
2253
2290
  if (mapped !== undefined) return this.#runVimKey(mapped, vim);
2254
2291
 
2255
- // Control chords carry no printable text and stay with the host.
2292
+ // Control chords, Enter, and Tab carry no printable text and stay with the host. A half-typed
2293
+ // command (`r`, `f`, `d`) is cancelled first, like character-jump mode, so it cannot outlive a
2294
+ // submit and swallow the first key of the next prompt.
2256
2295
  const printable = extractPrintableText(data);
2257
- if (!printable) return false;
2296
+ if (!printable) {
2297
+ if (vim.pending) this.#runVimKey("escape", vim);
2298
+ return false;
2299
+ }
2258
2300
 
2259
2301
  // Batched stdin can deliver several keystrokes at once, so replay the run one grapheme at a
2260
- // time. A command that drops out of Normal mode part-way (`iabc`) turns the rest of the run
2261
- // back into literal text rather than swallowing it.
2302
+ // time. A command that drops out of Normal mode part-way (`iabc`, `Rxx`) turns the rest of
2303
+ // the run into literal text or replace-mode overwrites rather than more Normal commands.
2262
2304
  for (const seg of segmenter.segment(printable)) {
2305
+ if (vim.mode === "replace") {
2306
+ this.#overwriteReplaceGrapheme(seg.segment);
2307
+ continue;
2308
+ }
2263
2309
  if (this.#runVimKey(seg.segment, vim)) continue;
2264
2310
  this.#insertCharacter(printable.slice(seg.index));
2265
2311
  return true;
@@ -2280,11 +2326,22 @@ export class Editor implements Component, Focusable {
2280
2326
  if (vim.mode !== before || vim.pendingText !== pendingBefore || this.vimSelectedLines !== selectedLinesBefore) {
2281
2327
  this.onVimModeChange?.(vim.mode);
2282
2328
  }
2329
+ if (before === "replace" && vim.mode !== "replace") this.#replaceLog.length = 0;
2330
+ if (vim.mode === "replace" && before !== "replace") this.#replaceLog.length = 0;
2283
2331
  return true;
2284
2332
  }
2285
2333
 
2286
2334
  #applyVimCommands(commands: readonly VimCommand[]): void {
2287
2335
  for (const command of commands) {
2336
+ // A delete or other edit in replace mode invalidates Backspace restores. Motions do not.
2337
+ if (
2338
+ this.#vim?.mode === "replace" &&
2339
+ command.kind !== "move" &&
2340
+ command.kind !== "mode" &&
2341
+ command.kind !== "yank"
2342
+ ) {
2343
+ this.#replaceLog.length = 0;
2344
+ }
2288
2345
  switch (command.kind) {
2289
2346
  case "move":
2290
2347
  this.#moveVimCursor(command.to);
@@ -2316,12 +2373,133 @@ export class Editor implements Component, Focusable {
2316
2373
  case "undo":
2317
2374
  this.#applyUndo();
2318
2375
  break;
2376
+ case "replace":
2377
+ this.#replaceVimSpan(command.from, command.to, command.text);
2378
+ break;
2379
+ case "join":
2380
+ this.#joinVimLines(command.fromLine, command.lines);
2381
+ break;
2382
+ case "indent":
2383
+ this.#indentVimLines(command.fromLine, command.toLine, command.out);
2384
+ break;
2319
2385
  }
2320
2386
  }
2321
2387
  this.#clampVimCursor();
2322
2388
  this.invalidate();
2323
2389
  }
2324
2390
 
2391
+ #replaceVimSpan(from: VimPosition, to: VimPosition, text: string): void {
2392
+ const line = this.#state.lines[from.line] ?? "";
2393
+ if (this.#spanCutsAtomicToken(line, from.col, to.col)) return;
2394
+ this.#recordUndoState();
2395
+ this.#lastAction = null;
2396
+ this.#state.lines[from.line] = line.slice(0, from.col) + text + line.slice(to.col);
2397
+ this.#state.cursorLine = from.line;
2398
+ let last = from.col;
2399
+ for (const seg of segmenter.segment(text)) last = from.col + seg.index;
2400
+ this.#setCursorCol(last);
2401
+ this.#afterVimEdit();
2402
+ }
2403
+
2404
+ #overwriteReplaceGrapheme(grapheme: string): void {
2405
+ const lineIdx = this.#state.cursorLine;
2406
+ const line = this.#state.lines[lineIdx] ?? "";
2407
+ const col = this.#state.cursorCol;
2408
+ if (this.#atomicTokenAt(line, col)) return;
2409
+ // Any other edit since the last overwrite (setText, `x`, paste) resets `#lastAction`, which
2410
+ // makes the logged columns meaningless; start a fresh undo step and a fresh log.
2411
+ if (this.#lastAction !== "replace") {
2412
+ this.#recordUndoState();
2413
+ this.#replaceLog.length = 0;
2414
+ }
2415
+ this.#lastAction = "replace";
2416
+ // Editing a recalled prompt makes it a draft, so Up/Down at an edge move the cursor again
2417
+ // instead of navigating history over the overwritten text (same as `#afterVimEdit`).
2418
+ this.#historyIndex = -1;
2419
+ const end = col >= line.length ? col : nextGraphemeStart(line, col);
2420
+ const removed = line.slice(col, end);
2421
+ this.#state.lines[lineIdx] = line.slice(0, col) + grapheme + line.slice(end);
2422
+ this.#replaceLog.push({ line: lineIdx, col, removed, text: grapheme });
2423
+ this.#setCursorCol(col + grapheme.length);
2424
+ this.#notifyChange();
2425
+ }
2426
+
2427
+ #replaceBackspace(): void {
2428
+ if (this.#lastAction !== "replace") {
2429
+ this.#replaceLog.length = 0;
2430
+ return;
2431
+ }
2432
+ const entry = this.#replaceLog.pop();
2433
+ if (!entry) return;
2434
+ const line = this.#state.lines[entry.line] ?? "";
2435
+ const stillThere = line.slice(entry.col, entry.col + entry.text.length) === entry.text;
2436
+ const atCursor = this.#state.cursorLine === entry.line && this.#state.cursorCol === entry.col + entry.text.length;
2437
+ if (!stillThere) return;
2438
+ if (!atCursor) {
2439
+ this.#replaceLog.push(entry);
2440
+ return;
2441
+ }
2442
+ this.#state.lines[entry.line] =
2443
+ line.slice(0, entry.col) + entry.removed + line.slice(entry.col + entry.text.length);
2444
+ this.#setCursorCol(entry.col);
2445
+ this.#notifyChange();
2446
+ }
2447
+
2448
+ #joinVimLines(fromLine: number, span: number): void {
2449
+ const start = Math.max(0, Math.min(fromLine, this.#state.lines.length - 1));
2450
+ const last = Math.min(start + Math.max(span, 2) - 1, this.#state.lines.length - 1);
2451
+ if (last <= start) return;
2452
+ this.#recordUndoState();
2453
+ let text = this.#state.lines[start] ?? "";
2454
+ let cursor = text.length;
2455
+ for (let i = start + 1; i <= last; i++) {
2456
+ const raw = this.#state.lines[i] ?? "";
2457
+ let trim = /^[ \t]*/.exec(raw)?.[0].length ?? 0;
2458
+ const token = this.#atomicTokenAt(raw, 0);
2459
+ if (token && token.start < trim) trim = token.start;
2460
+ const next = raw.slice(trim);
2461
+ const at = text.length;
2462
+ if (i === start + 1) cursor = at;
2463
+ // Vim join: no extra space after whitespace, before `)`, or for an empty line; two spaces after `.!?`.
2464
+ const gap =
2465
+ text.length === 0 || next.length === 0 || /[ \t]$/.test(text) || next.startsWith(")")
2466
+ ? ""
2467
+ : /[.!?]$/.test(text)
2468
+ ? " "
2469
+ : " ";
2470
+ text += gap + next;
2471
+ }
2472
+ this.#state.lines.splice(start, last - start + 1, text);
2473
+ this.#state.cursorLine = start;
2474
+ this.#setCursorCol(cursor);
2475
+ this.#lastAction = null;
2476
+ this.#afterVimEdit();
2477
+ }
2478
+
2479
+ #indentVimLines(fromLine: number, toLine: number, out: boolean): void {
2480
+ const first = Math.max(0, Math.min(fromLine, this.#state.lines.length - 1));
2481
+ const last = Math.max(first, Math.min(toLine, this.#state.lines.length - 1));
2482
+ // Two spaces, not Vim's shiftwidth of 8: a prompt draft should not jump a tab stop. A leading tab shifts by one tab so the line keeps its own indent style.
2483
+ // Empty lines stay empty, as in Vim, so shifting a paragraph never leaves trailing blanks.
2484
+ const next = this.#state.lines.slice(first, last + 1).map(line => {
2485
+ const text = line ?? "";
2486
+ if (!out) return text.length === 0 ? text : (text.startsWith("\t") ? "\t" : " ") + text;
2487
+ if (text.startsWith("\t")) return text.slice(1);
2488
+ const spaces = /^ */.exec(text)?.[0].length ?? 0;
2489
+ return text.slice(Math.min(2, spaces));
2490
+ });
2491
+ if (next.every((line, i) => line === (this.#state.lines[first + i] ?? ""))) return;
2492
+ this.#recordUndoState();
2493
+ for (let i = first; i <= last; i++) this.#state.lines[i] = next[i - first] ?? "";
2494
+ this.#state.cursorLine = first;
2495
+ const landed = this.#state.lines[first] ?? "";
2496
+ let col = 0;
2497
+ while (col < landed.length && /\s/.test(landed.charAt(col))) col++;
2498
+ this.#setCursorCol(col);
2499
+ this.#lastAction = null;
2500
+ this.#afterVimEdit();
2501
+ }
2502
+
2325
2503
  #moveVimCursor(to: VimPosition): void {
2326
2504
  this.#state.cursorLine = Math.max(0, Math.min(to.line, this.#state.lines.length - 1));
2327
2505
  const line = this.#state.lines[this.#state.cursorLine] ?? "";
@@ -2666,6 +2844,11 @@ export class Editor implements Component, Focusable {
2666
2844
  return this.#textRevision;
2667
2845
  }
2668
2846
 
2847
+ /** Monotonic atom-table revision for render caches: advances whenever {@link atoms} is mutated or replaced. */
2848
+ get atomsRevision(): number {
2849
+ return this.#atomsRevision;
2850
+ }
2851
+
2669
2852
  #notifyChange(text?: string): void {
2670
2853
  this.#textRevision++;
2671
2854
  this.onChange?.(text ?? this.getText());
@@ -2704,6 +2887,7 @@ export class Editor implements Component, Focusable {
2704
2887
  * it — for hosts that re-collapse restored draft text via {@link setText}. */
2705
2888
  registerAtom(label: string, expansion: string): void {
2706
2889
  this.#atoms.set(label, expansion);
2890
+ this.#atomsRevision++;
2707
2891
  }
2708
2892
 
2709
2893
  /** Insert `label` (plus a trailing space) at the cursor and register it as an atom expanding
@@ -2740,6 +2924,7 @@ export class Editor implements Component, Focusable {
2740
2924
  /** Drop every registered atom expansion (draft cleared or replaced by the host). */
2741
2925
  clearAtoms(): void {
2742
2926
  this.#atoms.clear();
2927
+ this.#atomsRevision++;
2743
2928
  }
2744
2929
 
2745
2930
  /**
@@ -2779,7 +2964,7 @@ export class Editor implements Component, Focusable {
2779
2964
  * Mirrors the transient state the key dispatch tears down before this action so the two
2780
2965
  * cannot diverge: a pending character jump is cancelled by any other key, and an open
2781
2966
  * spelling-assist popup is dismissed by anything that is not one of its accept keys (its
2782
- * debounced refresh skips assist mode, so a surviving list would hang around forever).
2967
+ * refresh skips assist mode, so a surviving list would hang around forever).
2783
2968
  * While Vim owns the buffer (Normal or Visual) the operation is Vim's `x` — deleting the
2784
2969
  * selection and returning to Normal in Visual mode, the grapheme under the cursor
2785
2970
  * otherwise. Only Insert mode and Vim-off editors delete straight through. */
@@ -3189,7 +3374,7 @@ export class Editor implements Component, Focusable {
3189
3374
  }
3190
3375
  }
3191
3376
  } else {
3192
- this.#debouncedUpdateAutocomplete();
3377
+ this.#refreshAutocomplete();
3193
3378
  }
3194
3379
  }
3195
3380
 
@@ -3285,7 +3470,7 @@ export class Editor implements Component, Focusable {
3285
3470
  /** Re-evaluate autocomplete triggers for the text ending at the cursor (used after bulk edits). */
3286
3471
  #retriggerAutocompleteAtCursor(): void {
3287
3472
  if (this.#autocompleteState) {
3288
- this.#debouncedUpdateAutocomplete();
3473
+ this.#refreshAutocomplete();
3289
3474
  return;
3290
3475
  }
3291
3476
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
@@ -3398,6 +3583,23 @@ export class Editor implements Component, Focusable {
3398
3583
  return undefined;
3399
3584
  }
3400
3585
 
3586
+ /** True when `[from, to)` overlaps a placeholder. Counted `r` must no-op rather than split it.
3587
+ * One pass over the line's matches, so a long counted replace stays linear. */
3588
+ #spanCutsAtomicToken(line: string, from: number, to: number): boolean {
3589
+ const re = this.#getAtomicTokenRe();
3590
+ if (re === undefined) return false;
3591
+ re.lastIndex = 0;
3592
+ for (;;) {
3593
+ const match = re.exec(line);
3594
+ if (match === null || match.index >= to) return false;
3595
+ if (match[0].length === 0) {
3596
+ re.lastIndex = match.index + 1;
3597
+ continue;
3598
+ }
3599
+ if (match.index + match[0].length > from) return true;
3600
+ }
3601
+ }
3602
+
3401
3603
  /** Expand the half-open range [start, end) so it never cuts through an atomic
3402
3604
  * placeholder token: a boundary landing inside a token pulls the whole token in. */
3403
3605
  #expandRangeOverAtomicTokens(line: string, start: number, end: number): { start: number; end: number } {
@@ -3467,7 +3669,7 @@ export class Editor implements Component, Focusable {
3467
3669
  this.#cancelAutocomplete();
3468
3670
  this.onAutocompleteUpdate?.();
3469
3671
  } else {
3470
- this.#debouncedUpdateAutocomplete();
3672
+ this.#refreshAutocomplete();
3471
3673
  }
3472
3674
  } else {
3473
3675
  // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
@@ -3647,7 +3849,7 @@ export class Editor implements Component, Focusable {
3647
3849
  this.#notifyChange();
3648
3850
 
3649
3851
  if (this.#autocompleteState) {
3650
- this.#debouncedUpdateAutocomplete();
3852
+ this.#refreshAutocomplete();
3651
3853
  } else {
3652
3854
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
3653
3855
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
@@ -3981,7 +4183,7 @@ export class Editor implements Component, Focusable {
3981
4183
 
3982
4184
  // Update or re-trigger autocomplete after forward delete
3983
4185
  if (this.#autocompleteState) {
3984
- this.#debouncedUpdateAutocomplete();
4186
+ this.#refreshAutocomplete();
3985
4187
  } else {
3986
4188
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
3987
4189
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
@@ -4265,7 +4467,7 @@ export class Editor implements Component, Focusable {
4265
4467
  if (!token.includes(" ") && !token.slice(1).includes("/")) {
4266
4468
  // Guard the timing window where the popup was built for an earlier
4267
4469
  // query (e.g. bare `/`) and the user typed further characters before
4268
- // the 100 ms debounced refresh fired: accept the stale skill only
4470
+ // the async refresh landed: accept the stale skill only
4269
4471
  // when the refreshed popup would still surface it (same gate as
4270
4472
  // buildMidPromptSkillCompletions). `tmp` after a bare slash
4271
4473
  // therefore falls through to file completion instead of rewriting
@@ -4438,6 +4640,7 @@ export class Editor implements Component, Focusable {
4438
4640
  }
4439
4641
 
4440
4642
  async #handleTabCompletion(): Promise<void> {
4643
+ if (this.#acceptPrediction()) return;
4441
4644
  if (this.#acceptWordCompletion({ space: true })) return;
4442
4645
  if (!this.#autocompleteProvider) return;
4443
4646
 
@@ -4475,6 +4678,13 @@ export class Editor implements Component, Focusable {
4475
4678
  if (space) this.#lastAction = "accept-word";
4476
4679
  return true;
4477
4680
  }
4681
+ /** Insert the rest of the shown {@link prediction}, if any. Never submits. */
4682
+ #acceptPrediction(): boolean {
4683
+ const suffix = this.#getPredictionSuffix();
4684
+ if (!suffix) return false;
4685
+ this.#insertTextAtCursor(suffix);
4686
+ return true;
4687
+ }
4478
4688
 
4479
4689
  async #showSpellingSuggestions(): Promise<void> {
4480
4690
  const cursorLine = this.#state.cursorLine;
@@ -4552,7 +4762,6 @@ export class Editor implements Component, Focusable {
4552
4762
 
4553
4763
  #cancelAutocomplete(notifyCancel: boolean = false): void {
4554
4764
  const wasAutocompleting = this.#autocompleteState !== null;
4555
- this.#clearAutocompleteTimeout();
4556
4765
  this.#invalidateAutocompleteRequests();
4557
4766
  this.#autocompleteState = null;
4558
4767
  this.#autocompleteList = undefined;
@@ -4671,7 +4880,7 @@ export class Editor implements Component, Focusable {
4671
4880
  this.onAutocompleteUpdate?.();
4672
4881
  }
4673
4882
 
4674
- /** Filter a shown `@` file list to the live `@` token until the debounced refresh replaces it. */
4883
+ /** Filter a shown `@` file list to the live `@` token until the async refresh replaces it. */
4675
4884
  #narrowAtFileList(): void {
4676
4885
  if (!this.#autocompleteList || !this.#autocompletePrefix.startsWith("@")) return;
4677
4886
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
@@ -4687,27 +4896,50 @@ export class Editor implements Component, Focusable {
4687
4896
  for (const resolve of waiters) resolve();
4688
4897
  }
4689
4898
 
4690
- #debouncedUpdateAutocomplete(): void {
4691
- if (this.#autocompleteState !== "assist") this.#narrowAtFileList();
4692
- if (this.#autocompleteTimeout) {
4693
- clearTimeout(this.#autocompleteTimeout);
4694
- }
4695
- this.#autocompleteTimeout = setTimeout(() => {
4696
- void this.#updateAutocomplete();
4697
- this.#autocompleteTimeout = undefined;
4698
- }, 100);
4899
+ /**
4900
+ * Re-query suggestions for the live text after an edit while the popup is open. Runs on
4901
+ * every edit; the request queue coalesces bursts (a newer request replaces the pending one
4902
+ * and aborts the in-flight one), so no timer delays the popup behind the typing.
4903
+ */
4904
+ #refreshAutocomplete(): void {
4905
+ if (this.#autocompleteState === "assist") return;
4906
+ if (this.#refreshSlashCommandListSync()) return;
4907
+ this.#narrowAtFileList();
4908
+ void this.#updateAutocomplete();
4699
4909
  }
4700
4910
 
4701
- #clearAutocompleteTimeout(): void {
4702
- if (this.#autocompleteTimeout) {
4703
- clearTimeout(this.#autocompleteTimeout);
4704
- this.#autocompleteTimeout = undefined;
4911
+ /**
4912
+ * Rebuild a shown slash command-name popup from the live token synchronously. An async
4913
+ * refresh resolves a microtask later at best, so Enter/Tab arriving in the same input
4914
+ * batch would accept a row computed for an earlier query (`/` → `mod` → Enter ran `/login`).
4915
+ * Returns false when the async refresh must handle the edit (arguments, mid-prompt
4916
+ * skills, absolute-path fall-through).
4917
+ */
4918
+ #refreshSlashCommandListSync(): boolean {
4919
+ const provider = this.#autocompleteProvider;
4920
+ if (this.#autocompleteState !== "regular" || !provider?.trySyncSlashCompletion) return false;
4921
+ if (!this.#isInSubmittedSlashCommandContext()) return false;
4922
+ const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
4923
+ const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
4924
+ const token = textBeforeCursor.trimStart();
4925
+ if (token.includes(" ")) return false;
4926
+ const suggestions = provider.trySyncSlashCompletion(textBeforeCursor);
4927
+ if (suggestions) {
4928
+ this.#invalidateAutocompleteRequests();
4929
+ this.#showAutocompleteSuggestions(suggestions, "regular");
4930
+ return true;
4705
4931
  }
4932
+ // No command matches: an inner slash may still be an absolute path, which needs the
4933
+ // async file lookup. Otherwise the async refresh would only close the popup.
4934
+ if (token.slice(1).includes("/")) return false;
4935
+ this.#cancelAutocomplete();
4936
+ this.onAutocompleteUpdate?.();
4937
+ return true;
4706
4938
  }
4707
4939
 
4708
4940
  /**
4709
4941
  * Get inline hint text to show as dim ghost text after the cursor.
4710
- * Checks selected autocomplete item's hint first, then falls back to provider.
4942
+ * Checks the selected autocomplete item's hint first, then the prediction, then the provider.
4711
4943
  */
4712
4944
  #getInlineHint(): string | null {
4713
4945
  // Check selected autocomplete item for a hint
@@ -4716,6 +4948,9 @@ export class Editor implements Component, Focusable {
4716
4948
  return selected?.hint ?? null;
4717
4949
  }
4718
4950
 
4951
+ const prediction = this.#getPredictionSuffix();
4952
+ if (prediction) return prediction;
4953
+
4719
4954
  // Fall back to provider's getInlineHint
4720
4955
  if (this.#autocompleteProvider?.getInlineHint) {
4721
4956
  const hint = this.#autocompleteProvider.getInlineHint(
@@ -4729,9 +4964,22 @@ export class Editor implements Component, Focusable {
4729
4964
  return this.#getWordCompletion();
4730
4965
  }
4731
4966
  #getPlaceholder(): string | undefined {
4732
- if (this.#autocompleteState || !this.#isEditorEmpty()) return undefined;
4967
+ if (this.#autocompleteState || !this.#isEditorEmpty() || this.#getPredictionSuffix()) return undefined;
4733
4968
  return this.placeholder?.() || undefined;
4734
4969
  }
4970
+ /** The part of {@link prediction} not yet typed, or `null` when no prediction is shown. */
4971
+ #getPredictionSuffix(): string | null {
4972
+ if (this.#autocompleteState) return null;
4973
+ const prediction = this.prediction?.();
4974
+ if (!prediction) return null;
4975
+ const { lines, cursorCol } = this.#state;
4976
+ if (lines.length !== 1) return null;
4977
+ const typed = lines[0] ?? "";
4978
+ if (cursorCol !== typed.length || typed.length >= prediction.length || !prediction.startsWith(typed)) {
4979
+ return null;
4980
+ }
4981
+ return prediction.slice(typed.length);
4982
+ }
4735
4983
  /** Typing `typed` where it diverges from the shown ghost completion rejects that ghost. */
4736
4984
  #reportTypedPastWordCompletion(typed: string): void {
4737
4985
  if (!this.#textAssistProvider?.wordCompletionFeedback) return;
@@ -44,6 +44,7 @@ export declare class FormField implements Component, Focusable, MouseRoutable {
44
44
  /** Label, description, details, preview, the control, error, summary, hint and footer as one column. */
45
45
  describe(_cx: DescribeContext): NativeNode;
46
46
  get focused(): boolean;
47
+ get hasError(): boolean;
47
48
  get debugChildren(): readonly Component[];
48
49
  set focused(value: boolean);
49
50
  setUseTerminalCursor(useTerminalCursor: boolean): void;
@@ -97,6 +97,10 @@ class OptionalText implements Component {
97
97
  this.#text = new StyledText("", style, token);
98
98
  }
99
99
 
100
+ get value(): string {
101
+ return this.#value;
102
+ }
103
+
100
104
  describe(cx: DescribeContext): NativeNode {
101
105
  return this.#value ? this.#text.describe(cx) : this.#hidden;
102
106
  }
@@ -200,6 +204,10 @@ export class FormField implements Component, Focusable, MouseRoutable {
200
204
  return this.#focused;
201
205
  }
202
206
 
207
+ get hasError(): boolean {
208
+ return this.#error.value !== "";
209
+ }
210
+
203
211
  get debugChildren(): readonly Component[] {
204
212
  return [this.control];
205
213
  }