nastech-tui 0.0.1

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 (424) hide show
  1. package/.prettierrc +11 -0
  2. package/README.md +346 -0
  3. package/eslint.config.mjs +111 -0
  4. package/package.json +51 -0
  5. package/packages/nastech-ink/ambient.d.ts +83 -0
  6. package/packages/nastech-ink/index.d.ts +40 -0
  7. package/packages/nastech-ink/index.js +1 -0
  8. package/packages/nastech-ink/nastech-ink/ambient.d.ts +83 -0
  9. package/packages/nastech-ink/nastech-ink/index.d.ts +40 -0
  10. package/packages/nastech-ink/nastech-ink/index.js +1 -0
  11. package/packages/nastech-ink/nastech-ink/package.json +54 -0
  12. package/packages/nastech-ink/nastech-ink/src/bootstrap/state.ts +9 -0
  13. package/packages/nastech-ink/nastech-ink/src/entry-exports.ts +32 -0
  14. package/packages/nastech-ink/nastech-ink/src/hooks/use-stderr.ts +15 -0
  15. package/packages/nastech-ink/nastech-ink/src/hooks/use-stdout.ts +15 -0
  16. package/packages/nastech-ink/nastech-ink/src/ink/Ansi.tsx +435 -0
  17. package/packages/nastech-ink/nastech-ink/src/ink/app-mouse.test.ts +123 -0
  18. package/packages/nastech-ink/nastech-ink/src/ink/bidi.ts +145 -0
  19. package/packages/nastech-ink/nastech-ink/src/ink/cache-eviction.ts +45 -0
  20. package/packages/nastech-ink/nastech-ink/src/ink/clearTerminal.ts +68 -0
  21. package/packages/nastech-ink/nastech-ink/src/ink/colorize.test.ts +60 -0
  22. package/packages/nastech-ink/nastech-ink/src/ink/colorize.ts +277 -0
  23. package/packages/nastech-ink/nastech-ink/src/ink/components/AlternateScreen.tsx +133 -0
  24. package/packages/nastech-ink/nastech-ink/src/ink/components/App.tsx +830 -0
  25. package/packages/nastech-ink/nastech-ink/src/ink/components/AppContext.ts +20 -0
  26. package/packages/nastech-ink/nastech-ink/src/ink/components/Box.tsx +294 -0
  27. package/packages/nastech-ink/nastech-ink/src/ink/components/Button.tsx +236 -0
  28. package/packages/nastech-ink/nastech-ink/src/ink/components/ClockContext.tsx +133 -0
  29. package/packages/nastech-ink/nastech-ink/src/ink/components/CursorAdvanceContext.ts +35 -0
  30. package/packages/nastech-ink/nastech-ink/src/ink/components/CursorDeclarationContext.ts +28 -0
  31. package/packages/nastech-ink/nastech-ink/src/ink/components/ErrorOverview.tsx +130 -0
  32. package/packages/nastech-ink/nastech-ink/src/ink/components/Link.tsx +38 -0
  33. package/packages/nastech-ink/nastech-ink/src/ink/components/Newline.tsx +43 -0
  34. package/packages/nastech-ink/nastech-ink/src/ink/components/NoSelect.tsx +73 -0
  35. package/packages/nastech-ink/nastech-ink/src/ink/components/RawAnsi.tsx +61 -0
  36. package/packages/nastech-ink/nastech-ink/src/ink/components/ScrollBox.tsx +290 -0
  37. package/packages/nastech-ink/nastech-ink/src/ink/components/Spacer.tsx +23 -0
  38. package/packages/nastech-ink/nastech-ink/src/ink/components/StdinContext.ts +25 -0
  39. package/packages/nastech-ink/nastech-ink/src/ink/components/TerminalFocusContext.tsx +63 -0
  40. package/packages/nastech-ink/nastech-ink/src/ink/components/TerminalSizeContext.tsx +7 -0
  41. package/packages/nastech-ink/nastech-ink/src/ink/components/Text.test.ts +38 -0
  42. package/packages/nastech-ink/nastech-ink/src/ink/components/Text.tsx +336 -0
  43. package/packages/nastech-ink/nastech-ink/src/ink/constants.ts +6 -0
  44. package/packages/nastech-ink/nastech-ink/src/ink/cursor.ts +5 -0
  45. package/packages/nastech-ink/nastech-ink/src/ink/devtools.ts +2 -0
  46. package/packages/nastech-ink/nastech-ink/src/ink/dom.ts +495 -0
  47. package/packages/nastech-ink/nastech-ink/src/ink/events/click-event.ts +38 -0
  48. package/packages/nastech-ink/nastech-ink/src/ink/events/cmd-shortcuts.test.ts +65 -0
  49. package/packages/nastech-ink/nastech-ink/src/ink/events/dispatcher.ts +242 -0
  50. package/packages/nastech-ink/nastech-ink/src/ink/events/emitter.ts +40 -0
  51. package/packages/nastech-ink/nastech-ink/src/ink/events/event-handlers.ts +84 -0
  52. package/packages/nastech-ink/nastech-ink/src/ink/events/event.ts +11 -0
  53. package/packages/nastech-ink/nastech-ink/src/ink/events/focus-event.ts +18 -0
  54. package/packages/nastech-ink/nastech-ink/src/ink/events/input-event.ts +176 -0
  55. package/packages/nastech-ink/nastech-ink/src/ink/events/keyboard-event.ts +57 -0
  56. package/packages/nastech-ink/nastech-ink/src/ink/events/mouse-event.ts +18 -0
  57. package/packages/nastech-ink/nastech-ink/src/ink/events/paste-event.ts +10 -0
  58. package/packages/nastech-ink/nastech-ink/src/ink/events/resize-event.ts +12 -0
  59. package/packages/nastech-ink/nastech-ink/src/ink/events/terminal-event.ts +107 -0
  60. package/packages/nastech-ink/nastech-ink/src/ink/events/terminal-focus-event.ts +19 -0
  61. package/packages/nastech-ink/nastech-ink/src/ink/focus.ts +219 -0
  62. package/packages/nastech-ink/nastech-ink/src/ink/frame.ts +124 -0
  63. package/packages/nastech-ink/nastech-ink/src/ink/get-max-width.ts +27 -0
  64. package/packages/nastech-ink/nastech-ink/src/ink/global.d.ts +1 -0
  65. package/packages/nastech-ink/nastech-ink/src/ink/hit-test.test.ts +38 -0
  66. package/packages/nastech-ink/nastech-ink/src/ink/hit-test.ts +224 -0
  67. package/packages/nastech-ink/nastech-ink/src/ink/hooks/use-animation-frame.ts +62 -0
  68. package/packages/nastech-ink/nastech-ink/src/ink/hooks/use-app.ts +9 -0
  69. package/packages/nastech-ink/nastech-ink/src/ink/hooks/use-cursor-advance.ts +33 -0
  70. package/packages/nastech-ink/nastech-ink/src/ink/hooks/use-declared-cursor.ts +75 -0
  71. package/packages/nastech-ink/nastech-ink/src/ink/hooks/use-external-process.ts +27 -0
  72. package/packages/nastech-ink/nastech-ink/src/ink/hooks/use-input.ts +95 -0
  73. package/packages/nastech-ink/nastech-ink/src/ink/hooks/use-interval.ts +71 -0
  74. package/packages/nastech-ink/package.json +57 -0
  75. package/packages/nastech-ink/src/bootstrap/state.ts +9 -0
  76. package/packages/nastech-ink/src/entry-exports.ts +32 -0
  77. package/packages/nastech-ink/src/hooks/use-stderr.ts +15 -0
  78. package/packages/nastech-ink/src/hooks/use-stdout.ts +15 -0
  79. package/packages/nastech-ink/src/ink/Ansi.tsx +435 -0
  80. package/packages/nastech-ink/src/ink/app-mouse.test.ts +123 -0
  81. package/packages/nastech-ink/src/ink/app-rawmode-mouse.test.ts +91 -0
  82. package/packages/nastech-ink/src/ink/bidi.ts +145 -0
  83. package/packages/nastech-ink/src/ink/cache-eviction.ts +45 -0
  84. package/packages/nastech-ink/src/ink/clearTerminal.ts +68 -0
  85. package/packages/nastech-ink/src/ink/colorize.test.ts +60 -0
  86. package/packages/nastech-ink/src/ink/colorize.ts +277 -0
  87. package/packages/nastech-ink/src/ink/components/AlternateScreen.tsx +133 -0
  88. package/packages/nastech-ink/src/ink/components/App.tsx +855 -0
  89. package/packages/nastech-ink/src/ink/components/AppContext.ts +20 -0
  90. package/packages/nastech-ink/src/ink/components/Box.tsx +294 -0
  91. package/packages/nastech-ink/src/ink/components/Button.tsx +236 -0
  92. package/packages/nastech-ink/src/ink/components/ClockContext.tsx +133 -0
  93. package/packages/nastech-ink/src/ink/components/CursorAdvanceContext.ts +35 -0
  94. package/packages/nastech-ink/src/ink/components/CursorDeclarationContext.ts +28 -0
  95. package/packages/nastech-ink/src/ink/components/ErrorOverview.tsx +130 -0
  96. package/packages/nastech-ink/src/ink/components/Link.tsx +38 -0
  97. package/packages/nastech-ink/src/ink/components/Newline.tsx +43 -0
  98. package/packages/nastech-ink/src/ink/components/NoSelect.tsx +73 -0
  99. package/packages/nastech-ink/src/ink/components/RawAnsi.tsx +61 -0
  100. package/packages/nastech-ink/src/ink/components/ScrollBox.tsx +290 -0
  101. package/packages/nastech-ink/src/ink/components/Spacer.tsx +23 -0
  102. package/packages/nastech-ink/src/ink/components/StdinContext.ts +25 -0
  103. package/packages/nastech-ink/src/ink/components/TerminalFocusContext.tsx +63 -0
  104. package/packages/nastech-ink/src/ink/components/TerminalSizeContext.tsx +7 -0
  105. package/packages/nastech-ink/src/ink/components/Text.test.ts +38 -0
  106. package/packages/nastech-ink/src/ink/components/Text.tsx +336 -0
  107. package/packages/nastech-ink/src/ink/constants.ts +6 -0
  108. package/packages/nastech-ink/src/ink/cursor.ts +5 -0
  109. package/packages/nastech-ink/src/ink/devtools.ts +2 -0
  110. package/packages/nastech-ink/src/ink/dom.ts +495 -0
  111. package/packages/nastech-ink/src/ink/events/click-event.ts +38 -0
  112. package/packages/nastech-ink/src/ink/events/cmd-shortcuts.test.ts +65 -0
  113. package/packages/nastech-ink/src/ink/events/dispatcher.ts +242 -0
  114. package/packages/nastech-ink/src/ink/events/emitter.ts +40 -0
  115. package/packages/nastech-ink/src/ink/events/event-handlers.ts +84 -0
  116. package/packages/nastech-ink/src/ink/events/event.ts +11 -0
  117. package/packages/nastech-ink/src/ink/events/focus-event.ts +18 -0
  118. package/packages/nastech-ink/src/ink/events/input-event.ts +176 -0
  119. package/packages/nastech-ink/src/ink/events/keyboard-event.ts +57 -0
  120. package/packages/nastech-ink/src/ink/events/mouse-event.ts +18 -0
  121. package/packages/nastech-ink/src/ink/events/paste-event.ts +10 -0
  122. package/packages/nastech-ink/src/ink/events/resize-event.ts +12 -0
  123. package/packages/nastech-ink/src/ink/events/terminal-event.ts +107 -0
  124. package/packages/nastech-ink/src/ink/events/terminal-focus-event.ts +19 -0
  125. package/packages/nastech-ink/src/ink/focus.ts +219 -0
  126. package/packages/nastech-ink/src/ink/frame.ts +124 -0
  127. package/packages/nastech-ink/src/ink/get-max-width.ts +27 -0
  128. package/packages/nastech-ink/src/ink/global.d.ts +1 -0
  129. package/packages/nastech-ink/src/ink/hit-test.test.ts +38 -0
  130. package/packages/nastech-ink/src/ink/hit-test.ts +224 -0
  131. package/packages/nastech-ink/src/ink/hooks/use-animation-frame.ts +62 -0
  132. package/packages/nastech-ink/src/ink/hooks/use-app.ts +9 -0
  133. package/packages/nastech-ink/src/ink/hooks/use-cursor-advance.ts +33 -0
  134. package/packages/nastech-ink/src/ink/hooks/use-declared-cursor.ts +75 -0
  135. package/packages/nastech-ink/src/ink/hooks/use-external-process.ts +27 -0
  136. package/packages/nastech-ink/src/ink/hooks/use-input.ts +95 -0
  137. package/packages/nastech-ink/src/ink/hooks/use-interval.ts +71 -0
  138. package/packages/nastech-ink/src/ink/hooks/use-search-highlight.ts +56 -0
  139. package/packages/nastech-ink/src/ink/hooks/use-selection.ts +101 -0
  140. package/packages/nastech-ink/src/ink/hooks/use-stdin.ts +9 -0
  141. package/packages/nastech-ink/src/ink/hooks/use-tab-status.ts +71 -0
  142. package/packages/nastech-ink/src/ink/hooks/use-terminal-focus.ts +18 -0
  143. package/packages/nastech-ink/src/ink/hooks/use-terminal-title.ts +34 -0
  144. package/packages/nastech-ink/src/ink/hooks/use-terminal-viewport.ts +100 -0
  145. package/packages/nastech-ink/src/ink/hyperlinkHover.ts +52 -0
  146. package/packages/nastech-ink/src/ink/ink-cursor-advance.test.ts +234 -0
  147. package/packages/nastech-ink/src/ink/ink-resize.test.ts +50 -0
  148. package/packages/nastech-ink/src/ink/ink.tsx +2705 -0
  149. package/packages/nastech-ink/src/ink/instances.ts +10 -0
  150. package/packages/nastech-ink/src/ink/layout/engine.ts +6 -0
  151. package/packages/nastech-ink/src/ink/layout/geometry.ts +98 -0
  152. package/packages/nastech-ink/src/ink/layout/node.ts +145 -0
  153. package/packages/nastech-ink/src/ink/layout/yoga.ts +313 -0
  154. package/packages/nastech-ink/src/ink/line-width-cache.ts +38 -0
  155. package/packages/nastech-ink/src/ink/log-update.test.ts +223 -0
  156. package/packages/nastech-ink/src/ink/log-update.ts +752 -0
  157. package/packages/nastech-ink/src/ink/lru.ts +14 -0
  158. package/packages/nastech-ink/src/ink/measure-element.ts +23 -0
  159. package/packages/nastech-ink/src/ink/measure-text.ts +50 -0
  160. package/packages/nastech-ink/src/ink/node-cache.ts +53 -0
  161. package/packages/nastech-ink/src/ink/optimizer.ts +99 -0
  162. package/packages/nastech-ink/src/ink/output.ts +845 -0
  163. package/packages/nastech-ink/src/ink/parse-keypress.test.ts +133 -0
  164. package/packages/nastech-ink/src/ink/parse-keypress.ts +848 -0
  165. package/packages/nastech-ink/src/ink/reconciler.ts +382 -0
  166. package/packages/nastech-ink/src/ink/render-border.ts +206 -0
  167. package/packages/nastech-ink/src/ink/render-node-to-output.ts +1582 -0
  168. package/packages/nastech-ink/src/ink/render-to-screen.ts +236 -0
  169. package/packages/nastech-ink/src/ink/renderer.ts +169 -0
  170. package/packages/nastech-ink/src/ink/root.ts +204 -0
  171. package/packages/nastech-ink/src/ink/screen.ts +1590 -0
  172. package/packages/nastech-ink/src/ink/searchHighlight.ts +91 -0
  173. package/packages/nastech-ink/src/ink/selection.test.ts +82 -0
  174. package/packages/nastech-ink/src/ink/selection.ts +1143 -0
  175. package/packages/nastech-ink/src/ink/squash-text-nodes.ts +74 -0
  176. package/packages/nastech-ink/src/ink/stringWidth.ts +341 -0
  177. package/packages/nastech-ink/src/ink/styles.ts +750 -0
  178. package/packages/nastech-ink/src/ink/supports-hyperlinks.ts +51 -0
  179. package/packages/nastech-ink/src/ink/tabstops.ts +44 -0
  180. package/packages/nastech-ink/src/ink/terminal-focus-state.ts +52 -0
  181. package/packages/nastech-ink/src/ink/terminal-querier.ts +222 -0
  182. package/packages/nastech-ink/src/ink/terminal.test.ts +15 -0
  183. package/packages/nastech-ink/src/ink/terminal.ts +299 -0
  184. package/packages/nastech-ink/src/ink/termio/ansi.ts +75 -0
  185. package/packages/nastech-ink/src/ink/termio/csi.ts +334 -0
  186. package/packages/nastech-ink/src/ink/termio/dec.ts +99 -0
  187. package/packages/nastech-ink/src/ink/termio/esc.ts +69 -0
  188. package/packages/nastech-ink/src/ink/termio/osc.test.ts +191 -0
  189. package/packages/nastech-ink/src/ink/termio/osc.ts +724 -0
  190. package/packages/nastech-ink/src/ink/termio/parser.ts +467 -0
  191. package/packages/nastech-ink/src/ink/termio/sgr.ts +362 -0
  192. package/packages/nastech-ink/src/ink/termio/tokenize.test.ts +185 -0
  193. package/packages/nastech-ink/src/ink/termio/tokenize.ts +350 -0
  194. package/packages/nastech-ink/src/ink/termio/types.ts +230 -0
  195. package/packages/nastech-ink/src/ink/termio.ts +42 -0
  196. package/packages/nastech-ink/src/ink/useTerminalNotification.ts +110 -0
  197. package/packages/nastech-ink/src/ink/warn.ts +15 -0
  198. package/packages/nastech-ink/src/ink/widest-line.ts +22 -0
  199. package/packages/nastech-ink/src/ink/wrap-text.test.ts +17 -0
  200. package/packages/nastech-ink/src/ink/wrap-text.ts +144 -0
  201. package/packages/nastech-ink/src/ink/wrapAnsi.ts +13 -0
  202. package/packages/nastech-ink/src/native-ts/yoga-layout/enums.ts +112 -0
  203. package/packages/nastech-ink/src/native-ts/yoga-layout/index.ts +2326 -0
  204. package/packages/nastech-ink/src/utils/debug.ts +6 -0
  205. package/packages/nastech-ink/src/utils/earlyInput.ts +131 -0
  206. package/packages/nastech-ink/src/utils/env.ts +66 -0
  207. package/packages/nastech-ink/src/utils/envUtils.ts +13 -0
  208. package/packages/nastech-ink/src/utils/execFileNoThrow.test.ts +146 -0
  209. package/packages/nastech-ink/src/utils/execFileNoThrow.ts +115 -0
  210. package/packages/nastech-ink/src/utils/fullscreen.ts +3 -0
  211. package/packages/nastech-ink/src/utils/intl.ts +87 -0
  212. package/packages/nastech-ink/src/utils/log.ts +7 -0
  213. package/packages/nastech-ink/src/utils/semver.ts +57 -0
  214. package/packages/nastech-ink/src/utils/sliceAnsi.ts +106 -0
  215. package/packages/nastech-ink/text-input.d.ts +2 -0
  216. package/packages/nastech-ink/text-input.js +1 -0
  217. package/scripts/build.mjs +61 -0
  218. package/scripts/profile-tui.mjs +121 -0
  219. package/src/__tests__/activeSessionSwitcher.test.ts +157 -0
  220. package/src/__tests__/appChromeStatusRule.test.tsx +84 -0
  221. package/src/__tests__/appChromeStatusRuleDevCredits.test.tsx +73 -0
  222. package/src/__tests__/approvalAction.test.ts +50 -0
  223. package/src/__tests__/asCommandDispatch.test.ts +27 -0
  224. package/src/__tests__/blockLayout.test.ts +122 -0
  225. package/src/__tests__/clipboard.test.ts +369 -0
  226. package/src/__tests__/constants.test.ts +53 -0
  227. package/src/__tests__/createGatewayEventHandler.test.ts +1091 -0
  228. package/src/__tests__/createSlashHandler.test.ts +822 -0
  229. package/src/__tests__/creditsCommand.test.ts +144 -0
  230. package/src/__tests__/cursorDriftRegression.test.ts +114 -0
  231. package/src/__tests__/details.test.ts +115 -0
  232. package/src/__tests__/emoji.test.ts +64 -0
  233. package/src/__tests__/externalLink.test.ts +144 -0
  234. package/src/__tests__/forceTruecolor.test.ts +191 -0
  235. package/src/__tests__/gatewayClient.test.ts +394 -0
  236. package/src/__tests__/gatewayRecovery.test.ts +47 -0
  237. package/src/__tests__/markdown.test.ts +331 -0
  238. package/src/__tests__/mathUnicode.test.ts +293 -0
  239. package/src/__tests__/memoryMonitor.test.ts +102 -0
  240. package/src/__tests__/messageLine.test.ts +19 -0
  241. package/src/__tests__/messages.test.ts +92 -0
  242. package/src/__tests__/orchestratorPromptSession.test.ts +64 -0
  243. package/src/__tests__/osc52.test.ts +67 -0
  244. package/src/__tests__/parentLog.test.ts +75 -0
  245. package/src/__tests__/paths.test.ts +70 -0
  246. package/src/__tests__/platform.test.ts +556 -0
  247. package/src/__tests__/precisionWheel.test.ts +44 -0
  248. package/src/__tests__/prompt.test.ts +31 -0
  249. package/src/__tests__/providers.test.ts +65 -0
  250. package/src/__tests__/reasoning.test.ts +76 -0
  251. package/src/__tests__/rpc.test.ts +27 -0
  252. package/src/__tests__/scroll.test.ts +99 -0
  253. package/src/__tests__/slashParity.test.ts +123 -0
  254. package/src/__tests__/spawnHistoryStore.test.ts +46 -0
  255. package/src/__tests__/stateIsolation.test.ts +46 -0
  256. package/src/__tests__/statusBarTicker.test.ts +18 -0
  257. package/src/__tests__/statusRule.test.ts +32 -0
  258. package/src/__tests__/streamingMarkdown.test.ts +121 -0
  259. package/src/__tests__/subagentTree.test.ts +407 -0
  260. package/src/__tests__/syntax.test.ts +45 -0
  261. package/src/__tests__/terminalModes.test.ts +39 -0
  262. package/src/__tests__/terminalParity.test.ts +77 -0
  263. package/src/__tests__/terminalSetup.test.ts +386 -0
  264. package/src/__tests__/termux.test.ts +35 -0
  265. package/src/__tests__/termuxComposerLayout.test.ts +40 -0
  266. package/src/__tests__/text.test.ts +233 -0
  267. package/src/__tests__/textInputBurstInput.test.ts +40 -0
  268. package/src/__tests__/textInputCursorSourceOfTruth.test.ts +50 -0
  269. package/src/__tests__/textInputFastEcho.test.ts +200 -0
  270. package/src/__tests__/textInputLineNav.test.ts +55 -0
  271. package/src/__tests__/textInputPassThrough.test.ts +59 -0
  272. package/src/__tests__/textInputRightClick.test.ts +48 -0
  273. package/src/__tests__/textInputWrap.test.ts +151 -0
  274. package/src/__tests__/theme.test.ts +311 -0
  275. package/src/__tests__/turnControllerNotice.test.ts +43 -0
  276. package/src/__tests__/turnStore.test.ts +66 -0
  277. package/src/__tests__/useCompletion.test.ts +35 -0
  278. package/src/__tests__/useComposerState.test.ts +59 -0
  279. package/src/__tests__/useConfigSync.test.ts +460 -0
  280. package/src/__tests__/useInputHandlers.test.ts +77 -0
  281. package/src/__tests__/useQueue.test.ts +28 -0
  282. package/src/__tests__/useSessionLifecycle.test.ts +60 -0
  283. package/src/__tests__/useVirtualHistoryHeights.test.ts +39 -0
  284. package/src/__tests__/viewport.test.ts +58 -0
  285. package/src/__tests__/viewportStore.test.ts +85 -0
  286. package/src/__tests__/virtualHeights.test.ts +96 -0
  287. package/src/__tests__/virtualHistoryClamp.test.ts +19 -0
  288. package/src/__tests__/virtualHistoryOffsetCache.test.ts +282 -0
  289. package/src/__tests__/wheelAccel.test.ts +138 -0
  290. package/src/app/createGatewayEventHandler.ts +833 -0
  291. package/src/app/createSlashHandler.ts +130 -0
  292. package/src/app/delegationStore.ts +77 -0
  293. package/src/app/gatewayContext.tsx +19 -0
  294. package/src/app/gatewayRecovery.ts +35 -0
  295. package/src/app/inputSelectionStore.ts +15 -0
  296. package/src/app/interfaces.ts +394 -0
  297. package/src/app/overlayStore.ts +53 -0
  298. package/src/app/scroll.ts +71 -0
  299. package/src/app/setupHandoff.ts +54 -0
  300. package/src/app/slash/commands/core.ts +648 -0
  301. package/src/app/slash/commands/credits.ts +57 -0
  302. package/src/app/slash/commands/debug.ts +48 -0
  303. package/src/app/slash/commands/ops.ts +717 -0
  304. package/src/app/slash/commands/session.ts +554 -0
  305. package/src/app/slash/commands/setup.ts +20 -0
  306. package/src/app/slash/registry.ts +20 -0
  307. package/src/app/slash/types.ts +21 -0
  308. package/src/app/spawnHistoryStore.ts +159 -0
  309. package/src/app/turnController.ts +866 -0
  310. package/src/app/turnStore.ts +85 -0
  311. package/src/app/uiStore.ts +44 -0
  312. package/src/app/useComposerState.ts +367 -0
  313. package/src/app/useConfigSync.ts +288 -0
  314. package/src/app/useInputHandlers.ts +576 -0
  315. package/src/app/useLongRunToolCharms.ts +69 -0
  316. package/src/app/useMainApp.ts +1039 -0
  317. package/src/app/useSessionLifecycle.ts +366 -0
  318. package/src/app/useSubmission.ts +429 -0
  319. package/src/app.tsx +25 -0
  320. package/src/banner.ts +93 -0
  321. package/src/components/activeSessionSwitcher.tsx +635 -0
  322. package/src/components/agentsOverlay.tsx +1073 -0
  323. package/src/components/appChrome.tsx +554 -0
  324. package/src/components/appLayout.tsx +444 -0
  325. package/src/components/appOverlays.tsx +254 -0
  326. package/src/components/branding.tsx +466 -0
  327. package/src/components/fpsOverlay.tsx +30 -0
  328. package/src/components/helpHint.tsx +73 -0
  329. package/src/components/markdown.tsx +1119 -0
  330. package/src/components/maskedPrompt.tsx +34 -0
  331. package/src/components/messageLine.tsx +237 -0
  332. package/src/components/modelPicker.tsx +527 -0
  333. package/src/components/overlayControls.tsx +50 -0
  334. package/src/components/pluginsHub.tsx +238 -0
  335. package/src/components/prompts.tsx +276 -0
  336. package/src/components/queuedMessages.tsx +64 -0
  337. package/src/components/sessionPicker.tsx +227 -0
  338. package/src/components/skillsHub.tsx +308 -0
  339. package/src/components/streamingAssistant.tsx +110 -0
  340. package/src/components/streamingMarkdown.tsx +174 -0
  341. package/src/components/textInput.tsx +1340 -0
  342. package/src/components/themed.tsx +30 -0
  343. package/src/components/thinking.tsx +1224 -0
  344. package/src/components/todoPanel.tsx +93 -0
  345. package/src/config/env.ts +64 -0
  346. package/src/config/limits.ts +13 -0
  347. package/src/config/timing.ts +6 -0
  348. package/src/content/charms.ts +1 -0
  349. package/src/content/faces.ts +17 -0
  350. package/src/content/fortunes.ts +30 -0
  351. package/src/content/hotkeys.ts +37 -0
  352. package/src/content/placeholders.ts +13 -0
  353. package/src/content/setup.ts +17 -0
  354. package/src/content/verbs.ts +38 -0
  355. package/src/domain/blockLayout.ts +146 -0
  356. package/src/domain/details.ts +76 -0
  357. package/src/domain/messages.ts +91 -0
  358. package/src/domain/paths.ts +16 -0
  359. package/src/domain/providers.ts +11 -0
  360. package/src/domain/roles.ts +9 -0
  361. package/src/domain/slash.ts +10 -0
  362. package/src/domain/usage.ts +3 -0
  363. package/src/domain/viewport.ts +51 -0
  364. package/src/entry.tsx +104 -0
  365. package/src/gatewayClient.ts +730 -0
  366. package/src/gatewayTypes.ts +568 -0
  367. package/src/hooks/useCompletion.ts +112 -0
  368. package/src/hooks/useGitBranch.ts +72 -0
  369. package/src/hooks/useInputHistory.ts +11 -0
  370. package/src/hooks/useQueue.ts +76 -0
  371. package/src/hooks/useVirtualHistory.ts +554 -0
  372. package/src/lib/circularBuffer.ts +48 -0
  373. package/src/lib/clipboard.ts +182 -0
  374. package/src/lib/editor.test.ts +74 -0
  375. package/src/lib/editor.ts +47 -0
  376. package/src/lib/emoji.ts +55 -0
  377. package/src/lib/externalCli.ts +16 -0
  378. package/src/lib/externalLink.ts +435 -0
  379. package/src/lib/forceTruecolor.ts +60 -0
  380. package/src/lib/fpsStore.ts +51 -0
  381. package/src/lib/fuzzy.test.ts +109 -0
  382. package/src/lib/fuzzy.ts +177 -0
  383. package/src/lib/gracefulExit.ts +47 -0
  384. package/src/lib/history.ts +82 -0
  385. package/src/lib/inputMetrics.ts +203 -0
  386. package/src/lib/liveProgress.test.ts +116 -0
  387. package/src/lib/liveProgress.ts +79 -0
  388. package/src/lib/mathUnicode.ts +770 -0
  389. package/src/lib/memory.test.ts +155 -0
  390. package/src/lib/memory.ts +188 -0
  391. package/src/lib/memoryMonitor.ts +109 -0
  392. package/src/lib/messages.test.ts +29 -0
  393. package/src/lib/messages.ts +8 -0
  394. package/src/lib/openExternalUrl.test.ts +217 -0
  395. package/src/lib/openExternalUrl.ts +158 -0
  396. package/src/lib/osc52.ts +73 -0
  397. package/src/lib/parentLog.ts +57 -0
  398. package/src/lib/perfPane.tsx +107 -0
  399. package/src/lib/platform.ts +409 -0
  400. package/src/lib/precisionWheel.ts +48 -0
  401. package/src/lib/prompt.ts +35 -0
  402. package/src/lib/reasoning.ts +55 -0
  403. package/src/lib/rpc.ts +41 -0
  404. package/src/lib/subagentTree.ts +355 -0
  405. package/src/lib/syntax.ts +117 -0
  406. package/src/lib/terminalModes.ts +51 -0
  407. package/src/lib/terminalParity.ts +78 -0
  408. package/src/lib/terminalSetup.ts +444 -0
  409. package/src/lib/termux.ts +29 -0
  410. package/src/lib/text.test.ts +18 -0
  411. package/src/lib/text.ts +339 -0
  412. package/src/lib/todo.test.ts +21 -0
  413. package/src/lib/todo.ts +9 -0
  414. package/src/lib/viewportStore.ts +124 -0
  415. package/src/lib/virtualHeights.ts +145 -0
  416. package/src/lib/wheelAccel.ts +190 -0
  417. package/src/protocol/interpolation.ts +3 -0
  418. package/src/protocol/paste.ts +1 -0
  419. package/src/theme.ts +589 -0
  420. package/src/types/nastech-ink.d.ts +176 -0
  421. package/src/types.ts +212 -0
  422. package/tsconfig.build.json +9 -0
  423. package/tsconfig.json +19 -0
  424. package/vitest.config.ts +7 -0
