@butlerw/vellum 0.1.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 (446) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +411 -0
  3. package/__fixtures__/responses/code-generation.json +42 -0
  4. package/__fixtures__/responses/error-response.json +20 -0
  5. package/__fixtures__/responses/hello-world.json +32 -0
  6. package/dist/auth-6MCXESOH.js +26 -0
  7. package/dist/chunk-SECXJGWA.js +597 -0
  8. package/dist/index.js +34023 -0
  9. package/package.json +67 -0
  10. package/src/__tests__/commands.e2e.test.ts +728 -0
  11. package/src/__tests__/credentials.test.ts +713 -0
  12. package/src/__tests__/mode-e2e.test.ts +391 -0
  13. package/src/__tests__/tui-integration.test.tsx +1271 -0
  14. package/src/agents/__tests__/task-persistence.test.ts +235 -0
  15. package/src/agents/commands/delegate.ts +240 -0
  16. package/src/agents/commands/index.ts +10 -0
  17. package/src/agents/commands/resume.ts +335 -0
  18. package/src/agents/index.ts +29 -0
  19. package/src/agents/task-persistence.ts +272 -0
  20. package/src/agents/task-resumption.ts +242 -0
  21. package/src/app.tsx +4737 -0
  22. package/src/commands/__tests__/.gitkeep +1 -0
  23. package/src/commands/__tests__/agents.test.ts +606 -0
  24. package/src/commands/__tests__/auth.test.ts +626 -0
  25. package/src/commands/__tests__/autocomplete.test.ts +683 -0
  26. package/src/commands/__tests__/batch.test.ts +287 -0
  27. package/src/commands/__tests__/chain-pipe-parser.test.ts +654 -0
  28. package/src/commands/__tests__/completion.test.ts +238 -0
  29. package/src/commands/__tests__/core.test.ts +363 -0
  30. package/src/commands/__tests__/executor.test.ts +496 -0
  31. package/src/commands/__tests__/exit-codes.test.ts +220 -0
  32. package/src/commands/__tests__/init.test.ts +243 -0
  33. package/src/commands/__tests__/language.test.ts +353 -0
  34. package/src/commands/__tests__/mode-cli.test.ts +667 -0
  35. package/src/commands/__tests__/model.test.ts +277 -0
  36. package/src/commands/__tests__/parser.test.ts +493 -0
  37. package/src/commands/__tests__/performance.bench.ts +380 -0
  38. package/src/commands/__tests__/registry.test.ts +534 -0
  39. package/src/commands/__tests__/resume.test.ts +449 -0
  40. package/src/commands/__tests__/security.test.ts +845 -0
  41. package/src/commands/__tests__/stream-json.test.ts +372 -0
  42. package/src/commands/__tests__/user-commands.test.ts +597 -0
  43. package/src/commands/adapters.ts +267 -0
  44. package/src/commands/agent.ts +395 -0
  45. package/src/commands/agents/generate.ts +506 -0
  46. package/src/commands/agents/index.ts +272 -0
  47. package/src/commands/agents/show.ts +271 -0
  48. package/src/commands/agents/validate.ts +387 -0
  49. package/src/commands/auth.ts +883 -0
  50. package/src/commands/autocomplete.ts +480 -0
  51. package/src/commands/batch/command.ts +388 -0
  52. package/src/commands/batch/executor.ts +361 -0
  53. package/src/commands/batch/index.ts +12 -0
  54. package/src/commands/commit.ts +235 -0
  55. package/src/commands/completion/index.ts +371 -0
  56. package/src/commands/condense.ts +191 -0
  57. package/src/commands/config.ts +344 -0
  58. package/src/commands/context-provider.ts +173 -0
  59. package/src/commands/copy.ts +329 -0
  60. package/src/commands/core/clear.ts +38 -0
  61. package/src/commands/core/exit.ts +43 -0
  62. package/src/commands/core/help.ts +354 -0
  63. package/src/commands/core/index.ts +15 -0
  64. package/src/commands/cost.ts +179 -0
  65. package/src/commands/credentials.tsx +618 -0
  66. package/src/commands/custom-agents/__tests__/custom-agents.test.ts +709 -0
  67. package/src/commands/custom-agents/create.ts +377 -0
  68. package/src/commands/custom-agents/export.ts +135 -0
  69. package/src/commands/custom-agents/import.ts +199 -0
  70. package/src/commands/custom-agents/index.ts +372 -0
  71. package/src/commands/custom-agents/info.ts +318 -0
  72. package/src/commands/custom-agents/list.ts +267 -0
  73. package/src/commands/custom-agents/validate.ts +388 -0
  74. package/src/commands/diff-mode.ts +241 -0
  75. package/src/commands/env.ts +53 -0
  76. package/src/commands/executor.ts +579 -0
  77. package/src/commands/exit-codes.ts +202 -0
  78. package/src/commands/index.ts +701 -0
  79. package/src/commands/init/index.ts +15 -0
  80. package/src/commands/init/prompts.ts +366 -0
  81. package/src/commands/init/templates/commands-readme.md +80 -0
  82. package/src/commands/init/templates/example-command.md +79 -0
  83. package/src/commands/init/templates/example-skill.md +168 -0
  84. package/src/commands/init/templates/example-workflow.md +101 -0
  85. package/src/commands/init/templates/prompts-readme.md +52 -0
  86. package/src/commands/init/templates/rules-readme.md +63 -0
  87. package/src/commands/init/templates/skills-readme.md +83 -0
  88. package/src/commands/init/templates/workflows-readme.md +94 -0
  89. package/src/commands/init.ts +391 -0
  90. package/src/commands/install.ts +90 -0
  91. package/src/commands/language.ts +191 -0
  92. package/src/commands/loaders/.gitkeep +1 -0
  93. package/src/commands/lsp.ts +199 -0
  94. package/src/commands/markdown-commands.ts +253 -0
  95. package/src/commands/mcp.ts +588 -0
  96. package/src/commands/memory/export.ts +341 -0
  97. package/src/commands/memory/index.ts +148 -0
  98. package/src/commands/memory/list.ts +261 -0
  99. package/src/commands/memory/search.ts +346 -0
  100. package/src/commands/memory/utils.ts +15 -0
  101. package/src/commands/metrics.ts +75 -0
  102. package/src/commands/migrate/index.ts +16 -0
  103. package/src/commands/migrate/prompts.ts +477 -0
  104. package/src/commands/mode.ts +331 -0
  105. package/src/commands/model.ts +298 -0
  106. package/src/commands/onboard.ts +205 -0
  107. package/src/commands/open.ts +169 -0
  108. package/src/commands/output/stream-json.ts +373 -0
  109. package/src/commands/parser/chain-parser.ts +370 -0
  110. package/src/commands/parser/index.ts +29 -0
  111. package/src/commands/parser/pipe-parser.ts +480 -0
  112. package/src/commands/parser.ts +588 -0
  113. package/src/commands/persistence.ts +355 -0
  114. package/src/commands/progress.ts +18 -0
  115. package/src/commands/prompt/index.ts +17 -0
  116. package/src/commands/prompt/validate.ts +621 -0
  117. package/src/commands/prompt-priority.ts +401 -0
  118. package/src/commands/registry.ts +374 -0
  119. package/src/commands/sandbox/index.ts +131 -0
  120. package/src/commands/security/index.ts +21 -0
  121. package/src/commands/security/input-sanitizer.ts +168 -0
  122. package/src/commands/security/permission-checker.ts +456 -0
  123. package/src/commands/security/sensitive-data.ts +350 -0
  124. package/src/commands/session/delete.ts +38 -0
  125. package/src/commands/session/export.ts +39 -0
  126. package/src/commands/session/index.ts +26 -0
  127. package/src/commands/session/list.ts +26 -0
  128. package/src/commands/session/resume.ts +562 -0
  129. package/src/commands/session/search.ts +434 -0
  130. package/src/commands/session/show.ts +26 -0
  131. package/src/commands/settings.ts +368 -0
  132. package/src/commands/setup.ts +23 -0
  133. package/src/commands/shell/index.ts +16 -0
  134. package/src/commands/shell/setup.ts +422 -0
  135. package/src/commands/shell-init.ts +50 -0
  136. package/src/commands/shell-integration/index.ts +194 -0
  137. package/src/commands/skill.ts +1220 -0
  138. package/src/commands/spec.ts +558 -0
  139. package/src/commands/status.ts +246 -0
  140. package/src/commands/theme.ts +211 -0
  141. package/src/commands/think.ts +551 -0
  142. package/src/commands/trust.ts +211 -0
  143. package/src/commands/tutorial.ts +522 -0
  144. package/src/commands/types.ts +512 -0
  145. package/src/commands/update.ts +274 -0
  146. package/src/commands/usage.ts +213 -0
  147. package/src/commands/user-commands.ts +630 -0
  148. package/src/commands/utils.ts +142 -0
  149. package/src/commands/vim.ts +152 -0
  150. package/src/commands/workflow.ts +257 -0
  151. package/src/components/header.tsx +25 -0
  152. package/src/components/input.tsx +25 -0
  153. package/src/components/message-list.tsx +32 -0
  154. package/src/components/status-bar.tsx +23 -0
  155. package/src/index.tsx +614 -0
  156. package/src/onboarding/__tests__/tutorial.test.ts +740 -0
  157. package/src/onboarding/index.ts +69 -0
  158. package/src/onboarding/tips/index.ts +9 -0
  159. package/src/onboarding/tips/tip-engine.ts +459 -0
  160. package/src/onboarding/tutorial/index.ts +88 -0
  161. package/src/onboarding/tutorial/lessons/basics.ts +151 -0
  162. package/src/onboarding/tutorial/lessons/index.ts +151 -0
  163. package/src/onboarding/tutorial/lessons/modes.ts +230 -0
  164. package/src/onboarding/tutorial/lessons/tools.ts +172 -0
  165. package/src/onboarding/tutorial/progress-tracker.ts +350 -0
  166. package/src/onboarding/tutorial/storage.ts +249 -0
  167. package/src/onboarding/tutorial/tutorial-system.ts +462 -0
  168. package/src/onboarding/tutorial/types.ts +310 -0
  169. package/src/orchestrator-singleton.ts +129 -0
  170. package/src/shutdown.ts +33 -0
  171. package/src/test/e2e/assertions.ts +267 -0
  172. package/src/test/e2e/fixtures.ts +204 -0
  173. package/src/test/e2e/harness.ts +575 -0
  174. package/src/test/e2e/index.ts +57 -0
  175. package/src/test/e2e/types.ts +228 -0
  176. package/src/test/fixtures/__tests__/fake-response-loader.test.ts +314 -0
  177. package/src/test/fixtures/fake-response-loader.ts +314 -0
  178. package/src/test/fixtures/index.ts +20 -0
  179. package/src/tui/__tests__/mcp-panel.test.tsx +82 -0
  180. package/src/tui/__tests__/mcp-wiring.test.tsx +78 -0
  181. package/src/tui/__tests__/mode-components.test.tsx +395 -0
  182. package/src/tui/__tests__/permission-ask-flow.test.tsx +138 -0
  183. package/src/tui/__tests__/sidebar-panel-data.test.tsx +148 -0
  184. package/src/tui/__tests__/tools-panel-hotkeys.test.tsx +41 -0
  185. package/src/tui/adapters/agent-adapter.ts +1008 -0
  186. package/src/tui/adapters/index.ts +48 -0
  187. package/src/tui/adapters/message-adapter.ts +315 -0
  188. package/src/tui/adapters/persistence-bridge.ts +331 -0
  189. package/src/tui/adapters/session-adapter.ts +419 -0
  190. package/src/tui/buffered-stdout.ts +223 -0
  191. package/src/tui/components/AgentProgress.tsx +424 -0
  192. package/src/tui/components/Banner/AsciiArt.ts +160 -0
  193. package/src/tui/components/Banner/Banner.tsx +355 -0
  194. package/src/tui/components/Banner/ShimmerContext.tsx +131 -0
  195. package/src/tui/components/Banner/ShimmerText.tsx +193 -0
  196. package/src/tui/components/Banner/TypeWriterGradient.tsx +321 -0
  197. package/src/tui/components/Banner/index.ts +61 -0
  198. package/src/tui/components/Banner/useShimmer.ts +241 -0
  199. package/src/tui/components/ChatView.tsx +11 -0
  200. package/src/tui/components/Checkpoint/CheckpointDiffView.tsx +371 -0
  201. package/src/tui/components/Checkpoint/SnapshotCheckpointPanel.tsx +440 -0
  202. package/src/tui/components/Checkpoint/index.ts +19 -0
  203. package/src/tui/components/CostDisplay.tsx +226 -0
  204. package/src/tui/components/InitErrorBanner.tsx +122 -0
  205. package/src/tui/components/Input/Autocomplete.tsx +603 -0
  206. package/src/tui/components/Input/EnhancedCommandInput.tsx +471 -0
  207. package/src/tui/components/Input/HighlightedText.tsx +236 -0
  208. package/src/tui/components/Input/MentionAutocomplete.tsx +375 -0
  209. package/src/tui/components/Input/TextInput.tsx +1002 -0
  210. package/src/tui/components/Input/__tests__/Autocomplete.test.tsx +374 -0
  211. package/src/tui/components/Input/__tests__/TextInput.test.tsx +241 -0
  212. package/src/tui/components/Input/__tests__/highlight.test.ts +219 -0
  213. package/src/tui/components/Input/__tests__/slash-command-utils.test.ts +104 -0
  214. package/src/tui/components/Input/highlight.ts +362 -0
  215. package/src/tui/components/Input/index.ts +36 -0
  216. package/src/tui/components/Input/slash-command-utils.ts +135 -0
  217. package/src/tui/components/Layout.tsx +432 -0
  218. package/src/tui/components/McpPanel.tsx +137 -0
  219. package/src/tui/components/MemoryPanel.tsx +448 -0
  220. package/src/tui/components/Messages/CodeBlock.tsx +527 -0
  221. package/src/tui/components/Messages/DiffView.tsx +679 -0
  222. package/src/tui/components/Messages/ImageReference.tsx +89 -0
  223. package/src/tui/components/Messages/MarkdownBlock.tsx +228 -0
  224. package/src/tui/components/Messages/MarkdownRenderer.tsx +498 -0
  225. package/src/tui/components/Messages/MessageBubble.tsx +270 -0
  226. package/src/tui/components/Messages/MessageList.tsx +1719 -0
  227. package/src/tui/components/Messages/StreamingText.tsx +216 -0
  228. package/src/tui/components/Messages/ThinkingBlock.tsx +408 -0
  229. package/src/tui/components/Messages/ToolResultPreview.tsx +243 -0
  230. package/src/tui/components/Messages/__tests__/CodeBlock.test.tsx +296 -0
  231. package/src/tui/components/Messages/__tests__/DiffView.test.tsx +239 -0
  232. package/src/tui/components/Messages/__tests__/MarkdownRenderer.test.tsx +303 -0
  233. package/src/tui/components/Messages/__tests__/MessageBubble.test.tsx +268 -0
  234. package/src/tui/components/Messages/__tests__/MessageList.test.tsx +324 -0
  235. package/src/tui/components/Messages/__tests__/StreamingText.test.tsx +215 -0
  236. package/src/tui/components/Messages/index.ts +25 -0
  237. package/src/tui/components/ModeIndicator.tsx +177 -0
  238. package/src/tui/components/ModeSelector.tsx +216 -0
  239. package/src/tui/components/ModelSelector.tsx +339 -0
  240. package/src/tui/components/OnboardingWizard.tsx +670 -0
  241. package/src/tui/components/PhaseProgressIndicator.tsx +270 -0
  242. package/src/tui/components/RateLimitIndicator.tsx +82 -0
  243. package/src/tui/components/ScreenReaderLayout.tsx +295 -0
  244. package/src/tui/components/SettingsPanel.tsx +643 -0
  245. package/src/tui/components/Sidebar/SystemStatusPanel.tsx +284 -0
  246. package/src/tui/components/Sidebar/index.ts +9 -0
  247. package/src/tui/components/Status/ModelStatusBar.tsx +270 -0
  248. package/src/tui/components/Status/index.ts +12 -0
  249. package/src/tui/components/StatusBar/AgentModeIndicator.tsx +257 -0
  250. package/src/tui/components/StatusBar/ContextProgress.tsx +167 -0
  251. package/src/tui/components/StatusBar/FileChangesIndicator.tsx +62 -0
  252. package/src/tui/components/StatusBar/GitIndicator.tsx +89 -0
  253. package/src/tui/components/StatusBar/HeaderBar.tsx +126 -0
  254. package/src/tui/components/StatusBar/ModelIndicator.tsx +157 -0
  255. package/src/tui/components/StatusBar/PersistenceStatusIndicator.tsx +210 -0
  256. package/src/tui/components/StatusBar/ResilienceIndicator.tsx +106 -0
  257. package/src/tui/components/StatusBar/SandboxIndicator.tsx +167 -0
  258. package/src/tui/components/StatusBar/StatusBar.tsx +368 -0
  259. package/src/tui/components/StatusBar/ThinkingModeIndicator.tsx +170 -0
  260. package/src/tui/components/StatusBar/TokenBreakdown.tsx +246 -0
  261. package/src/tui/components/StatusBar/TokenCounter.tsx +135 -0
  262. package/src/tui/components/StatusBar/TrustModeIndicator.tsx +130 -0
  263. package/src/tui/components/StatusBar/WorkspaceIndicator.tsx +86 -0
  264. package/src/tui/components/StatusBar/__tests__/AgentModeIndicator.test.tsx +193 -0
  265. package/src/tui/components/StatusBar/__tests__/StatusBar.test.tsx +729 -0
  266. package/src/tui/components/StatusBar/index.ts +60 -0
  267. package/src/tui/components/TipBanner.tsx +115 -0
  268. package/src/tui/components/TodoItem.tsx +208 -0
  269. package/src/tui/components/TodoPanel.tsx +455 -0
  270. package/src/tui/components/Tools/ApprovalQueue.tsx +407 -0
  271. package/src/tui/components/Tools/OptionSelector.tsx +160 -0
  272. package/src/tui/components/Tools/PermissionDialog.tsx +286 -0
  273. package/src/tui/components/Tools/ToolParams.tsx +483 -0
  274. package/src/tui/components/Tools/ToolsPanel.tsx +178 -0
  275. package/src/tui/components/Tools/__tests__/PermissionDialog.test.tsx +510 -0
  276. package/src/tui/components/Tools/__tests__/ToolParams.test.tsx +432 -0
  277. package/src/tui/components/Tools/index.ts +21 -0
  278. package/src/tui/components/TrustPrompt.tsx +279 -0
  279. package/src/tui/components/UpdateBanner.tsx +166 -0
  280. package/src/tui/components/VimModeIndicator.tsx +112 -0
  281. package/src/tui/components/backtrack/BacktrackControls.tsx +402 -0
  282. package/src/tui/components/backtrack/index.ts +13 -0
  283. package/src/tui/components/common/AutoApprovalStatus.tsx +251 -0
  284. package/src/tui/components/common/CostWarning.tsx +294 -0
  285. package/src/tui/components/common/DynamicShortcutHints.tsx +209 -0
  286. package/src/tui/components/common/EnhancedLoadingIndicator.tsx +305 -0
  287. package/src/tui/components/common/ErrorBoundary.tsx +140 -0
  288. package/src/tui/components/common/GradientText.tsx +224 -0
  289. package/src/tui/components/common/HotkeyHelpModal.tsx +193 -0
  290. package/src/tui/components/common/HotkeyHints.tsx +70 -0
  291. package/src/tui/components/common/MaxSizedBox.tsx +354 -0
  292. package/src/tui/components/common/NewMessagesBadge.tsx +65 -0
  293. package/src/tui/components/common/ProtectedFileLegend.tsx +89 -0
  294. package/src/tui/components/common/ScrollIndicator.tsx +160 -0
  295. package/src/tui/components/common/Spinner.tsx +342 -0
  296. package/src/tui/components/common/StreamingIndicator.tsx +316 -0
  297. package/src/tui/components/common/VirtualizedList/VirtualizedList.tsx +428 -0
  298. package/src/tui/components/common/VirtualizedList/hooks/index.ts +19 -0
  299. package/src/tui/components/common/VirtualizedList/hooks/useBatchedScroll.ts +64 -0
  300. package/src/tui/components/common/VirtualizedList/hooks/useScrollAnchor.ts +290 -0
  301. package/src/tui/components/common/VirtualizedList/hooks/useVirtualization.ts +340 -0
  302. package/src/tui/components/common/VirtualizedList/index.ts +30 -0
  303. package/src/tui/components/common/VirtualizedList/types.ts +107 -0
  304. package/src/tui/components/common/__tests__/NewMessagesBadge.test.tsx +74 -0
  305. package/src/tui/components/common/__tests__/ScrollIndicator.test.tsx +193 -0
  306. package/src/tui/components/common/index.ts +110 -0
  307. package/src/tui/components/index.ts +79 -0
  308. package/src/tui/components/session/CheckpointPanel.tsx +323 -0
  309. package/src/tui/components/session/RollbackDialog.tsx +169 -0
  310. package/src/tui/components/session/SessionItem.tsx +136 -0
  311. package/src/tui/components/session/SessionListPanel.tsx +252 -0
  312. package/src/tui/components/session/SessionPicker.tsx +449 -0
  313. package/src/tui/components/session/SessionPreview.tsx +240 -0
  314. package/src/tui/components/session/__tests__/session.test.tsx +408 -0
  315. package/src/tui/components/session/index.ts +28 -0
  316. package/src/tui/components/session/types.ts +116 -0
  317. package/src/tui/components/theme/__tests__/tokens.test.ts +471 -0
  318. package/src/tui/components/theme/index.ts +227 -0
  319. package/src/tui/components/theme/tokens.ts +484 -0
  320. package/src/tui/config/defaults.ts +134 -0
  321. package/src/tui/config/index.ts +17 -0
  322. package/src/tui/context/AnimationContext.tsx +284 -0
  323. package/src/tui/context/AppContext.tsx +349 -0
  324. package/src/tui/context/BracketedPasteContext.tsx +372 -0
  325. package/src/tui/context/LspContext.tsx +192 -0
  326. package/src/tui/context/McpContext.tsx +325 -0
  327. package/src/tui/context/MessagesContext.tsx +870 -0
  328. package/src/tui/context/OverflowContext.tsx +213 -0
  329. package/src/tui/context/RateLimitContext.tsx +108 -0
  330. package/src/tui/context/ResilienceContext.tsx +275 -0
  331. package/src/tui/context/RootProvider.tsx +136 -0
  332. package/src/tui/context/ScrollContext.tsx +331 -0
  333. package/src/tui/context/ToolsContext.tsx +702 -0
  334. package/src/tui/context/__tests__/BracketedPasteContext.test.tsx +416 -0
  335. package/src/tui/context/index.ts +140 -0
  336. package/src/tui/enterprise-integration.ts +282 -0
  337. package/src/tui/hooks/__tests__/useBacktrack.test.tsx +138 -0
  338. package/src/tui/hooks/__tests__/useBracketedPaste.test.tsx +222 -0
  339. package/src/tui/hooks/__tests__/useCopyMode.test.tsx +336 -0
  340. package/src/tui/hooks/__tests__/useHotkeys.ctrl-input.test.tsx +96 -0
  341. package/src/tui/hooks/__tests__/useHotkeys.test.tsx +454 -0
  342. package/src/tui/hooks/__tests__/useInputHistory.test.tsx +660 -0
  343. package/src/tui/hooks/__tests__/useLineBuffer.test.ts +295 -0
  344. package/src/tui/hooks/__tests__/useModeController.test.ts +137 -0
  345. package/src/tui/hooks/__tests__/useModeShortcuts.test.tsx +142 -0
  346. package/src/tui/hooks/__tests__/useScrollController.test.ts +464 -0
  347. package/src/tui/hooks/__tests__/useVim.test.tsx +531 -0
  348. package/src/tui/hooks/index.ts +252 -0
  349. package/src/tui/hooks/useAgentLoop.ts +712 -0
  350. package/src/tui/hooks/useAlternateBuffer.ts +398 -0
  351. package/src/tui/hooks/useAnimatedScrollbar.ts +241 -0
  352. package/src/tui/hooks/useBacktrack.ts +443 -0
  353. package/src/tui/hooks/useBracketedPaste.ts +104 -0
  354. package/src/tui/hooks/useCollapsible.ts +240 -0
  355. package/src/tui/hooks/useCopyMode.ts +382 -0
  356. package/src/tui/hooks/useCostSummary.ts +75 -0
  357. package/src/tui/hooks/useDesktopNotification.ts +414 -0
  358. package/src/tui/hooks/useDiffMode.ts +44 -0
  359. package/src/tui/hooks/useFileChangeStats.ts +110 -0
  360. package/src/tui/hooks/useFileSuggestions.ts +284 -0
  361. package/src/tui/hooks/useFlickerDetector.ts +250 -0
  362. package/src/tui/hooks/useGitStatus.ts +200 -0
  363. package/src/tui/hooks/useHotkeys.ts +579 -0
  364. package/src/tui/hooks/useImagePaste.ts +114 -0
  365. package/src/tui/hooks/useInputHighlight.ts +145 -0
  366. package/src/tui/hooks/useInputHistory.ts +246 -0
  367. package/src/tui/hooks/useKeyboardScroll.ts +209 -0
  368. package/src/tui/hooks/useLineBuffer.ts +356 -0
  369. package/src/tui/hooks/useMentionAutocomplete.ts +235 -0
  370. package/src/tui/hooks/useModeController.ts +167 -0
  371. package/src/tui/hooks/useModeShortcuts.ts +196 -0
  372. package/src/tui/hooks/usePermissionHandler.ts +146 -0
  373. package/src/tui/hooks/usePersistence.ts +480 -0
  374. package/src/tui/hooks/usePersistenceShortcuts.ts +225 -0
  375. package/src/tui/hooks/usePlaceholderRotation.ts +143 -0
  376. package/src/tui/hooks/useProviderStatus.ts +270 -0
  377. package/src/tui/hooks/useRateLimitStatus.ts +90 -0
  378. package/src/tui/hooks/useScreenReader.ts +315 -0
  379. package/src/tui/hooks/useScrollController.ts +450 -0
  380. package/src/tui/hooks/useScrollEventBatcher.ts +185 -0
  381. package/src/tui/hooks/useSidebarPanelData.ts +115 -0
  382. package/src/tui/hooks/useSmoothScroll.ts +202 -0
  383. package/src/tui/hooks/useSnapshots.ts +300 -0
  384. package/src/tui/hooks/useStateAndRef.ts +50 -0
  385. package/src/tui/hooks/useTerminalSize.ts +206 -0
  386. package/src/tui/hooks/useToolApprovalController.ts +91 -0
  387. package/src/tui/hooks/useVim.ts +334 -0
  388. package/src/tui/hooks/useWorkspace.ts +56 -0
  389. package/src/tui/i18n/__tests__/init.test.ts +278 -0
  390. package/src/tui/i18n/__tests__/language-config.test.ts +199 -0
  391. package/src/tui/i18n/__tests__/locale-detection.test.ts +250 -0
  392. package/src/tui/i18n/__tests__/settings-integration.test.ts +262 -0
  393. package/src/tui/i18n/index.ts +72 -0
  394. package/src/tui/i18n/init.ts +131 -0
  395. package/src/tui/i18n/language-config.ts +106 -0
  396. package/src/tui/i18n/locale-detection.ts +173 -0
  397. package/src/tui/i18n/settings-integration.ts +557 -0
  398. package/src/tui/i18n/tui-namespace.ts +538 -0
  399. package/src/tui/i18n/types.ts +312 -0
  400. package/src/tui/index.ts +43 -0
  401. package/src/tui/lsp-integration.ts +409 -0
  402. package/src/tui/metrics-integration.ts +366 -0
  403. package/src/tui/plugins.ts +383 -0
  404. package/src/tui/resilience.ts +342 -0
  405. package/src/tui/sandbox-integration.ts +317 -0
  406. package/src/tui/services/clipboard.ts +348 -0
  407. package/src/tui/services/fuzzy-search.ts +441 -0
  408. package/src/tui/services/index.ts +72 -0
  409. package/src/tui/services/markdown-renderer.ts +565 -0
  410. package/src/tui/services/open-external.ts +247 -0
  411. package/src/tui/services/syntax-highlighter.ts +483 -0
  412. package/src/tui/slash-commands.ts +12 -0
  413. package/src/tui/theme/index.ts +15 -0
  414. package/src/tui/theme/provider.tsx +206 -0
  415. package/src/tui/tip-integration.ts +300 -0
  416. package/src/tui/types/__tests__/ink-extended.test.ts +121 -0
  417. package/src/tui/types/ink-extended.ts +87 -0
  418. package/src/tui/utils/__tests__/bracketedPaste.test.ts +231 -0
  419. package/src/tui/utils/__tests__/heightEstimator.test.ts +157 -0
  420. package/src/tui/utils/__tests__/text-width.test.ts +158 -0
  421. package/src/tui/utils/__tests__/textSanitizer.test.ts +266 -0
  422. package/src/tui/utils/__tests__/ui-sizing.test.ts +169 -0
  423. package/src/tui/utils/bracketedPaste.ts +107 -0
  424. package/src/tui/utils/cursor-manager.ts +131 -0
  425. package/src/tui/utils/detectTerminal.ts +596 -0
  426. package/src/tui/utils/findLastSafeSplitPoint.ts +92 -0
  427. package/src/tui/utils/heightEstimator.ts +198 -0
  428. package/src/tui/utils/index.ts +91 -0
  429. package/src/tui/utils/isNarrowWidth.ts +52 -0
  430. package/src/tui/utils/stdoutGuard.ts +90 -0
  431. package/src/tui/utils/synchronized-update.ts +70 -0
  432. package/src/tui/utils/text-width.ts +225 -0
  433. package/src/tui/utils/textSanitizer.ts +225 -0
  434. package/src/tui/utils/textUtils.ts +114 -0
  435. package/src/tui/utils/ui-sizing.ts +192 -0
  436. package/src/tui-blessed/app.ts +160 -0
  437. package/src/tui-blessed/index.ts +2 -0
  438. package/src/tui-blessed/neo-blessed.d.ts +6 -0
  439. package/src/tui-blessed/test.ts +21 -0
  440. package/src/tui-blessed/types.ts +14 -0
  441. package/src/utils/icons.ts +130 -0
  442. package/src/utils/index.ts +33 -0
  443. package/src/utils/resume-hint.ts +86 -0
  444. package/src/version.ts +1 -0
  445. package/tsconfig.json +8 -0
  446. package/vitest.config.ts +35 -0