@@ -0,0 +1,1340 @@
1
+ import type { InputEvent, Key } from '@nastechai/ink'
2
+ import * as Ink from '@nastechai/ink'
3
+ import { type MutableRefObject, useEffect, useMemo, useRef, useState } from 'react'
4
+
5
+ import { setInputSelection } from '../app/inputSelectionStore.js'
6
+ import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
7
+ import { cursorLayout, offsetFromPosition } from '../lib/inputMetrics.js'
8
+ import {
9
+ DEFAULT_VOICE_RECORD_KEY,
10
+ isActionMod,
11
+ isMac,
12
+ isMacActionFallback,
13
+ isVoiceToggleKey,
14
+ type ParsedVoiceRecordKey
15
+ } from '../lib/platform.js'
16
+ import { isTermuxTuiMode } from '../lib/termux.js'
17
+
18
+ type InkExt = typeof Ink & {
19
+ stringWidth: (s: string) => number
20
+ useCursorAdvance: () => (dx: number, dy?: number) => void
21
+ useDeclaredCursor: (a: { line: number; column: number; active: boolean }) => (el: any) => void
22
+ useStdout: () => { stdout?: NodeJS.WriteStream }
23
+ useTerminalFocus: () => boolean
24
+ }
25
+
26
+ const ink = Ink as unknown as InkExt
27
+ const { Box, Text, useStdin, useInput, useStdout, stringWidth, useCursorAdvance, useDeclaredCursor, useTerminalFocus } = ink
28
+
29
+ const ESC = '\x1b'
30
+ const INV = `${ESC}[7m`
31
+ const INV_OFF = `${ESC}[27m`
32
+ const DIM = `${ESC}[2m`
33
+ const DIM_OFF = `${ESC}[22m`
34
+ const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`)
35
+ const PRINTABLE = /^[ -~\u00a0-\uffff]+$/
36
+ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g')
37
+ const FRAME_BATCH_MS = 16
38
+ const MULTI_CLICK_MS = 500
39
+ type MinimalEnv = Record<string, string | undefined>
40
+
41
+ const invert = (s: string) => INV + s + INV_OFF
42
+ const dim = (s: string) => DIM + s + DIM_OFF
43
+
44
+ let _seg: Intl.Segmenter | null = null
45
+ const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
46
+ const STOP_CACHE_MAX = 32
47
+ const stopCache = new Map<string, number[]>()
48
+
49
+ function graphemeStops(s: string) {
50
+ const hit = stopCache.get(s)
51
+
52
+ if (hit) {
53
+ return hit
54
+ }
55
+
56
+ const stops = [0]
57
+
58
+ for (const { index } of seg().segment(s)) {
59
+ if (index > 0) {
60
+ stops.push(index)
61
+ }
62
+ }
63
+
64
+ if (stops.at(-1) !== s.length) {
65
+ stops.push(s.length)
66
+ }
67
+
68
+ stopCache.set(s, stops)
69
+
70
+ if (stopCache.size > STOP_CACHE_MAX) {
71
+ const oldest = stopCache.keys().next().value
72
+
73
+ if (oldest !== undefined) {
74
+ stopCache.delete(oldest)
75
+ }
76
+ }
77
+
78
+ return stops
79
+ }
80
+
81
+ function snapPos(s: string, p: number) {
82
+ const pos = Math.max(0, Math.min(p, s.length))
83
+ let last = 0
84
+
85
+ for (const stop of graphemeStops(s)) {
86
+ if (stop > pos) {
87
+ break
88
+ }
89
+
90
+ last = stop
91
+ }
92
+
93
+ return last
94
+ }
95
+
96
+ export interface TextInsertResult {
97
+ cursor: number
98
+ value: string
99
+ }
100
+
101
+ export function applyPrintableInsert(
102
+ value: string,
103
+ cursor: number,
104
+ text: string,
105
+ range?: { end: number; start: number } | null
106
+ ): null | TextInsertResult {
107
+ if (!PRINTABLE.test(text)) {
108
+ return null
109
+ }
110
+
111
+ if (range) {
112
+ return {
113
+ cursor: range.start + text.length,
114
+ value: value.slice(0, range.start) + text + value.slice(range.end)
115
+ }
116
+ }
117
+
118
+ return {
119
+ cursor: cursor + text.length,
120
+ value: value.slice(0, cursor) + text + value.slice(cursor)
121
+ }
122
+ }
123
+
124
+ export const shouldRouteMultiCharInputAsPaste = (text: string): boolean => text.includes('\n')
125
+
126
+ export function shouldPreserveCtrlJNewline(env: MinimalEnv = process.env): boolean {
127
+ if (env.WT_SESSION) {
128
+ return true
129
+ }
130
+
131
+ if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) {
132
+ return true
133
+ }
134
+
135
+ if (env.GHOSTTY_RESOURCES_DIR || env.GHOSTTY_BIN_DIR) {
136
+ return true
137
+ }
138
+
139
+ if ((env.TERM ?? '').toLowerCase() === 'xterm-ghostty') {
140
+ return true
141
+ }
142
+
143
+ if ((env.TERM_PROGRAM ?? '').toLowerCase() === 'ghostty') {
144
+ return true
145
+ }
146
+
147
+ return (env.WSL_DISTRO_NAME ?? '').toLowerCase().includes('microsoft')
148
+ }
149
+
150
+ function prevPos(s: string, p: number) {
151
+ const pos = snapPos(s, p)
152
+ let prev = 0
153
+
154
+ for (const stop of graphemeStops(s)) {
155
+ if (stop >= pos) {
156
+ return prev
157
+ }
158
+
159
+ prev = stop
160
+ }
161
+
162
+ return prev
163
+ }
164
+
165
+ function nextPos(s: string, p: number) {
166
+ const pos = snapPos(s, p)
167
+
168
+ for (const stop of graphemeStops(s)) {
169
+ if (stop > pos) {
170
+ return stop
171
+ }
172
+ }
173
+
174
+ return s.length
175
+ }
176
+
177
+ function wordLeft(s: string, p: number) {
178
+ let i = snapPos(s, p) - 1
179
+
180
+ while (i > 0 && /\s/.test(s[i]!)) {
181
+ i--
182
+ }
183
+
184
+ while (i > 0 && !/\s/.test(s[i - 1]!)) {
185
+ i--
186
+ }
187
+
188
+ return Math.max(0, i)
189
+ }
190
+
191
+ function wordRight(s: string, p: number) {
192
+ let i = snapPos(s, p)
193
+
194
+ while (i < s.length && !/\s/.test(s[i]!)) {
195
+ i++
196
+ }
197
+
198
+ while (i < s.length && /\s/.test(s[i]!)) {
199
+ i++
200
+ }
201
+
202
+ return i
203
+ }
204
+
205
+ /**
206
+ * Move cursor one logical line up or down inside `s` while preserving the
207
+ * column offset from the current line's start. Returns `null` when the cursor
208
+ * is already on the first line (up) or last line (down) — callers use that
209
+ * signal to fall through to history cycling instead of eating the arrow key.
210
+ */
211
+ export function lineNav(s: string, p: number, dir: -1 | 1): null | number {
212
+ const pos = snapPos(s, p)
213
+ const curStart = s.lastIndexOf('\n', pos - 1) + 1
214
+ const col = pos - curStart
215
+
216
+ if (dir < 0) {
217
+ if (curStart === 0) {
218
+ return null
219
+ }
220
+
221
+ const prevStart = s.lastIndexOf('\n', curStart - 2) + 1
222
+
223
+ return snapPos(s, Math.min(prevStart + col, curStart - 1))
224
+ }
225
+
226
+ const nextBreak = s.indexOf('\n', pos)
227
+
228
+ if (nextBreak < 0) {
229
+ return null
230
+ }
231
+
232
+ const nextEnd = s.indexOf('\n', nextBreak + 1)
233
+ const lineEnd = nextEnd < 0 ? s.length : nextEnd
234
+
235
+ return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd))
236
+ }
237
+
238
+ export { offsetFromPosition }
239
+
240
+ const ASCII_PRINTABLE_RE = /^[\x20-\x7e]+$/
241
+
242
+ /**
243
+ * Pure shape-only precondition for the fast-echo append path.
244
+ *
245
+ * The fast-echo path bypasses Ink's renderer and writes text directly to
246
+ * stdout, so the stored value, the rendered terminal cells, and the cursor
247
+ * column must all stay in sync without any layout work. We only allow it
248
+ * when the inserted text is pure printable ASCII so that:
249
+ *
250
+ * - `text.length` matches the number of grapheme clusters (no combining
251
+ * marks, no surrogate pairs, no precomposed CJK / Latin-Extended
252
+ * letters that an IME might still be holding open as a composition),
253
+ * - terminal width is exactly 1 cell per character (no East-Asian wide,
254
+ * no zero-width, no ambiguous-width fonts),
255
+ * - input methods (Vietnamese Telex, IME, dead-keys) cannot leak
256
+ * intermediate composition bytes through the bypass before the final
257
+ * commit arrives — those always go through the normal Ink render path
258
+ * and stay layout-accurate (closes #5221, #7443, #17602/#17603).
259
+ *
260
+ * We deliberately do NOT just check `stringWidth(text) === text.length`:
261
+ * Vietnamese precomposed letters like "ề" (U+1EC1) report width 1 and
262
+ * length 1 but are still produced by IME compositions and must not be
263
+ * fast-echoed.
264
+ */
265
+ export function canFastAppendShape(
266
+ current: string,
267
+ cursor: number,
268
+ text: string,
269
+ columns: number,
270
+ currentLineWidth: number
271
+ ): boolean {
272
+ if (cursor !== current.length) {
273
+ return false
274
+ }
275
+
276
+ if (current.length === 0) {
277
+ return false
278
+ }
279
+
280
+ if (current.includes('\n')) {
281
+ return false
282
+ }
283
+
284
+ if (!ASCII_PRINTABLE_RE.test(text)) {
285
+ return false
286
+ }
287
+
288
+ return currentLineWidth + text.length < Math.max(1, columns)
289
+ }
290
+
291
+ /**
292
+ * Pure shape-only precondition for the fast-echo backspace path.
293
+ *
294
+ * Same reasoning as canFastAppendShape — only allow the direct
295
+ * "\b \b" stdout shortcut when the deleted grapheme is pure printable
296
+ * ASCII. Anything else (combining marks, IME compositions, wide chars,
297
+ * tabs, ANSI fragments) goes through the normal render path so Ink can
298
+ * recompute cell widths.
299
+ *
300
+ * When `columns` is supplied, ALSO rejects when the physical cursor
301
+ * sits at visual column 0 — i.e., right after a soft-wrap boundary.
302
+ * The "\b \b" sequence cannot move the cursor onto the previous visual
303
+ * row (terminals don't back-step across line wraps), so the physical
304
+ * cursor would stay put while the logical caret moves to the end of
305
+ * the previous visual line, desyncing both Ink's `displayCursor` model
306
+ * and the user-visible position.
307
+ *
308
+ * When `columns` is OMITTED, the wrap-boundary check is skipped
309
+ * entirely and the function reverts to the legacy non-wrap-aware
310
+ * contract — values like `'hello '` will return `true` even though
311
+ * they would be unsafe at a width of 6. Production callers (the
312
+ * composer's `canFastBackspace` helper) always pass `columns`;
313
+ * `columns` is optional only so unit tests of the pre-wrap shape
314
+ * contract can keep calling the helper without threading width
315
+ * through. Do NOT omit it from any new caller that relies on the
316
+ * wrap-boundary protection.
317
+ */
318
+ export function canFastBackspaceShape(current: string, cursor: number, columns?: number): boolean {
319
+ if (cursor !== current.length) {
320
+ return false
321
+ }
322
+
323
+ if (cursor <= 0) {
324
+ return false
325
+ }
326
+
327
+ if (current.includes('\n')) {
328
+ return false
329
+ }
330
+
331
+ // If we know the wrap width, reject at the soft-wrap boundary: the
332
+ // caret's physical column would be at (or past) the terminal's right
333
+ // edge, so the terminal has already auto-wrapped to the next row.
334
+ // "\b \b" can't represent the physical move back across that wrap.
335
+ //
336
+ // We check `column === 0` for the "wrap-ansi broke onto a new line"
337
+ // case AND `column >= columns` for the "exact-fill, terminal auto-wraps"
338
+ // case. Both manifest as the same physical state (cursor parked at
339
+ // col 0 of the next row) but cursorLayout reports them differently
340
+ // because it now mirrors wrap-ansi's break points exactly (see the
341
+ // cursor-drift-multiline fix in lib/inputMetrics.ts).
342
+ if (columns !== undefined) {
343
+ const layout = cursorLayout(current, cursor, columns)
344
+
345
+ if (layout.column === 0 || layout.column >= columns) {
346
+ return false
347
+ }
348
+ }
349
+
350
+ const removed = current.slice(prevPos(current, cursor), cursor)
351
+
352
+ return ASCII_PRINTABLE_RE.test(removed)
353
+ }
354
+
355
+ export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
356
+ // Terminal.app still shows paint/cursor artifacts under the fast-echo
357
+ // bypass path. Fall back to the normal Ink render path there.
358
+ if ((env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal') {
359
+ return false
360
+ }
361
+
362
+ // Termux terminals are especially sensitive to bypass-path cursor drift and
363
+ // stale paints at soft-wrap boundaries on tall/narrow viewports. Keep this
364
+ // off by default in Termux mode; allow explicit opt-in for local debugging.
365
+ if (isTermuxTuiMode(env)) {
366
+ const override = String(env.NASTECH_TUI_TERMUX_FAST_ECHO ?? '').trim().toLowerCase()
367
+
368
+ if (override) {
369
+ return /^(?:1|true|yes|on)$/i.test(override)
370
+ }
371
+
372
+ return false
373
+ }
374
+
375
+ return true
376
+ }
377
+
378
+ function renderWithCursor(value: string, cursor: number) {
379
+ const pos = Math.max(0, Math.min(cursor, value.length))
380
+
381
+ let out = '',
382
+ done = false
383
+
384
+ for (const { segment, index } of seg().segment(value)) {
385
+ if (!done && index >= pos) {
386
+ out += invert(index === pos && segment !== '\n' ? segment : ' ')
387
+ done = true
388
+
389
+ if (index === pos && segment !== '\n') {
390
+ continue
391
+ }
392
+ }
393
+
394
+ out += segment
395
+ }
396
+
397
+ return done ? out : out + invert(' ')
398
+ }
399
+
400
+ function renderWithSelection(value: string, start: number, end: number) {
401
+ if (start >= end) {
402
+ return value
403
+ }
404
+
405
+ return value.slice(0, start) + invert(value.slice(start, end) || ' ') + value.slice(end)
406
+ }
407
+
408
+ function useFwdDelete(active: boolean) {
409
+ const ref = useRef(false)
410
+ const { inputEmitter: ee } = useStdin()
411
+
412
+ useEffect(() => {
413
+ if (!active) {
414
+ return
415
+ }
416
+
417
+ const h = (d: string) => {
418
+ ref.current = FWD_DEL_RE.test(d)
419
+ }
420
+
421
+ ee.prependListener('input', h)
422
+
423
+ return () => {
424
+ ee.removeListener('input', h)
425
+ }
426
+ }, [active, ee])
427
+
428
+ return ref
429
+ }
430
+
431
+ type PasteResult = { cursor: number; value: string } | null
432
+
433
+ const isPasteResultPromise = (
434
+ value: PasteResult | Promise<PasteResult> | null | undefined
435
+ ): value is Promise<PasteResult> => !!value && typeof (value as PromiseLike<PasteResult>).then === 'function'
436
+
437
+ export function TextInput({
438
+ columns = 80,
439
+ value,
440
+ onChange,
441
+ onPaste,
442
+ onSubmit,
443
+ mask,
444
+ mouseApiRef,
445
+ voiceRecordKey = DEFAULT_VOICE_RECORD_KEY,
446
+ placeholder = '',
447
+ focus = true
448
+ }: TextInputProps) {
449
+ const [cur, setCur] = useState(value.length)
450
+ const [sel, setSel] = useState<null | { end: number; start: number }>(null)
451
+ const fwdDel = useFwdDelete(focus)
452
+ const termFocus = useTerminalFocus()
453
+ const { stdout } = useStdout()
454
+ const noteCursorAdvance = useCursorAdvance()
455
+
456
+ const curRef = useRef(cur)
457
+ const selRef = useRef<null | { end: number; start: number }>(null)
458
+ const vRef = useRef(value)
459
+ const self = useRef(false)
460
+ const keyBurstTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
461
+ const editVersionRef = useRef(0)
462
+ const parentChangeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
463
+ const pendingParentValue = useRef<string | null>(null)
464
+ const localRenderTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
465
+ const lineWidthRef = useRef(stringWidth(value.includes('\n') ? value.slice(value.lastIndexOf('\n') + 1) : value))
466
+ const mouseAnchorRef = useRef<null | number>(null)
467
+ const lastClickRef = useRef<{ at: number; offset: number }>({ at: 0, offset: -1 })
468
+ const undo = useRef<{ cursor: number; value: string }[]>([])
469
+ const redo = useRef<{ cursor: number; value: string }[]>([])
470
+
471
+ const cbChange = useRef(onChange)
472
+ const cbSubmit = useRef(onSubmit)
473
+ const cbPaste = useRef(onPaste)
474
+ cbChange.current = onChange
475
+ cbSubmit.current = onSubmit
476
+ cbPaste.current = onPaste
477
+
478
+ const raw = self.current ? vRef.current : value
479
+ const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw
480
+
481
+ const selected = useMemo(
482
+ () =>
483
+ sel && sel.start !== sel.end ? { end: Math.max(sel.start, sel.end), start: Math.min(sel.start, sel.end) } : null,
484
+ [sel]
485
+ )
486
+
487
+ // Read `curRef.current` (always up-to-date) rather than the `cur`
488
+ // React state. The fast-echo path defers the React `setCur` by 16ms
489
+ // to batch re-renders during heavy typing; if an unrelated render
490
+ // flushes this component during that window and we used the stale
491
+ // `cur` state here, the layout effect inside `useDeclaredCursor`
492
+ // would publish a stale cursor declaration and clobber the Ink-level
493
+ // bump from `noteCursorAdvance(...)`. `cur` is still in scope and
494
+ // referenced by setSel/setCur paths below, so React tracks the
495
+ // dependency naturally — we just don't use it as the source of truth
496
+ // for layout. The cursorLayout call is cheap (one wrap-text pass
497
+ // over a single-line string in the common case), so dropping useMemo
498
+ // is fine.
499
+ const layout = cursorLayout(display, curRef.current, columns)
500
+
501
+ const boxRef = useDeclaredCursor({
502
+ line: layout.line,
503
+ column: layout.column,
504
+ active: focus && termFocus && !selected
505
+ })
506
+
507
+ // Hide the hardware cursor while a selection is active (prevents
508
+ // auto-wrap onto the next row when inverted text fills the column
509
+ // exactly) or when the terminal loses focus (suppresses the hollow-rect
510
+ // ghost most terminals draw at the parked position).
511
+ const hideHardwareCursor = focus && !!stdout?.isTTY && (!!selected || !termFocus)
512
+
513
+ useEffect(() => {
514
+ if (!hideHardwareCursor || !stdout) {
515
+ return
516
+ }
517
+
518
+ stdout.write('\x1b[?25l')
519
+
520
+ return () => {
521
+ stdout.write('\x1b[?25h')
522
+ }
523
+ }, [hideHardwareCursor, stdout])
524
+
525
+ const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY
526
+
527
+ // Placeholder text is just a hint, not a selection — render it dim
528
+ // without inverse styling. In a TTY the hardware cursor parks at column
529
+ // 0 and visually marks the input start. Non-TTY surfaces still need the
530
+ // synthetic inverse first-char to draw a cursor at all.
531
+ const rendered = useMemo(() => {
532
+ if (!focus) {
533
+ return display || dim(placeholder)
534
+ }
535
+
536
+ if (!display && placeholder) {
537
+ return nativeCursor ? dim(placeholder) : invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1))
538
+ }
539
+
540
+ if (selected) {
541
+ return renderWithSelection(display, selected.start, selected.end)
542
+ }
543
+
544
+ return nativeCursor ? display || ' ' : renderWithCursor(display, cur)
545
+ }, [cur, display, focus, nativeCursor, placeholder, selected])
546
+
547
+ useEffect(() => {
548
+ if (self.current) {
549
+ self.current = false
550
+ } else {
551
+ setCur(value.length)
552
+ setSel(null)
553
+ curRef.current = value.length
554
+ selRef.current = null
555
+ vRef.current = value
556
+ lineWidthRef.current = stringWidth(value.includes('\n') ? value.slice(value.lastIndexOf('\n') + 1) : value)
557
+ undo.current = []
558
+ redo.current = []
559
+ }
560
+ }, [value])
561
+
562
+ useEffect(() => {
563
+ if (!focus) {
564
+ return
565
+ }
566
+
567
+ const dropSel = () => {
568
+ if (!selRef.current) {
569
+ return
570
+ }
571
+
572
+ selRef.current = null
573
+ setSel(null)
574
+ }
575
+
576
+ setInputSelection({
577
+ clear: dropSel,
578
+ collapseToEnd: () => {
579
+ dropSel()
580
+ setCur(vRef.current.length)
581
+ curRef.current = vRef.current.length
582
+ },
583
+ end: selected?.end ?? curRef.current,
584
+ start: selected?.start ?? curRef.current,
585
+ value: vRef.current
586
+ })
587
+
588
+ return () => setInputSelection(null)
589
+ }, [cur, focus, selected])
590
+
591
+ useEffect(
592
+ () => () => {
593
+ if (keyBurstTimer.current) {
594
+ clearTimeout(keyBurstTimer.current)
595
+ }
596
+
597
+ if (parentChangeTimer.current) {
598
+ clearTimeout(parentChangeTimer.current)
599
+ }
600
+
601
+ if (localRenderTimer.current) {
602
+ clearTimeout(localRenderTimer.current)
603
+ }
604
+ },
605
+ []
606
+ )
607
+
608
+ const flushParentChange = () => {
609
+ if (parentChangeTimer.current) {
610
+ clearTimeout(parentChangeTimer.current)
611
+ parentChangeTimer.current = null
612
+ }
613
+
614
+ const next = pendingParentValue.current
615
+ pendingParentValue.current = null
616
+
617
+ if (next !== null) {
618
+ self.current = true
619
+ cbChange.current(next)
620
+ }
621
+ }
622
+
623
+ const scheduleParentChange = (next: string) => {
624
+ pendingParentValue.current = next
625
+
626
+ if (parentChangeTimer.current) {
627
+ return
628
+ }
629
+
630
+ parentChangeTimer.current = setTimeout(flushParentChange, FRAME_BATCH_MS)
631
+ }
632
+
633
+ const cancelLocalRender = () => {
634
+ if (localRenderTimer.current) {
635
+ clearTimeout(localRenderTimer.current)
636
+ localRenderTimer.current = null
637
+ }
638
+ }
639
+
640
+ const scheduleLocalRender = () => {
641
+ if (localRenderTimer.current) {
642
+ return
643
+ }
644
+
645
+ localRenderTimer.current = setTimeout(() => {
646
+ localRenderTimer.current = null
647
+ setCur(curRef.current)
648
+ }, FRAME_BATCH_MS)
649
+ }
650
+
651
+ const canFastEchoBase = () => supportsFastEchoTerminal() && focus && termFocus && !selected && !mask && !!stdout?.isTTY
652
+
653
+ const canFastAppend = (current: string, cursor: number, text: string) =>
654
+ canFastEchoBase() && canFastAppendShape(current, cursor, text, columns, lineWidthRef.current)
655
+
656
+ const canFastBackspace = (current: string, cursor: number) =>
657
+ canFastEchoBase() && canFastBackspaceShape(current, cursor, columns)
658
+
659
+ const commit = (
660
+ next: string,
661
+ nextCur: number,
662
+ track = true,
663
+ syncParent = true,
664
+ syncLocal = true,
665
+ nextLineWidth?: number
666
+ ) => {
667
+ const prev = vRef.current
668
+ const c = snapPos(next, nextCur)
669
+ editVersionRef.current += 1
670
+
671
+ if (selRef.current) {
672
+ selRef.current = null
673
+ setSel(null)
674
+ }
675
+
676
+ if (track && next !== prev) {
677
+ undo.current.push({ cursor: curRef.current, value: prev })
678
+
679
+ if (undo.current.length > 200) {
680
+ undo.current.shift()
681
+ }
682
+
683
+ redo.current = []
684
+ }
685
+
686
+ if (syncLocal) {
687
+ cancelLocalRender()
688
+ setCur(c)
689
+ } else {
690
+ scheduleLocalRender()
691
+ }
692
+
693
+ curRef.current = c
694
+ vRef.current = next
695
+ lineWidthRef.current =
696
+ nextLineWidth ?? stringWidth(next.includes('\n') ? next.slice(next.lastIndexOf('\n') + 1) : next)
697
+
698
+ if (next !== prev) {
699
+ if (syncParent) {
700
+ flushParentChange()
701
+ self.current = true
702
+ cbChange.current(next)
703
+ } else {
704
+ self.current = true
705
+ scheduleParentChange(next)
706
+ }
707
+ }
708
+ }
709
+
710
+ const swap = (from: typeof undo, to: typeof redo) => {
711
+ const entry = from.current.pop()
712
+
713
+ if (!entry) {
714
+ return
715
+ }
716
+
717
+ to.current.push({ cursor: curRef.current, value: vRef.current })
718
+ commit(entry.value, entry.cursor, false)
719
+ }
720
+
721
+ const emitPaste = (e: PasteEvent) => {
722
+ const startVersion = editVersionRef.current
723
+ const h = cbPaste.current?.(e)
724
+
725
+ if (isPasteResultPromise(h)) {
726
+ const fallbackText = e.text
727
+
728
+ void h
729
+ .then(result => {
730
+ if (result && editVersionRef.current === startVersion) {
731
+ commit(result.value, result.cursor)
732
+ } else if (result && fallbackText && PRINTABLE.test(fallbackText)) {
733
+ // User typed while async paste was in-flight — fall back to raw text insert
734
+ // so the pasted content is not silently lost.
735
+ const cur = curRef.current
736
+ const v = vRef.current
737
+ commit(v.slice(0, cur) + fallbackText + v.slice(cur), cur + fallbackText.length)
738
+ }
739
+ })
740
+ .catch(() => {})
741
+
742
+ return true
743
+ }
744
+
745
+ if (h) {
746
+ commit(h.value, h.cursor)
747
+ }
748
+
749
+ return !!h
750
+ }
751
+
752
+ const flushKeyBurst = () => {
753
+ if (keyBurstTimer.current) {
754
+ clearTimeout(keyBurstTimer.current)
755
+ keyBurstTimer.current = null
756
+ }
757
+
758
+ flushParentChange()
759
+ }
760
+
761
+ const scheduleKeyBurstCommit = (next: string, nextCur: number) => {
762
+ commit(next, nextCur, true, false, false)
763
+
764
+ if (keyBurstTimer.current) {
765
+ return
766
+ }
767
+
768
+ keyBurstTimer.current = setTimeout(() => {
769
+ keyBurstTimer.current = null
770
+ flushParentChange()
771
+ }, FRAME_BATCH_MS)
772
+ }
773
+
774
+ const clearSel = () => {
775
+ if (!selRef.current) {
776
+ return
777
+ }
778
+
779
+ selRef.current = null
780
+ setSel(null)
781
+ }
782
+
783
+ const selectAll = () => {
784
+ const end = vRef.current.length
785
+
786
+ if (!end) {
787
+ return
788
+ }
789
+
790
+ const next = { end, start: 0 }
791
+ selRef.current = next
792
+ setSel(next)
793
+ setCur(end)
794
+ curRef.current = end
795
+ }
796
+
797
+ const moveCursor = (next: number, extend = false) => {
798
+ const c = snapPos(vRef.current, next)
799
+ const anchor = selRef.current?.start ?? curRef.current
800
+
801
+ if (!extend || anchor === c) {
802
+ clearSel()
803
+ } else {
804
+ const nextSel = { end: c, start: anchor }
805
+ selRef.current = nextSel
806
+ setSel(nextSel)
807
+ }
808
+
809
+ setCur(c)
810
+ curRef.current = c
811
+ }
812
+
813
+ const selRange = () => {
814
+ const range = selRef.current
815
+
816
+ return range && range.start !== range.end
817
+ ? { end: Math.max(range.start, range.end), start: Math.min(range.start, range.end) }
818
+ : null
819
+ }
820
+
821
+ const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c)
822
+
823
+ const pastePlainText = (text: string) => {
824
+ const cleaned = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
825
+
826
+ if (!cleaned) {
827
+ return
828
+ }
829
+
830
+ const range = selRange()
831
+
832
+ const nextValue = range
833
+ ? vRef.current.slice(0, range.start) + cleaned + vRef.current.slice(range.end)
834
+ : vRef.current.slice(0, curRef.current) + cleaned + vRef.current.slice(curRef.current)
835
+
836
+ const nextCursor = range ? range.start + cleaned.length : curRef.current + cleaned.length
837
+
838
+ commit(nextValue, nextCursor)
839
+ }
840
+
841
+ const startMouseSelection = (next: number) => {
842
+ const c = snapPos(vRef.current, next)
843
+
844
+ mouseAnchorRef.current = c
845
+ selRef.current = { end: c, start: c }
846
+ setSel(null)
847
+ setCur(c)
848
+ curRef.current = c
849
+ }
850
+
851
+ const dragMouseSelection = (next: number) => {
852
+ if (mouseAnchorRef.current === null) {
853
+ return
854
+ }
855
+
856
+ const c = snapPos(vRef.current, next)
857
+ const range = { end: c, start: mouseAnchorRef.current }
858
+ selRef.current = range
859
+ setSel(range.start === range.end ? null : range)
860
+ setCur(c)
861
+ curRef.current = c
862
+ }
863
+
864
+ const endMouseSelection = () => {
865
+ mouseAnchorRef.current = null
866
+
867
+ const range = selRef.current
868
+
869
+ if (range && range.start === range.end) {
870
+ selRef.current = null
871
+ setSel(null)
872
+
873
+ return
874
+ }
875
+
876
+ const normalized = selRange()
877
+
878
+ if (isMac && normalized) {
879
+ void writeClipboardText(vRef.current.slice(normalized.start, normalized.end))
880
+ }
881
+ }
882
+
883
+ const offsetAt = (e: { localCol?: number; localRow?: number }) =>
884
+ offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
885
+
886
+ const isMultiClickAt = (offset: number) => {
887
+ const now = Date.now()
888
+ const last = lastClickRef.current
889
+ lastClickRef.current = { at: now, offset }
890
+
891
+ return now - last.at < MULTI_CLICK_MS && offset === last.offset
892
+ }
893
+
894
+ if (mouseApiRef) {
895
+ mouseApiRef.current = {
896
+ dragAt: (row, col) => dragMouseSelection(offsetFromPosition(display, row, col, columns)),
897
+ end: endMouseSelection,
898
+ startAtBeginning: () => startMouseSelection(0)
899
+ }
900
+ }
901
+
902
+ useInput(
903
+ (inp: string, k: Key, event: InputEvent) => {
904
+ const eventRaw = event.keypress.raw
905
+
906
+ // Configured voice shortcut wins over composer-level defaults like
907
+ // paste/copy so users who bind voice to ctrl+v / alt+v / cmd+v
908
+ // actually get voice toggled instead of a paste (Copilot round-7
909
+ // follow-up on #19835). The pass-through predicate is a no-op for
910
+ // ordinary typing and plain paste when voice is unbound to 'v'.
911
+ if (shouldPassThroughToGlobalHandler(inp, k, voiceRecordKey)) {
912
+ flushKeyBurst()
913
+
914
+ return
915
+ }
916
+
917
+ if (
918
+ eventRaw === '\x1bv' ||
919
+ eventRaw === '\x1bV' ||
920
+ eventRaw === '\x16' ||
921
+ (isMac && isActionMod(k) && inp.toLowerCase() === 'v')
922
+ ) {
923
+ flushKeyBurst()
924
+
925
+ if (cbPaste.current) {
926
+ return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
927
+ }
928
+
929
+ if (isMac) {
930
+ void readClipboardText().then(text => {
931
+ if (text) {
932
+ pastePlainText(text)
933
+ }
934
+ })
935
+ }
936
+
937
+ return
938
+ }
939
+
940
+ if (isMac && isActionMod(k) && inp.toLowerCase() === 'c') {
941
+ flushKeyBurst()
942
+
943
+ const range = selRange()
944
+
945
+ if (range) {
946
+ const text = vRef.current.slice(range.start, range.end)
947
+
948
+ void writeClipboardText(text)
949
+ }
950
+
951
+ return
952
+ }
953
+
954
+ if (k.upArrow || k.downArrow) {
955
+ flushKeyBurst()
956
+
957
+ const next = lineNav(vRef.current, curRef.current, k.upArrow ? -1 : 1)
958
+
959
+ if (next !== null) {
960
+ moveCursor(next, k.shift)
961
+
962
+ return
963
+ }
964
+
965
+ return
966
+ }
967
+
968
+ if (k.return) {
969
+ flushKeyBurst()
970
+
971
+ const sequence = (event.keypress as { sequence?: string }).sequence
972
+ const preserveBareLineFeed = shouldPreserveCtrlJNewline() && sequence === '\n'
973
+
974
+ if (k.shift || k.ctrl || preserveBareLineFeed || (isMac ? isActionMod(k) : k.meta)) {
975
+ commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1)
976
+ } else {
977
+ cbSubmit.current?.(vRef.current)
978
+ }
979
+
980
+ return
981
+ }
982
+
983
+ let c = curRef.current
984
+ let v = vRef.current
985
+ const mod = isActionMod(k)
986
+ const wordMod = mod || k.meta
987
+ const actionHome = k.home || (!isMac && mod && inp === 'a') || isMacActionFallback(k, inp, 'a')
988
+ const actionEnd = k.end || (mod && inp === 'e') || isMacActionFallback(k, inp, 'e')
989
+ const actionDeleteToStart = (mod && inp === 'u') || isMacActionFallback(k, inp, 'u')
990
+ const actionKillToEnd = (mod && inp === 'k') || isMacActionFallback(k, inp, 'k')
991
+ const actionDeleteWord = (mod && inp === 'w') || isMacActionFallback(k, inp, 'w')
992
+ const range = selRange()
993
+ const delFwd = k.delete || fwdDel.current
994
+ const isPrintableInput = (event.keypress.isPasted || inp.length > 0) && PRINTABLE.test(inp.replace(BRACKET_PASTE, ''))
995
+
996
+ if (!isPrintableInput) {
997
+ flushKeyBurst()
998
+ }
999
+
1000
+ if (mod && inp === 'z') {
1001
+ return swap(undo, redo)
1002
+ }
1003
+
1004
+ if ((mod && inp === 'y') || (mod && k.shift && inp === 'z')) {
1005
+ return swap(redo, undo)
1006
+ }
1007
+
1008
+ if (isMac && mod && inp === 'a') {
1009
+ return selectAll()
1010
+ }
1011
+
1012
+ if (actionHome) {
1013
+ c = 0
1014
+ moveCursor(c, k.shift)
1015
+
1016
+ return
1017
+ } else if (actionEnd) {
1018
+ c = v.length
1019
+ moveCursor(c, k.shift)
1020
+
1021
+ return
1022
+ } else if (k.leftArrow) {
1023
+ if (range && !wordMod && !k.shift) {
1024
+ clearSel()
1025
+ c = range.start
1026
+ } else {
1027
+ c = wordMod ? wordLeft(v, c) : prevPos(v, c)
1028
+ }
1029
+
1030
+ moveCursor(c, k.shift)
1031
+
1032
+ return
1033
+ } else if (k.rightArrow) {
1034
+ if (range && !wordMod && !k.shift) {
1035
+ clearSel()
1036
+ c = range.end
1037
+ } else {
1038
+ c = wordMod ? wordRight(v, c) : nextPos(v, c)
1039
+ }
1040
+
1041
+ moveCursor(c, k.shift)
1042
+
1043
+ return
1044
+ } else if (wordMod && inp === 'b') {
1045
+ clearSel()
1046
+ c = wordLeft(v, c)
1047
+ } else if (wordMod && inp === 'f') {
1048
+ clearSel()
1049
+ c = wordRight(v, c)
1050
+ } else if (range && (k.backspace || delFwd)) {
1051
+ v = v.slice(0, range.start) + v.slice(range.end)
1052
+ c = range.start
1053
+ } else if (k.backspace && c > 0) {
1054
+ if (wordMod) {
1055
+ const t = wordLeft(v, c)
1056
+ v = v.slice(0, t) + v.slice(c)
1057
+ c = t
1058
+ } else if (canFastBackspace(v, c)) {
1059
+ const t = prevPos(v, c)
1060
+ v = v.slice(0, t) + v.slice(c)
1061
+ c = t
1062
+ stdout!.write('\b \b')
1063
+ // The "\b \b" sequence ends with the cursor one column to the
1064
+ // LEFT of where Ink last parked it. Tell Ink so its `displayCursor`
1065
+ // (and log-update's relative-move basis on the next frame) stays
1066
+ // in sync — otherwise the cursor parks one cell to the right of
1067
+ // the caret on the next unrelated re-render.
1068
+ noteCursorAdvance(-1)
1069
+ commit(v, c, true, false, false, Math.max(0, lineWidthRef.current - 1))
1070
+
1071
+ return
1072
+ } else {
1073
+ const t = prevPos(v, c)
1074
+ v = v.slice(0, t) + v.slice(c)
1075
+ c = t
1076
+ }
1077
+ } else if (delFwd && c < v.length) {
1078
+ if (wordMod) {
1079
+ const t = wordRight(v, c)
1080
+ v = v.slice(0, c) + v.slice(t)
1081
+ } else {
1082
+ v = v.slice(0, c) + v.slice(nextPos(v, c))
1083
+ }
1084
+ } else if (actionDeleteWord) {
1085
+ if (range) {
1086
+ v = v.slice(0, range.start) + v.slice(range.end)
1087
+ c = range.start
1088
+ } else if (c > 0) {
1089
+ clearSel()
1090
+ const t = wordLeft(v, c)
1091
+ v = v.slice(0, t) + v.slice(c)
1092
+ c = t
1093
+ } else {
1094
+ return
1095
+ }
1096
+ } else if (actionDeleteToStart) {
1097
+ if (range) {
1098
+ v = v.slice(0, range.start) + v.slice(range.end)
1099
+ c = range.start
1100
+ } else {
1101
+ v = v.slice(c)
1102
+ c = 0
1103
+ }
1104
+ } else if (actionKillToEnd) {
1105
+ if (range) {
1106
+ v = v.slice(0, range.start) + v.slice(range.end)
1107
+ c = range.start
1108
+ } else {
1109
+ v = v.slice(0, c)
1110
+ }
1111
+ } else if (event.keypress.isPasted || inp.length > 0) {
1112
+ const bracketed = event.keypress.isPasted || inp.includes('[200~')
1113
+ const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
1114
+
1115
+ if (bracketed && emitPaste({ bracketed: true, cursor: c, text, value: v })) {
1116
+ return
1117
+ }
1118
+
1119
+ if (!text) {
1120
+ return
1121
+ }
1122
+
1123
+ if (text === '\n') {
1124
+ return commit(ins(v, c, '\n'), c + 1)
1125
+ }
1126
+
1127
+ if (text.length > 1 || text.includes('\n')) {
1128
+ if (shouldRouteMultiCharInputAsPaste(text)) {
1129
+ flushKeyBurst()
1130
+
1131
+ if (!emitPaste({ cursor: c, text, value: v })) {
1132
+ commit(ins(v, c, text), c + text.length)
1133
+ }
1134
+
1135
+ return
1136
+ }
1137
+
1138
+ const inserted = applyPrintableInsert(v, c, text, range)
1139
+
1140
+ if (!inserted) {
1141
+ return
1142
+ }
1143
+
1144
+ v = inserted.value
1145
+ c = inserted.cursor
1146
+ scheduleKeyBurstCommit(v, c)
1147
+
1148
+ return
1149
+ }
1150
+
1151
+ {
1152
+ const inserted = applyPrintableInsert(v, c, text, range)
1153
+
1154
+ if (!inserted) {
1155
+ return
1156
+ }
1157
+
1158
+ if (range) {
1159
+ v = inserted.value
1160
+ c = inserted.cursor
1161
+ } else {
1162
+ const simpleAppend = canFastAppend(v, c, text)
1163
+
1164
+ v = inserted.value
1165
+ c = inserted.cursor
1166
+
1167
+ if (simpleAppend) {
1168
+ stdout!.write(text)
1169
+ // ASCII-printable text advances the physical cursor by exactly
1170
+ // text.length cells (canFastAppendShape rejects non-ASCII,
1171
+ // wide chars, newlines). Notify Ink so the cached displayCursor
1172
+ // / log-update relative-move basis advances with it; otherwise
1173
+ // any unrelated re-render that happens before the 16ms
1174
+ // setCur/setParent flush parks the cursor text.length cells
1175
+ // too far right (#cursor-drift).
1176
+ noteCursorAdvance(text.length)
1177
+ commit(v, c, true, false, false, lineWidthRef.current + stringWidth(text))
1178
+
1179
+ return
1180
+ }
1181
+ }
1182
+ }
1183
+ } else {
1184
+ return
1185
+ }
1186
+
1187
+ commit(v, c)
1188
+ },
1189
+ { isActive: focus }
1190
+ )
1191
+
1192
+ return (
1193
+ <Box
1194
+ onClick={(e: MouseEventLite) => {
1195
+ if (!focus) {
1196
+ return
1197
+ }
1198
+
1199
+ e.stopImmediatePropagation?.()
1200
+ clearSel()
1201
+ const next = offsetAt(e)
1202
+ setCur(next)
1203
+ curRef.current = next
1204
+ }}
1205
+ onMouseDown={(e: MouseEventLite) => {
1206
+ if (!focus) {
1207
+ return
1208
+ }
1209
+
1210
+ // Right-click → copy active selection if any, otherwise paste.
1211
+ if (e.button === 2) {
1212
+ e.stopImmediatePropagation?.()
1213
+ const decision = decideRightClickAction(vRef.current, selRange())
1214
+
1215
+ if (decision.action === 'copy') {
1216
+ void writeClipboardText(decision.text)
1217
+
1218
+ return
1219
+ }
1220
+
1221
+ emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
1222
+
1223
+ return
1224
+ }
1225
+
1226
+ if (e.button !== 0) {
1227
+ return
1228
+ }
1229
+
1230
+ e.stopImmediatePropagation?.()
1231
+ const offset = offsetAt(e)
1232
+
1233
+ if (isMultiClickAt(offset)) {
1234
+ mouseAnchorRef.current = null
1235
+ selectAll()
1236
+
1237
+ return
1238
+ }
1239
+
1240
+ startMouseSelection(offset)
1241
+ }}
1242
+ onMouseDrag={(e: MouseEventLite) => {
1243
+ if (!focus || e.button !== 0 || mouseAnchorRef.current === null) {
1244
+ return
1245
+ }
1246
+
1247
+ e.stopImmediatePropagation?.()
1248
+ dragMouseSelection(offsetAt(e))
1249
+ }}
1250
+ onMouseUp={(e: MouseEventLite) => {
1251
+ e.stopImmediatePropagation?.()
1252
+ endMouseSelection()
1253
+ }}
1254
+ ref={boxRef}
1255
+ width={columns}
1256
+ >
1257
+ <Text wrap="wrap">{rendered}</Text>
1258
+ </Box>
1259
+ )
1260
+ }
1261
+
1262
+ type MouseEventLite = {
1263
+ button?: number
1264
+ localCol?: number
1265
+ localRow?: number
1266
+ stopImmediatePropagation?: () => void
1267
+ }
1268
+
1269
+ export interface PasteEvent {
1270
+ bracketed?: boolean
1271
+ cursor: number
1272
+ hotkey?: boolean
1273
+ text: string
1274
+ value: string
1275
+ }
1276
+
1277
+ interface TextInputProps {
1278
+ columns?: number
1279
+ focus?: boolean
1280
+ mask?: string
1281
+ mouseApiRef?: MutableRefObject<null | TextInputMouseApi>
1282
+ onChange: (v: string) => void
1283
+ onPaste?: (
1284
+ e: PasteEvent
1285
+ ) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null
1286
+ onSubmit?: (v: string) => void
1287
+ placeholder?: string
1288
+ value: string
1289
+ voiceRecordKey?: ParsedVoiceRecordKey
1290
+ }
1291
+
1292
+ export type RightClickDecision =
1293
+ | { action: 'copy'; text: string }
1294
+ | { action: 'paste' }
1295
+
1296
+ /**
1297
+ * Decide what right-click should do on the composer:
1298
+ * - non-empty selection → copy that text to the clipboard
1299
+ * - no selection (or empty/collapsed range) → fall through to paste
1300
+ *
1301
+ * Mirrors terminal-native behavior (xterm, iTerm, gnome-terminal) where
1302
+ * right-click pastes only when there is nothing selected to copy.
1303
+ *
1304
+ * Callers pass the already-normalized range from `selRange()` (start <= end,
1305
+ * or null when collapsed), so this helper does not need to re-normalize.
1306
+ */
1307
+ export function decideRightClickAction(
1308
+ value: string,
1309
+ range: { end: number; start: number } | null
1310
+ ): RightClickDecision {
1311
+ if (range && range.end > range.start) {
1312
+ const text = value.slice(range.start, range.end)
1313
+
1314
+ if (text) {
1315
+ return { action: 'copy', text }
1316
+ }
1317
+ }
1318
+
1319
+ return { action: 'paste' }
1320
+ }
1321
+
1322
+ export const shouldPassThroughToGlobalHandler = (
1323
+ input: string,
1324
+ key: Key,
1325
+ voiceRecordKey: ParsedVoiceRecordKey = DEFAULT_VOICE_RECORD_KEY
1326
+ ): boolean =>
1327
+ (key.ctrl && input === 'c') ||
1328
+ (key.ctrl && input === 'x') ||
1329
+ key.tab ||
1330
+ (key.shift && key.tab) ||
1331
+ key.pageUp ||
1332
+ key.pageDown ||
1333
+ key.escape ||
1334
+ isVoiceToggleKey(key, input, voiceRecordKey)
1335
+
1336
+ export interface TextInputMouseApi {
1337
+ dragAt: (row: number, col: number) => void
1338
+ end: () => void
1339
+ startAtBeginning: () => void
1340
+ }