@@ -0,0 +1,1002 @@
1
+ /**
2
+ * TextInput Component (T009)
3
+ *
4
+ * A React Ink-based text input component with multiline support.
5
+ * Provides keyboard handling for text entry, navigation, and submission.
6
+ *
7
+ * @module tui/components/Input/TextInput
8
+ */
9
+
10
+ import type { Key } from "ink";
11
+ import { Box, Text, useInput } from "ink";
12
+ import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
13
+ import { useAnimation } from "../../context/AnimationContext.js";
14
+ import { usePasteHandler } from "../../context/BracketedPasteContext.js";
15
+ import { useTheme } from "../../theme/index.js";
16
+ import { HighlightedText } from "./HighlightedText.js";
17
+
18
+ // =============================================================================
19
+ // Types
20
+ // =============================================================================
21
+
22
+ /**
23
+ * Props for the TextInput component.
24
+ */
25
+ export interface TextInputProps {
26
+ /** Current input value (controlled) */
27
+ readonly value: string;
28
+ /** Callback when value changes */
29
+ readonly onChange: (value: string) => void;
30
+ /** Callback when input is submitted */
31
+ readonly onSubmit?: (value: string) => void;
32
+ /** Placeholder text shown when value is empty */
33
+ readonly placeholder?: string;
34
+ /** Enable multiline input mode */
35
+ readonly multiline?: boolean;
36
+ /** Disable input interactions */
37
+ readonly disabled?: boolean;
38
+ /** Maximum character length */
39
+ readonly maxLength?: number;
40
+ /** Whether the input is focused (enables keyboard handling) */
41
+ readonly focused?: boolean;
42
+ /** Minimum height in lines (default: 1 for single-line, 3 for multiline) */
43
+ readonly minHeight?: number;
44
+ /** Optional mask character for password-style input */
45
+ readonly mask?: string;
46
+ /** When true, suppress Enter from submitting (for autocomplete integration) */
47
+ readonly suppressEnter?: boolean;
48
+ /** When true, suppress Tab from inserting spaces (for autocomplete integration) */
49
+ readonly suppressTab?: boolean;
50
+ /** When true, move cursor to end of value on next render */
51
+ readonly cursorToEnd?: boolean;
52
+ /** Callback when cursorToEnd is consumed */
53
+ readonly onCursorMoved?: () => void;
54
+ /** Whether to show border in single-line mode (default: true) */
55
+ readonly showBorder?: boolean;
56
+ /** Enable syntax highlighting for @mentions, /commands, URLs, and `code` (default: false) */
57
+ readonly enableHighlight?: boolean;
58
+ }
59
+
60
+ // =============================================================================
61
+ // Helper Functions
62
+ // =============================================================================
63
+
64
+ /**
65
+ * Insert a character at a specific position in a string.
66
+ */
67
+ function insertAt(str: string, index: number, char: string): string {
68
+ return str.slice(0, index) + char + str.slice(index);
69
+ }
70
+
71
+ /**
72
+ * Delete a character at a specific position in a string.
73
+ */
74
+ function deleteAt(str: string, index: number): string {
75
+ if (index <= 0 || index > str.length) return str;
76
+ return str.slice(0, index - 1) + str.slice(index);
77
+ }
78
+
79
+ /**
80
+ * Strip common ANSI control sequences (e.g., bracketed paste wrappers).
81
+ */
82
+ function stripAnsiSequences(input: string): string {
83
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Intentional - matching ANSI escape sequences
84
+ const withoutCsi = input.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
85
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Intentional - matching OSC sequences with BEL/ST terminators
86
+ return withoutCsi.replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, "");
87
+ }
88
+
89
+ /**
90
+ * Normalize and sanitize input chunks for single-line or multiline fields.
91
+ */
92
+ function normalizeInputValue(input: string, multiline: boolean): string {
93
+ const sanitized = stripAnsiSequences(input)
94
+ .replace(/\r\n/g, "\n")
95
+ .replace(/\r/g, "\n")
96
+ .replace(/\u2028|\u2029/g, "\n");
97
+
98
+ const withoutControls = multiline
99
+ ? // biome-ignore lint/suspicious/noControlCharactersInRegex: Intentional - stripping control chars except tab/newline/CR
100
+ sanitized.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x80-\x9f]/g, "")
101
+ : // biome-ignore lint/suspicious/noControlCharactersInRegex: Intentional - stripping all control chars for single-line
102
+ sanitized.replace(/[\x00-\x1f\x7f\x80-\x9f]/g, "");
103
+
104
+ return multiline ? withoutControls : withoutControls.replace(/\n/g, "");
105
+ }
106
+
107
+ /**
108
+ * Generate a stable key for multiline rendering.
109
+ * Uses line start position as key since lines can be added/removed.
110
+ */
111
+ function getLineKey(lineStartPos: number): string {
112
+ return `line-${lineStartPos}`;
113
+ }
114
+
115
+ // =============================================================================
116
+ // Component
117
+ // =============================================================================
118
+
119
+ /**
120
+ * TextInput provides a text input field for terminal UIs.
121
+ *
122
+ * Features:
123
+ * - Single-line mode: Enter submits
124
+ * - Multiline mode: Shift+Enter adds newline, Ctrl+Enter submits
125
+ * - Cursor navigation with arrow keys
126
+ * - Placeholder display when empty
127
+ * - Theme-aware styling
128
+ *
129
+ * @example
130
+ * ```tsx
131
+ * // Single-line input
132
+ * <TextInput
133
+ * value={text}
134
+ * onChange={setText}
135
+ * onSubmit={handleSubmit}
136
+ * placeholder="Type a message..."
137
+ * />
138
+ *
139
+ * // Multiline input
140
+ * <TextInput
141
+ * value={text}
142
+ * onChange={setText}
143
+ * onSubmit={handleSubmit}
144
+ * placeholder="Type a message..."
145
+ * multiline
146
+ * />
147
+ * ```
148
+ */
149
+ function TextInputComponent({
150
+ value,
151
+ onChange,
152
+ onSubmit,
153
+ placeholder = "",
154
+ multiline = false,
155
+ disabled = false,
156
+ maxLength,
157
+ focused = true,
158
+ minHeight,
159
+ mask,
160
+ suppressEnter = false,
161
+ suppressTab = false,
162
+ cursorToEnd = false,
163
+ onCursorMoved,
164
+ showBorder = true,
165
+ enableHighlight = false,
166
+ }: TextInputProps) {
167
+ const { theme } = useTheme();
168
+ const { pauseAnimations, resumeAnimations, isVSCode } = useAnimation();
169
+
170
+ // Pause animations when input is focused in VS Code to reduce flickering
171
+ useEffect(() => {
172
+ if (focused && isVSCode) {
173
+ pauseAnimations();
174
+ return () => resumeAnimations();
175
+ }
176
+ }, [focused, isVSCode, pauseAnimations, resumeAnimations]);
177
+
178
+ // Calculate effective min height (default 5 for multiline, 1 for single-line)
179
+ const effectiveMinHeight = minHeight ?? (multiline ? 5 : 1);
180
+
181
+ // Rapid input buffering for paste fallback (when bracketed paste is not available)
182
+ const inputBufferRef = useRef<string>("");
183
+ const inputTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
184
+ // Use shorter threshold for faster input response
185
+ // Reduced from 100/150ms to 50/80ms for improved responsiveness
186
+ const RAPID_INPUT_THRESHOLD = mask ? 80 : 50; // ms - reduced for faster response
187
+
188
+ // Refs to store latest value and cursorPosition for setTimeout callback
189
+ // This avoids closure trap where setTimeout captures stale values
190
+ const valueRef = useRef(value);
191
+ const cursorPositionRef = useRef(0);
192
+
193
+ // Cursor position within the value
194
+ const [cursorPosition, setCursorPosition] = useState(value.length);
195
+
196
+ // Undo/Redo stacks for Ctrl+Z support
197
+ const [undoStack, setUndoStack] = useState<Array<{ value: string; cursor: number }>>([]);
198
+ const [redoStack, setRedoStack] = useState<Array<{ value: string; cursor: number }>>([]);
199
+ const lastValueRef = useRef(value);
200
+
201
+ // Sync cursor position when value changes externally
202
+ useEffect(() => {
203
+ if (cursorPosition > value.length) {
204
+ setCursorPosition(value.length);
205
+ }
206
+ }, [value, cursorPosition]);
207
+
208
+ // Keep valueRef in sync with latest value
209
+ useEffect(() => {
210
+ valueRef.current = value;
211
+ }, [value]);
212
+
213
+ // Keep cursorPositionRef in sync with latest cursorPosition
214
+ useEffect(() => {
215
+ cursorPositionRef.current = cursorPosition;
216
+ }, [cursorPosition]);
217
+
218
+ // Track value changes for undo stack (debounced to group rapid typing)
219
+ const undoTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
220
+ useEffect(() => {
221
+ if (value !== lastValueRef.current) {
222
+ // Debounce undo snapshots - group rapid typing into single undo entry
223
+ if (undoTimerRef.current) {
224
+ clearTimeout(undoTimerRef.current);
225
+ }
226
+ const prevValue = lastValueRef.current;
227
+ const prevCursor = cursorPositionRef.current;
228
+ undoTimerRef.current = setTimeout(() => {
229
+ setUndoStack((prev) => [...prev.slice(-99), { value: prevValue, cursor: prevCursor }]);
230
+ setRedoStack([]); // Clear redo on new changes
231
+ }, 300);
232
+ lastValueRef.current = value;
233
+ }
234
+ return () => {
235
+ if (undoTimerRef.current) {
236
+ clearTimeout(undoTimerRef.current);
237
+ }
238
+ };
239
+ }, [value]);
240
+
241
+ // Handle cursorToEnd prop - move cursor to end when requested
242
+ useEffect(() => {
243
+ if (cursorToEnd) {
244
+ setCursorPosition(value.length);
245
+ onCursorMoved?.();
246
+ }
247
+ }, [cursorToEnd, value.length, onCursorMoved]);
248
+
249
+ // Cleanup input buffer timer on unmount
250
+ useEffect(() => {
251
+ return () => {
252
+ if (inputTimerRef.current) {
253
+ clearTimeout(inputTimerRef.current);
254
+ }
255
+ };
256
+ }, []);
257
+
258
+ /**
259
+ * Handle bracketed paste events.
260
+ * When a paste is detected via bracketed paste mode, the entire
261
+ * pasted content arrives as a single event instead of character-by-character.
262
+ */
263
+ const handlePaste = useCallback(
264
+ (pastedText: string) => {
265
+ if (disabled || !focused) return;
266
+
267
+ // Normalize the pasted text
268
+ const normalizedPaste = normalizeInputValue(pastedText, multiline);
269
+ if (normalizedPaste.length === 0) return;
270
+
271
+ // Insert at cursor position
272
+ let newValue = insertAt(value, cursorPosition, normalizedPaste);
273
+ let newCursorPosition = cursorPosition + normalizedPaste.length;
274
+
275
+ // Handle max length
276
+ if (maxLength !== undefined && newValue.length > maxLength) {
277
+ newValue = newValue.slice(0, maxLength);
278
+ newCursorPosition = Math.min(newCursorPosition, maxLength);
279
+ }
280
+
281
+ onChange(newValue);
282
+ setCursorPosition(newCursorPosition);
283
+ },
284
+ [disabled, focused, multiline, value, cursorPosition, maxLength, onChange]
285
+ );
286
+
287
+ // Subscribe to paste events from the BracketedPasteProvider
288
+ usePasteHandler(handlePaste);
289
+
290
+ /**
291
+ * Handle character input
292
+ */
293
+ const handleInput = useCallback(
294
+ (char: string) => {
295
+ if (disabled) return;
296
+
297
+ const normalizedInput = normalizeInputValue(char, multiline);
298
+ if (normalizedInput.length === 0) {
299
+ return;
300
+ }
301
+
302
+ // Check max length before inserting
303
+ if (maxLength !== undefined && value.length >= maxLength) {
304
+ return;
305
+ }
306
+
307
+ const newValue = insertAt(value, cursorPosition, normalizedInput);
308
+
309
+ // Check max length after insertion (handles paste)
310
+ if (maxLength !== undefined && newValue.length > maxLength) {
311
+ const truncated = newValue.slice(0, maxLength);
312
+ onChange(truncated);
313
+ setCursorPosition(Math.min(cursorPosition + normalizedInput.length, maxLength));
314
+ return;
315
+ }
316
+
317
+ onChange(newValue);
318
+ setCursorPosition(cursorPosition + normalizedInput.length);
319
+ },
320
+ [disabled, value, cursorPosition, maxLength, onChange, multiline]
321
+ );
322
+
323
+ /**
324
+ * Handle backspace key
325
+ */
326
+ const handleBackspace = useCallback(() => {
327
+ if (disabled || cursorPosition === 0) return;
328
+
329
+ const newValue = deleteAt(value, cursorPosition);
330
+ onChange(newValue);
331
+ setCursorPosition(cursorPosition - 1);
332
+ }, [disabled, value, cursorPosition, onChange]);
333
+
334
+ /**
335
+ * Handle delete key
336
+ */
337
+ const handleDelete = useCallback(() => {
338
+ if (disabled || cursorPosition >= value.length) return;
339
+
340
+ const newValue = value.slice(0, cursorPosition) + value.slice(cursorPosition + 1);
341
+ onChange(newValue);
342
+ }, [disabled, value, cursorPosition, onChange]);
343
+
344
+ /**
345
+ * Handle left arrow navigation
346
+ */
347
+ const handleLeftArrow = useCallback(
348
+ (ctrl: boolean) => {
349
+ if (cursorPosition === 0) return;
350
+
351
+ if (ctrl) {
352
+ // Move to previous word boundary
353
+ const beforeCursor = value.slice(0, cursorPosition);
354
+ const match = beforeCursor.match(/\s*\S*$/);
355
+ const jumpLength = match ? match[0].length : 1;
356
+ setCursorPosition(Math.max(0, cursorPosition - jumpLength));
357
+ } else {
358
+ setCursorPosition(cursorPosition - 1);
359
+ }
360
+ },
361
+ [value, cursorPosition]
362
+ );
363
+
364
+ /**
365
+ * Handle right arrow navigation
366
+ */
367
+ const handleRightArrow = useCallback(
368
+ (ctrl: boolean) => {
369
+ if (cursorPosition >= value.length) return;
370
+
371
+ if (ctrl) {
372
+ // Move to next word boundary
373
+ const afterCursor = value.slice(cursorPosition);
374
+ const match = afterCursor.match(/^\S*\s*/);
375
+ const jumpLength = match ? match[0].length : 1;
376
+ setCursorPosition(Math.min(value.length, cursorPosition + jumpLength));
377
+ } else {
378
+ setCursorPosition(cursorPosition + 1);
379
+ }
380
+ },
381
+ [value, cursorPosition]
382
+ );
383
+
384
+ /**
385
+ * Handle up arrow in multiline mode
386
+ */
387
+ const handleUpArrow = useCallback(() => {
388
+ if (!multiline) return;
389
+
390
+ // Find the previous newline
391
+ const beforeCursor = value.slice(0, cursorPosition);
392
+ const lastNewline = beforeCursor.lastIndexOf("\n");
393
+
394
+ if (lastNewline === -1) {
395
+ // No previous line, move to start
396
+ setCursorPosition(0);
397
+ return;
398
+ }
399
+
400
+ // Find the newline before that to get line start
401
+ const lineStart = beforeCursor.lastIndexOf("\n", lastNewline - 1) + 1;
402
+ const columnInCurrentLine = cursorPosition - lastNewline - 1;
403
+ const previousLineLength = lastNewline - lineStart;
404
+
405
+ // Move to same column in previous line (or end of line if shorter)
406
+ const newPosition = lineStart + Math.min(columnInCurrentLine, previousLineLength);
407
+ setCursorPosition(newPosition);
408
+ }, [multiline, value, cursorPosition]);
409
+
410
+ /**
411
+ * Handle down arrow in multiline mode
412
+ */
413
+ const handleDownArrow = useCallback(() => {
414
+ if (!multiline) return;
415
+
416
+ // Find the current line boundaries
417
+ const beforeCursor = value.slice(0, cursorPosition);
418
+ const afterCursor = value.slice(cursorPosition);
419
+
420
+ const currentLineStart = beforeCursor.lastIndexOf("\n") + 1;
421
+ const columnInCurrentLine = cursorPosition - currentLineStart;
422
+
423
+ const nextNewline = afterCursor.indexOf("\n");
424
+ if (nextNewline === -1) {
425
+ // No next line, move to end
426
+ setCursorPosition(value.length);
427
+ return;
428
+ }
429
+
430
+ // Find the line after the next newline
431
+ const nextLineStart = cursorPosition + nextNewline + 1;
432
+ const restAfterNextLine = value.slice(nextLineStart);
433
+ const nextLineEnd = restAfterNextLine.indexOf("\n");
434
+ const nextLineLength = nextLineEnd === -1 ? restAfterNextLine.length : nextLineEnd;
435
+
436
+ // Move to same column in next line (or end of line if shorter)
437
+ const newPosition = nextLineStart + Math.min(columnInCurrentLine, nextLineLength);
438
+ setCursorPosition(newPosition);
439
+ }, [multiline, value, cursorPosition]);
440
+
441
+ /**
442
+ * Calculate current cursor row (0-indexed)
443
+ */
444
+ const cursorRow = useMemo(() => {
445
+ const beforeCursor = value.slice(0, cursorPosition);
446
+ return (beforeCursor.match(/\n/g) || []).length;
447
+ }, [value, cursorPosition]);
448
+
449
+ /**
450
+ * Handle Home key (Ctrl+A) - move to current line start
451
+ */
452
+ const handleHome = useCallback(() => {
453
+ const lines = value.split("\n");
454
+ let pos = 0;
455
+ for (let i = 0; i < cursorRow; i++) {
456
+ const line = lines[i];
457
+ if (line !== undefined) {
458
+ pos += line.length + 1;
459
+ }
460
+ }
461
+ setCursorPosition(pos);
462
+ }, [value, cursorRow]);
463
+
464
+ /**
465
+ * Handle End key (Ctrl+E) - move to current line end
466
+ */
467
+ const handleEnd = useCallback(() => {
468
+ const lines = value.split("\n");
469
+ let pos = 0;
470
+ for (let i = 0; i <= cursorRow; i++) {
471
+ const line = lines[i];
472
+ if (line !== undefined) {
473
+ pos += line.length + (i < cursorRow ? 1 : 0);
474
+ }
475
+ }
476
+ setCursorPosition(pos);
477
+ }, [value, cursorRow]);
478
+
479
+ /**
480
+ * Handle Ctrl+K - kill (delete) from cursor to end of line
481
+ */
482
+ const handleKillToEnd = useCallback(() => {
483
+ if (disabled) return;
484
+ const lines = value.split("\n");
485
+ const currentLine = lines[cursorRow];
486
+ if (currentLine === undefined) return;
487
+ const lineStart = lines.slice(0, cursorRow).join("\n").length + (cursorRow > 0 ? 1 : 0);
488
+ const lineEnd = lineStart + currentLine.length;
489
+ const newValue = value.slice(0, cursorPosition) + value.slice(lineEnd);
490
+ onChange(newValue);
491
+ }, [disabled, value, cursorPosition, cursorRow, onChange]);
492
+
493
+ /**
494
+ * Handle Ctrl+U - kill (delete) from cursor to start of line
495
+ */
496
+ const handleKillToStart = useCallback(() => {
497
+ if (disabled) return;
498
+ const lines = value.split("\n");
499
+ const lineStart = lines.slice(0, cursorRow).join("\n").length + (cursorRow > 0 ? 1 : 0);
500
+ const newValue = value.slice(0, lineStart) + value.slice(cursorPosition);
501
+ onChange(newValue);
502
+ setCursorPosition(lineStart);
503
+ }, [disabled, value, cursorPosition, cursorRow, onChange]);
504
+
505
+ /**
506
+ * Handle Ctrl+W - delete word backward
507
+ */
508
+ const handleDeleteWordBackward = useCallback(() => {
509
+ if (disabled || cursorPosition === 0) return;
510
+
511
+ // Find the start of the previous word
512
+ let pos = cursorPosition - 1;
513
+ // Skip whitespace
514
+ while (pos > 0 && /\s/.test(value.charAt(pos))) pos--;
515
+ // Skip word characters
516
+ while (pos > 0 && !/\s/.test(value.charAt(pos - 1))) pos--;
517
+
518
+ const newValue = value.slice(0, pos) + value.slice(cursorPosition);
519
+ onChange(newValue);
520
+ setCursorPosition(pos);
521
+ }, [disabled, value, cursorPosition, onChange]);
522
+
523
+ /**
524
+ * Handle Ctrl+Z - undo
525
+ */
526
+ const handleUndo = useCallback(() => {
527
+ const prev = undoStack[undoStack.length - 1];
528
+ if (!prev) return;
529
+ setUndoStack((s) => s.slice(0, -1));
530
+ setRedoStack((s) => [...s, { value, cursor: cursorPosition }]);
531
+ onChange(prev.value);
532
+ setCursorPosition(prev.cursor);
533
+ lastValueRef.current = prev.value; // Prevent re-adding to undo stack
534
+ }, [undoStack, value, cursorPosition, onChange]);
535
+
536
+ /**
537
+ * Handle Ctrl+Shift+Z / Ctrl+Y - redo
538
+ */
539
+ const handleRedo = useCallback(() => {
540
+ const next = redoStack[redoStack.length - 1];
541
+ if (!next) return;
542
+ setRedoStack((s) => s.slice(0, -1));
543
+ setUndoStack((s) => [...s, { value, cursor: cursorPosition }]);
544
+ onChange(next.value);
545
+ setCursorPosition(next.cursor);
546
+ lastValueRef.current = next.value; // Prevent re-adding to undo stack
547
+ }, [redoStack, value, cursorPosition, onChange]);
548
+
549
+ /**
550
+ * Handle submission
551
+ */
552
+ const handleSubmit = useCallback(() => {
553
+ if (disabled) return;
554
+ onSubmit?.(value);
555
+ }, [disabled, value, onSubmit]);
556
+
557
+ /**
558
+ * Handle newline in multiline mode
559
+ */
560
+ const handleNewline = useCallback(() => {
561
+ if (disabled || !multiline) return;
562
+
563
+ // Check max length before inserting newline
564
+ if (maxLength !== undefined && value.length >= maxLength) {
565
+ return;
566
+ }
567
+
568
+ const newValue = insertAt(value, cursorPosition, "\n");
569
+ onChange(newValue);
570
+ setCursorPosition(cursorPosition + 1);
571
+ }, [disabled, multiline, value, cursorPosition, maxLength, onChange]);
572
+
573
+ /**
574
+ * Handle return/enter key based on mode
575
+ * - Shift+Enter: Always inserts newline (even in single-line mode for multiline contexts)
576
+ * - Enter (multiline): Inserts newline
577
+ * - Ctrl+Enter (multiline): Submits
578
+ * - Enter (single-line): Submits
579
+ */
580
+ const handleReturn = useCallback(
581
+ (ctrl: boolean, shift: boolean): boolean => {
582
+ // Skip if Enter should be suppressed (e.g., autocomplete is active)
583
+ // Check prop directly for synchronous behavior - state-based check was racy
584
+ if (suppressEnter) {
585
+ return false; // Let parent handle it
586
+ }
587
+ // Shift+Enter always inserts newline (multiline mode required)
588
+ if (shift && multiline) {
589
+ handleNewline();
590
+ return true;
591
+ }
592
+ if (multiline && !ctrl) {
593
+ handleNewline();
594
+ } else {
595
+ handleSubmit();
596
+ }
597
+ return true;
598
+ },
599
+ [multiline, handleNewline, handleSubmit, suppressEnter]
600
+ );
601
+
602
+ /**
603
+ * Handle tab key (multiline only)
604
+ * Returns true if key was handled, false to pass through
605
+ */
606
+ const handleTab = useCallback((): boolean => {
607
+ // Skip if Tab should be suppressed (e.g., autocomplete is active)
608
+ if (suppressTab) {
609
+ return false; // Let parent handle it
610
+ }
611
+ if (multiline) {
612
+ handleInput(" ");
613
+ }
614
+ return true;
615
+ }, [multiline, handleInput, suppressTab]);
616
+
617
+ /**
618
+ * Process a key event and dispatch to appropriate handler.
619
+ * Returns true if the key was handled.
620
+ */
621
+ const processKeyEvent = useCallback(
622
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Key event handler with many key combinations
623
+ (input: string, key: Key): boolean => {
624
+ // Navigation and editing keys
625
+ if (key.backspace) {
626
+ // Ctrl+Backspace: delete word backward
627
+ if (key.ctrl) {
628
+ handleDeleteWordBackward();
629
+ } else {
630
+ handleBackspace();
631
+ }
632
+ return true;
633
+ }
634
+ if (key.delete) {
635
+ handleDelete();
636
+ return true;
637
+ }
638
+ if (key.leftArrow) {
639
+ handleLeftArrow(key.ctrl);
640
+ return true;
641
+ }
642
+ if (key.rightArrow) {
643
+ handleRightArrow(key.ctrl);
644
+ return true;
645
+ }
646
+ if (key.upArrow) {
647
+ handleUpArrow();
648
+ return true;
649
+ }
650
+ if (key.downArrow) {
651
+ handleDownArrow();
652
+ return true;
653
+ }
654
+ if (key.return) {
655
+ return handleReturn(key.ctrl, key.shift);
656
+ }
657
+ if (key.tab) {
658
+ return handleTab();
659
+ }
660
+ if (key.escape) {
661
+ return true;
662
+ }
663
+
664
+ // Emacs-style keybindings (Ctrl+key)
665
+ if (key.ctrl) {
666
+ switch (input.toLowerCase()) {
667
+ case "a": // Ctrl+A: Home (line start)
668
+ handleHome();
669
+ return true;
670
+ case "e": // Ctrl+E: End (line end)
671
+ handleEnd();
672
+ return true;
673
+ case "k": // Ctrl+K: Kill to end of line
674
+ handleKillToEnd();
675
+ return true;
676
+ case "u": // Ctrl+U: Kill to start of line
677
+ handleKillToStart();
678
+ return true;
679
+ case "w": // Ctrl+W: Delete word backward
680
+ handleDeleteWordBackward();
681
+ return true;
682
+ case "z": // Ctrl+Z: Undo, Ctrl+Shift+Z: Redo
683
+ if (key.shift) {
684
+ handleRedo();
685
+ } else {
686
+ handleUndo();
687
+ }
688
+ return true;
689
+ case "y": // Ctrl+Y: Redo (alternative)
690
+ handleRedo();
691
+ return true;
692
+ }
693
+ }
694
+
695
+ return false;
696
+ },
697
+ [
698
+ handleBackspace,
699
+ handleDelete,
700
+ handleLeftArrow,
701
+ handleRightArrow,
702
+ handleUpArrow,
703
+ handleDownArrow,
704
+ handleReturn,
705
+ handleTab,
706
+ handleHome,
707
+ handleEnd,
708
+ handleKillToEnd,
709
+ handleKillToStart,
710
+ handleDeleteWordBackward,
711
+ handleUndo,
712
+ handleRedo,
713
+ ]
714
+ );
715
+
716
+ // Handle keyboard input with immediate display for single chars, buffering for paste
717
+ useInput(
718
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Keyboard input handler with many key combinations
719
+ (input, key) => {
720
+ if (disabled) return;
721
+
722
+ // Try to process as a special key
723
+ if (processKeyEvent(input, key)) {
724
+ return;
725
+ }
726
+
727
+ // Handle regular character input
728
+ // Strategy: Single chars ALWAYS display immediately, multi-char inputs (paste) use buffering
729
+ if (input && !key.ctrl && !key.meta) {
730
+ const normalizedInput = normalizeInputValue(input, multiline);
731
+ if (normalizedInput.length === 0) return;
732
+
733
+ // Single character: ALWAYS process immediately for responsive typing
734
+ // Multi-char (paste): use buffering to batch operations
735
+ const isSingleChar = normalizedInput.length === 1;
736
+
737
+ if (isSingleChar) {
738
+ // Clear any pending buffer timer - single chars take priority
739
+ if (inputTimerRef.current) {
740
+ clearTimeout(inputTimerRef.current);
741
+ inputTimerRef.current = null;
742
+ }
743
+ // Flush any buffered content first
744
+ if (inputBufferRef.current.length > 0) {
745
+ const buffered = inputBufferRef.current;
746
+ inputBufferRef.current = "";
747
+ handleInput(buffered);
748
+ }
749
+ // Immediate processing - no buffering delay
750
+ const currentValue = valueRef.current;
751
+ const currentCursorPosition = cursorPositionRef.current;
752
+
753
+ // Check max length before inserting
754
+ if (maxLength !== undefined && currentValue.length >= maxLength) {
755
+ return;
756
+ }
757
+
758
+ // Insert character at cursor position
759
+ let newValue = insertAt(currentValue, currentCursorPosition, normalizedInput);
760
+
761
+ // Handle max length after insertion
762
+ if (maxLength !== undefined && newValue.length > maxLength) {
763
+ newValue = newValue.slice(0, maxLength);
764
+ }
765
+
766
+ const newPosition = Math.min(currentCursorPosition + 1, newValue.length);
767
+ // Update value first, then cursor position in low-priority transition
768
+ // This batches both updates in React 18+ automatic batching
769
+ onChange(newValue);
770
+ startTransition(() => {
771
+ setCursorPosition(newPosition);
772
+ });
773
+ } else if (normalizedInput.length > 1) {
774
+ // Multi-character input (paste) only - use buffering to batch paste operations
775
+ inputBufferRef.current += normalizedInput;
776
+
777
+ if (inputTimerRef.current) {
778
+ clearTimeout(inputTimerRef.current);
779
+ }
780
+
781
+ inputTimerRef.current = setTimeout(() => {
782
+ const buffered = inputBufferRef.current;
783
+ inputBufferRef.current = "";
784
+
785
+ if (buffered.length === 0) return;
786
+
787
+ // Use refs to get latest values, avoiding closure trap
788
+ const currentValue = valueRef.current;
789
+ const currentCursorPosition = cursorPositionRef.current;
790
+
791
+ // Check max length before inserting
792
+ if (maxLength !== undefined && currentValue.length >= maxLength) {
793
+ return;
794
+ }
795
+
796
+ // Insert buffered content at cursor position
797
+ let newValue = insertAt(currentValue, currentCursorPosition, buffered);
798
+
799
+ // Handle max length after insertion
800
+ if (maxLength !== undefined && newValue.length > maxLength) {
801
+ newValue = newValue.slice(0, maxLength);
802
+ }
803
+
804
+ const newPosition = Math.min(currentCursorPosition + buffered.length, newValue.length);
805
+ // Use startTransition for cursor update to reduce flickering during paste
806
+ startTransition(() => {
807
+ setCursorPosition(newPosition);
808
+ });
809
+ onChange(newValue);
810
+ }, RAPID_INPUT_THRESHOLD);
811
+ }
812
+ }
813
+ },
814
+ { isActive: focused && !disabled }
815
+ );
816
+
817
+ // ==========================================================================
818
+ // Rendering
819
+ // ==========================================================================
820
+
821
+ const isEmpty = value.length === 0;
822
+ const showPlaceholder = isEmpty && placeholder;
823
+ const displayValue = mask ? value.replace(/[\s\S]/g, mask) : value;
824
+
825
+ // Split value into lines for multiline display
826
+ const lines = multiline ? displayValue.split("\n") : [displayValue];
827
+
828
+ // Pre-calculate line start positions for stable keys and rendering
829
+ const lineData = useMemo(() => {
830
+ let pos = 0;
831
+ return lines.map((line, index) => {
832
+ const startPos = pos;
833
+ pos += line.length + 1; // +1 for newline
834
+ return { line, index, startPos, key: getLineKey(startPos) };
835
+ });
836
+ }, [lines]);
837
+
838
+ // Input highlighting (disabled when masking)
839
+ const shouldHighlight = enableHighlight && !mask;
840
+
841
+ /**
842
+ * Render a line with cursor indicator (and optional highlighting)
843
+ */
844
+ const renderLineWithCursor = (line: string, lineStartPosition: number) => {
845
+ const lineEndPosition = lineStartPosition + line.length;
846
+ const cursorInLine = cursorPosition >= lineStartPosition && cursorPosition <= lineEndPosition;
847
+
848
+ // Use HighlightedText when highlighting is enabled
849
+ if (shouldHighlight) {
850
+ return (
851
+ <HighlightedText
852
+ text={line || " "}
853
+ cursorPosition={cursorPosition}
854
+ showCursor={cursorInLine && focused}
855
+ lineStartPosition={lineStartPosition}
856
+ />
857
+ );
858
+ }
859
+
860
+ // Default rendering without highlighting
861
+ if (!cursorInLine || !focused) {
862
+ return <Text>{line || " "}</Text>;
863
+ }
864
+
865
+ const cursorCol = cursorPosition - lineStartPosition;
866
+ const beforeCursor = line.slice(0, cursorCol);
867
+ const cursorChar = line[cursorCol] || " ";
868
+ const afterCursor = line.slice(cursorCol + 1);
869
+
870
+ return (
871
+ <Text>
872
+ {beforeCursor || null}
873
+ <Text inverse>{cursorChar}</Text>
874
+ {afterCursor || null}
875
+ </Text>
876
+ );
877
+ };
878
+
879
+ // Border color based on focus state
880
+ const borderColor = focused ? theme.semantic.border.focus : theme.semantic.border.default;
881
+
882
+ // Calculate visual line count considering terminal width wrapping
883
+ const terminalWidth = process.stdout.columns || 80;
884
+ const contentWidth = Math.max(1, terminalWidth - 4); // Account for border (2) + paddingX (1 each side)
885
+
886
+ // Sum visual lines for each logical line (accounts for wrapping)
887
+ const visualLineCount = lineData.reduce((total, { line }) => {
888
+ if (!line) return total + 1;
889
+ // Unicode-aware length for proper CJK/emoji handling
890
+ const lineLength = [...line].length;
891
+ const wrappedLines = Math.ceil(lineLength / contentWidth) || 1;
892
+ return total + wrappedLines;
893
+ }, 0);
894
+
895
+ // Calculate padding lines needed to meet minHeight
896
+ const paddingLinesNeeded = Math.max(0, effectiveMinHeight - visualLineCount);
897
+
898
+ // Render placeholder
899
+ if (showPlaceholder) {
900
+ // Calculate empty lines needed for placeholder (account for the placeholder line itself)
901
+ const emptyLinesForPlaceholder = Math.max(0, effectiveMinHeight - 1);
902
+ return (
903
+ <Box
904
+ flexDirection="column"
905
+ borderStyle="round"
906
+ borderColor={borderColor}
907
+ paddingX={1}
908
+ minHeight={effectiveMinHeight + 2} // +2 for top/bottom border
909
+ >
910
+ <Box flexDirection="row">
911
+ <Text color={theme.semantic.text.primary}>{"❯ "}</Text>
912
+ <Text color={theme.semantic.text.muted}>
913
+ {focused ? (
914
+ <>
915
+ <Text inverse>{placeholder[0] || " "}</Text>
916
+ {placeholder.slice(1)}
917
+ </>
918
+ ) : (
919
+ placeholder
920
+ )}
921
+ </Text>
922
+ </Box>
923
+ {/* Add empty lines to maintain minHeight - these are static decorative elements */}
924
+ {Array.from({ length: emptyLinesForPlaceholder }).map((_, i) => (
925
+ // biome-ignore lint/suspicious/noArrayIndexKey: Static placeholder lines don't reorder
926
+ <Text key={`empty-${i}`}> </Text>
927
+ ))}
928
+ </Box>
929
+ );
930
+ }
931
+
932
+ // Render single-line
933
+ if (!multiline) {
934
+ const content = renderLineWithCursor(displayValue, 0);
935
+ if (!showBorder) {
936
+ return (
937
+ <Box flexDirection="row">
938
+ <Text color={theme.semantic.text.primary}>{"❯ "}</Text>
939
+ {content}
940
+ </Box>
941
+ );
942
+ }
943
+ return (
944
+ <Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="row">
945
+ <Text color={theme.semantic.text.primary}>{"❯ "}</Text>
946
+ <Box flexGrow={1}>{content}</Box>
947
+ </Box>
948
+ );
949
+ }
950
+
951
+ // Render multiline with stable keys based on line position
952
+ return (
953
+ <Box
954
+ flexDirection="column"
955
+ borderStyle="round"
956
+ borderColor={borderColor}
957
+ paddingX={1}
958
+ minHeight={effectiveMinHeight + 2} // +2 for top/bottom border
959
+ >
960
+ {lineData.map(({ line, startPos, key }, index) => (
961
+ <Box key={key} flexDirection="row">
962
+ {/* Show prompt prefix on first line only */}
963
+ {index === 0 ? (
964
+ <Text color={theme.semantic.text.primary}>{"❯ "}</Text>
965
+ ) : (
966
+ <Text>{" "}</Text>
967
+ )}
968
+ <Box flexGrow={1}>{renderLineWithCursor(line, startPos)}</Box>
969
+ </Box>
970
+ ))}
971
+ {/* Add empty lines to maintain minHeight - these are static decorative elements */}
972
+ {Array.from({ length: paddingLinesNeeded }).map((_, i) => (
973
+ // biome-ignore lint/suspicious/noArrayIndexKey: Static padding lines don't reorder
974
+ <Text key={`padding-${i}`}> </Text>
975
+ ))}
976
+ </Box>
977
+ );
978
+ }
979
+
980
+ /**
981
+ * Memoized TextInput to prevent unnecessary re-renders.
982
+ * Custom comparison checks key props that affect visual output.
983
+ */
984
+ export const TextInput = memo(TextInputComponent, (prevProps, nextProps) => {
985
+ // Return true if props are equal (skip render)
986
+ return (
987
+ prevProps.value === nextProps.value &&
988
+ prevProps.focused === nextProps.focused &&
989
+ prevProps.disabled === nextProps.disabled &&
990
+ prevProps.mask === nextProps.mask &&
991
+ prevProps.placeholder === nextProps.placeholder &&
992
+ prevProps.suppressEnter === nextProps.suppressEnter &&
993
+ prevProps.cursorToEnd === nextProps.cursorToEnd &&
994
+ prevProps.multiline === nextProps.multiline &&
995
+ prevProps.maxLength === nextProps.maxLength &&
996
+ prevProps.minHeight === nextProps.minHeight &&
997
+ prevProps.showBorder === nextProps.showBorder &&
998
+ prevProps.enableHighlight === nextProps.enableHighlight
999
+ );
1000
+ });
1001
+
1002
+ export default TextInput;