fss-link 1.0.49 → 1.0.51

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 (419) hide show
  1. package/dist/index.js +0 -0
  2. package/dist/package.json +2 -2
  3. package/dist/src/config/auth.js +8 -5
  4. package/dist/src/config/auth.js.map +1 -1
  5. package/dist/src/config/database.d.ts +103 -11
  6. package/dist/src/config/database.js +301 -59
  7. package/dist/src/config/database.js.map +1 -1
  8. package/dist/src/config/databaseBackup.d.ts +114 -0
  9. package/dist/src/config/databaseBackup.js +334 -0
  10. package/dist/src/config/databaseBackup.js.map +1 -0
  11. package/dist/src/config/databaseMigrations.d.ts +63 -0
  12. package/dist/src/config/databaseMigrations.js +379 -0
  13. package/dist/src/config/databaseMigrations.js.map +1 -0
  14. package/dist/src/config/databasePool.d.ts +70 -0
  15. package/dist/src/config/databasePool.js +193 -0
  16. package/dist/src/config/databasePool.js.map +1 -0
  17. package/dist/src/config/queryOptimizer.d.ts +127 -0
  18. package/dist/src/config/queryOptimizer.js +309 -0
  19. package/dist/src/config/queryOptimizer.js.map +1 -0
  20. package/dist/src/utils/sandbox.js +2 -8
  21. package/dist/src/utils/sandbox.js.map +1 -1
  22. package/dist/src/validateNonInterActiveAuth.js +3 -7
  23. package/dist/src/validateNonInterActiveAuth.js.map +1 -1
  24. package/dist/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +2 -2
  26. package/dist/commands/mcp/add.test.ts +0 -122
  27. package/dist/commands/mcp/add.ts +0 -222
  28. package/dist/commands/mcp/list.test.ts +0 -154
  29. package/dist/commands/mcp/list.ts +0 -139
  30. package/dist/commands/mcp/remove.test.ts +0 -69
  31. package/dist/commands/mcp/remove.ts +0 -60
  32. package/dist/commands/mcp.test.ts +0 -55
  33. package/dist/commands/mcp.ts +0 -27
  34. package/dist/config/apiValidation.test.ts +0 -118
  35. package/dist/config/auth.test.ts +0 -79
  36. package/dist/config/auth.ts +0 -100
  37. package/dist/config/config.integration.test.ts +0 -407
  38. package/dist/config/config.test.ts +0 -1952
  39. package/dist/config/config.ts +0 -690
  40. package/dist/config/database.test.ts +0 -96
  41. package/dist/config/database.ts +0 -824
  42. package/dist/config/extension.test.ts +0 -236
  43. package/dist/config/extension.ts +0 -180
  44. package/dist/config/keyBindings.test.ts +0 -62
  45. package/dist/config/keyBindings.ts +0 -184
  46. package/dist/config/modelManager.ts +0 -326
  47. package/dist/config/providerManager.ts +0 -244
  48. package/dist/config/providerPersistence.test.ts +0 -377
  49. package/dist/config/providerPersistence.ts +0 -105
  50. package/dist/config/sandboxConfig.ts +0 -107
  51. package/dist/config/settings.test.ts +0 -1424
  52. package/dist/config/settings.ts +0 -517
  53. package/dist/config/settingsSchema.test.ts +0 -252
  54. package/dist/config/settingsSchema.ts +0 -728
  55. package/dist/config/trustedFolders.test.ts +0 -208
  56. package/dist/config/trustedFolders.ts +0 -167
  57. package/dist/gemini.test.tsx +0 -252
  58. package/dist/gemini.tsx +0 -357
  59. package/dist/generated/git-commit.ts +0 -10
  60. package/dist/index.ts +0 -21
  61. package/dist/nonInteractiveCli.test.ts +0 -276
  62. package/dist/nonInteractiveCli.ts +0 -143
  63. package/dist/patches/is-in-ci.ts +0 -17
  64. package/dist/services/BuiltinCommandLoader.test.ts +0 -127
  65. package/dist/services/BuiltinCommandLoader.ts +0 -95
  66. package/dist/services/CommandService.test.ts +0 -352
  67. package/dist/services/CommandService.ts +0 -103
  68. package/dist/services/FileCommandLoader.test.ts +0 -1002
  69. package/dist/services/FileCommandLoader.ts +0 -289
  70. package/dist/services/McpPromptLoader.ts +0 -231
  71. package/dist/services/SearchEngineConfigProvider.ts +0 -100
  72. package/dist/services/prompt-processors/argumentProcessor.test.ts +0 -41
  73. package/dist/services/prompt-processors/argumentProcessor.ts +0 -23
  74. package/dist/services/prompt-processors/shellProcessor.test.ts +0 -709
  75. package/dist/services/prompt-processors/shellProcessor.ts +0 -248
  76. package/dist/services/prompt-processors/types.ts +0 -44
  77. package/dist/services/types.ts +0 -24
  78. package/dist/src/config/apiValidation.test.d.ts +0 -6
  79. package/dist/src/config/apiValidation.test.js +0 -99
  80. package/dist/src/config/apiValidation.test.js.map +0 -1
  81. package/dist/src/config/database.test.d.ts +0 -6
  82. package/dist/src/config/database.test.js +0 -80
  83. package/dist/src/config/database.test.js.map +0 -1
  84. package/dist/src/config/providerManager.d.ts +0 -74
  85. package/dist/src/config/providerManager.js +0 -203
  86. package/dist/src/config/providerManager.js.map +0 -1
  87. package/dist/src/config/providerPersistence.test.d.ts +0 -6
  88. package/dist/src/config/providerPersistence.test.js +0 -283
  89. package/dist/src/config/providerPersistence.test.js.map +0 -1
  90. package/dist/src/ui/components/GeminiKeyDialog.d.ts +0 -11
  91. package/dist/src/ui/components/GeminiKeyDialog.js +0 -156
  92. package/dist/src/ui/components/GeminiKeyDialog.js.map +0 -1
  93. package/dist/src/ui/components/OpenAIEndpointDialog.d.ts +0 -19
  94. package/dist/src/ui/components/OpenAIEndpointDialog.js +0 -163
  95. package/dist/src/ui/components/OpenAIEndpointDialog.js.map +0 -1
  96. package/dist/test-setup.ts +0 -12
  97. package/dist/test-utils/customMatchers.ts +0 -65
  98. package/dist/test-utils/mockCommandContext.test.ts +0 -62
  99. package/dist/test-utils/mockCommandContext.ts +0 -105
  100. package/dist/test-utils/render.tsx +0 -18
  101. package/dist/ui/App.test.tsx +0 -2181
  102. package/dist/ui/App.tsx +0 -1344
  103. package/dist/ui/IdeIntegrationNudge.tsx +0 -98
  104. package/dist/ui/__snapshots__/App.test.tsx.snap +0 -124
  105. package/dist/ui/colors.ts +0 -56
  106. package/dist/ui/commands/aboutCommand.test.ts +0 -153
  107. package/dist/ui/commands/aboutCommand.ts +0 -49
  108. package/dist/ui/commands/authCommand.test.ts +0 -36
  109. package/dist/ui/commands/authCommand.ts +0 -17
  110. package/dist/ui/commands/bugCommand.test.ts +0 -114
  111. package/dist/ui/commands/bugCommand.ts +0 -92
  112. package/dist/ui/commands/chatCommand.test.ts +0 -414
  113. package/dist/ui/commands/chatCommand.ts +0 -280
  114. package/dist/ui/commands/clearCommand.test.ts +0 -100
  115. package/dist/ui/commands/clearCommand.ts +0 -29
  116. package/dist/ui/commands/compressCommand.test.ts +0 -129
  117. package/dist/ui/commands/compressCommand.ts +0 -78
  118. package/dist/ui/commands/contextCommand.ts +0 -132
  119. package/dist/ui/commands/copyCommand.test.ts +0 -296
  120. package/dist/ui/commands/copyCommand.ts +0 -67
  121. package/dist/ui/commands/corgiCommand.test.ts +0 -34
  122. package/dist/ui/commands/corgiCommand.ts +0 -16
  123. package/dist/ui/commands/directoryCommand.test.tsx +0 -185
  124. package/dist/ui/commands/directoryCommand.tsx +0 -179
  125. package/dist/ui/commands/docsCommand.test.ts +0 -99
  126. package/dist/ui/commands/docsCommand.ts +0 -42
  127. package/dist/ui/commands/editorCommand.test.ts +0 -30
  128. package/dist/ui/commands/editorCommand.ts +0 -21
  129. package/dist/ui/commands/extensionsCommand.test.ts +0 -67
  130. package/dist/ui/commands/extensionsCommand.ts +0 -46
  131. package/dist/ui/commands/helpCommand.test.ts +0 -52
  132. package/dist/ui/commands/helpCommand.ts +0 -23
  133. package/dist/ui/commands/ideCommand.test.ts +0 -255
  134. package/dist/ui/commands/ideCommand.ts +0 -283
  135. package/dist/ui/commands/initCommand.test.ts +0 -127
  136. package/dist/ui/commands/initCommand.ts +0 -117
  137. package/dist/ui/commands/mcpCommand.test.ts +0 -1057
  138. package/dist/ui/commands/mcpCommand.ts +0 -531
  139. package/dist/ui/commands/memoryCommand.test.ts +0 -344
  140. package/dist/ui/commands/memoryCommand.ts +0 -305
  141. package/dist/ui/commands/privacyCommand.test.ts +0 -38
  142. package/dist/ui/commands/privacyCommand.ts +0 -17
  143. package/dist/ui/commands/quitCommand.test.ts +0 -55
  144. package/dist/ui/commands/quitCommand.ts +0 -36
  145. package/dist/ui/commands/restoreCommand.test.ts +0 -250
  146. package/dist/ui/commands/restoreCommand.ts +0 -157
  147. package/dist/ui/commands/searchEngineSetupCommand.ts +0 -18
  148. package/dist/ui/commands/settingsCommand.test.ts +0 -36
  149. package/dist/ui/commands/settingsCommand.ts +0 -17
  150. package/dist/ui/commands/setupGithubCommand.test.ts +0 -238
  151. package/dist/ui/commands/setupGithubCommand.ts +0 -212
  152. package/dist/ui/commands/speakCommand.ts +0 -175
  153. package/dist/ui/commands/statsCommand.test.ts +0 -78
  154. package/dist/ui/commands/statsCommand.ts +0 -70
  155. package/dist/ui/commands/terminalSetupCommand.test.ts +0 -85
  156. package/dist/ui/commands/terminalSetupCommand.ts +0 -45
  157. package/dist/ui/commands/themeCommand.test.ts +0 -38
  158. package/dist/ui/commands/themeCommand.ts +0 -17
  159. package/dist/ui/commands/toolsCommand.test.ts +0 -105
  160. package/dist/ui/commands/toolsCommand.ts +0 -71
  161. package/dist/ui/commands/ttsCommand.ts +0 -143
  162. package/dist/ui/commands/types.ts +0 -204
  163. package/dist/ui/commands/vimCommand.ts +0 -25
  164. package/dist/ui/commands/voiceCommand.ts +0 -125
  165. package/dist/ui/components/AboutBox.tsx +0 -133
  166. package/dist/ui/components/AsciiArt.ts +0 -54
  167. package/dist/ui/components/AuthDialog.test.tsx +0 -334
  168. package/dist/ui/components/AuthDialog.tsx +0 -289
  169. package/dist/ui/components/AuthInProgress.tsx +0 -62
  170. package/dist/ui/components/AutoAcceptIndicator.tsx +0 -47
  171. package/dist/ui/components/ConsoleSummaryDisplay.tsx +0 -35
  172. package/dist/ui/components/ContextSummaryDisplay.test.tsx +0 -85
  173. package/dist/ui/components/ContextSummaryDisplay.tsx +0 -120
  174. package/dist/ui/components/ContextUsageDisplay.tsx +0 -77
  175. package/dist/ui/components/DebugProfiler.tsx +0 -36
  176. package/dist/ui/components/DetailedMessagesDisplay.tsx +0 -82
  177. package/dist/ui/components/EditorSettingsDialog.tsx +0 -172
  178. package/dist/ui/components/FolderTrustDialog.test.tsx +0 -36
  179. package/dist/ui/components/FolderTrustDialog.tsx +0 -74
  180. package/dist/ui/components/Footer.test.tsx +0 -159
  181. package/dist/ui/components/Footer.tsx +0 -158
  182. package/dist/ui/components/GeminiKeyDialog.tsx +0 -252
  183. package/dist/ui/components/GeminiRespondingSpinner.tsx +0 -34
  184. package/dist/ui/components/Header.test.tsx +0 -44
  185. package/dist/ui/components/Header.tsx +0 -70
  186. package/dist/ui/components/Help.tsx +0 -174
  187. package/dist/ui/components/HistoryItemDisplay.test.tsx +0 -125
  188. package/dist/ui/components/HistoryItemDisplay.tsx +0 -98
  189. package/dist/ui/components/InputPrompt.test.tsx +0 -1467
  190. package/dist/ui/components/InputPrompt.tsx +0 -641
  191. package/dist/ui/components/LMStudioModelPrompt.tsx +0 -215
  192. package/dist/ui/components/LoadingIndicator.test.tsx +0 -296
  193. package/dist/ui/components/LoadingIndicator.tsx +0 -82
  194. package/dist/ui/components/MemoryUsageDisplay.tsx +0 -36
  195. package/dist/ui/components/ModelStatsDisplay.test.tsx +0 -252
  196. package/dist/ui/components/ModelStatsDisplay.tsx +0 -197
  197. package/dist/ui/components/OllamaModelPrompt.tsx +0 -206
  198. package/dist/ui/components/OpenAIEndpointDialog.tsx +0 -261
  199. package/dist/ui/components/OpenAIKeyPrompt.test.tsx +0 -64
  200. package/dist/ui/components/OpenAIKeyPrompt.tsx +0 -197
  201. package/dist/ui/components/PrepareLabel.tsx +0 -48
  202. package/dist/ui/components/SearchEngineConfigDialog.tsx +0 -280
  203. package/dist/ui/components/SessionSummaryDisplay.test.tsx +0 -75
  204. package/dist/ui/components/SessionSummaryDisplay.tsx +0 -18
  205. package/dist/ui/components/SettingsDialog.test.tsx +0 -865
  206. package/dist/ui/components/SettingsDialog.tsx +0 -753
  207. package/dist/ui/components/ShellConfirmationDialog.test.tsx +0 -53
  208. package/dist/ui/components/ShellConfirmationDialog.tsx +0 -103
  209. package/dist/ui/components/ShellModeIndicator.tsx +0 -18
  210. package/dist/ui/components/ShowMoreLines.tsx +0 -40
  211. package/dist/ui/components/StatsDisplay.test.tsx +0 -401
  212. package/dist/ui/components/StatsDisplay.tsx +0 -273
  213. package/dist/ui/components/SuggestionsDisplay.tsx +0 -102
  214. package/dist/ui/components/ThemeDialog.tsx +0 -310
  215. package/dist/ui/components/Tips.tsx +0 -45
  216. package/dist/ui/components/TodoDisplay.test.tsx +0 -97
  217. package/dist/ui/components/TodoDisplay.tsx +0 -72
  218. package/dist/ui/components/ToolStatsDisplay.test.tsx +0 -180
  219. package/dist/ui/components/ToolStatsDisplay.tsx +0 -208
  220. package/dist/ui/components/UpdateNotification.tsx +0 -23
  221. package/dist/ui/components/WelcomeBackDialog.tsx +0 -290
  222. package/dist/ui/components/__snapshots__/IDEContextDetailDisplay.test.tsx.snap +0 -24
  223. package/dist/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap +0 -121
  224. package/dist/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap +0 -30
  225. package/dist/ui/components/__snapshots__/ShellConfirmationDialog.test.tsx.snap +0 -21
  226. package/dist/ui/components/__snapshots__/StatsDisplay.test.tsx.snap +0 -264
  227. package/dist/ui/components/__snapshots__/ToolStatsDisplay.test.tsx.snap +0 -91
  228. package/dist/ui/components/messages/CompressionMessage.tsx +0 -49
  229. package/dist/ui/components/messages/DiffRenderer.test.tsx +0 -365
  230. package/dist/ui/components/messages/DiffRenderer.tsx +0 -358
  231. package/dist/ui/components/messages/ErrorMessage.tsx +0 -31
  232. package/dist/ui/components/messages/GeminiMessage.tsx +0 -43
  233. package/dist/ui/components/messages/GeminiMessageContent.tsx +0 -43
  234. package/dist/ui/components/messages/InfoMessage.tsx +0 -32
  235. package/dist/ui/components/messages/ToolConfirmationMessage.test.tsx +0 -58
  236. package/dist/ui/components/messages/ToolConfirmationMessage.tsx +0 -297
  237. package/dist/ui/components/messages/ToolGroupMessage.tsx +0 -126
  238. package/dist/ui/components/messages/ToolMessage.test.tsx +0 -183
  239. package/dist/ui/components/messages/ToolMessage.tsx +0 -296
  240. package/dist/ui/components/messages/UserMessage.tsx +0 -43
  241. package/dist/ui/components/messages/UserShellMessage.tsx +0 -25
  242. package/dist/ui/components/shared/MaxSizedBox.test.tsx +0 -425
  243. package/dist/ui/components/shared/MaxSizedBox.tsx +0 -624
  244. package/dist/ui/components/shared/RadioButtonSelect.test.tsx +0 -181
  245. package/dist/ui/components/shared/RadioButtonSelect.tsx +0 -234
  246. package/dist/ui/components/shared/__snapshots__/RadioButtonSelect.test.tsx.snap +0 -47
  247. package/dist/ui/components/shared/text-buffer.test.ts +0 -1728
  248. package/dist/ui/components/shared/text-buffer.ts +0 -2227
  249. package/dist/ui/components/shared/vim-buffer-actions.test.ts +0 -1119
  250. package/dist/ui/components/shared/vim-buffer-actions.ts +0 -814
  251. package/dist/ui/constants.ts +0 -17
  252. package/dist/ui/contexts/KeypressContext.test.tsx +0 -391
  253. package/dist/ui/contexts/KeypressContext.tsx +0 -440
  254. package/dist/ui/contexts/OverflowContext.tsx +0 -87
  255. package/dist/ui/contexts/SessionContext.test.tsx +0 -132
  256. package/dist/ui/contexts/SessionContext.tsx +0 -143
  257. package/dist/ui/contexts/SettingsContext.tsx +0 -20
  258. package/dist/ui/contexts/StreamingContext.tsx +0 -22
  259. package/dist/ui/contexts/VimModeContext.tsx +0 -79
  260. package/dist/ui/editors/editorSettingsManager.ts +0 -66
  261. package/dist/ui/hooks/atCommandProcessor.test.ts +0 -1102
  262. package/dist/ui/hooks/atCommandProcessor.ts +0 -485
  263. package/dist/ui/hooks/shellCommandProcessor.test.ts +0 -481
  264. package/dist/ui/hooks/shellCommandProcessor.ts +0 -314
  265. package/dist/ui/hooks/slashCommandProcessor.test.ts +0 -1044
  266. package/dist/ui/hooks/slashCommandProcessor.ts +0 -595
  267. package/dist/ui/hooks/useAtCompletion.test.ts +0 -497
  268. package/dist/ui/hooks/useAtCompletion.ts +0 -244
  269. package/dist/ui/hooks/useAuthCommand.ts +0 -129
  270. package/dist/ui/hooks/useAutoAcceptIndicator.test.ts +0 -300
  271. package/dist/ui/hooks/useAutoAcceptIndicator.ts +0 -52
  272. package/dist/ui/hooks/useBracketedPaste.ts +0 -37
  273. package/dist/ui/hooks/useCommandCompletion.test.ts +0 -518
  274. package/dist/ui/hooks/useCommandCompletion.tsx +0 -238
  275. package/dist/ui/hooks/useCompletion.ts +0 -128
  276. package/dist/ui/hooks/useConsoleMessages.test.ts +0 -147
  277. package/dist/ui/hooks/useConsoleMessages.ts +0 -110
  278. package/dist/ui/hooks/useEditorSettings.test.ts +0 -283
  279. package/dist/ui/hooks/useEditorSettings.ts +0 -75
  280. package/dist/ui/hooks/useFocus.test.ts +0 -119
  281. package/dist/ui/hooks/useFocus.ts +0 -48
  282. package/dist/ui/hooks/useFolderTrust.test.ts +0 -159
  283. package/dist/ui/hooks/useFolderTrust.ts +0 -72
  284. package/dist/ui/hooks/useGeminiStream.test.tsx +0 -1998
  285. package/dist/ui/hooks/useGeminiStream.ts +0 -1017
  286. package/dist/ui/hooks/useGitBranchName.test.ts +0 -280
  287. package/dist/ui/hooks/useGitBranchName.ts +0 -79
  288. package/dist/ui/hooks/useHistoryManager.test.ts +0 -202
  289. package/dist/ui/hooks/useHistoryManager.ts +0 -111
  290. package/dist/ui/hooks/useInputHistory.test.ts +0 -261
  291. package/dist/ui/hooks/useInputHistory.ts +0 -111
  292. package/dist/ui/hooks/useKeypress.test.ts +0 -280
  293. package/dist/ui/hooks/useKeypress.ts +0 -39
  294. package/dist/ui/hooks/useKittyKeyboardProtocol.ts +0 -31
  295. package/dist/ui/hooks/useLoadingIndicator.test.ts +0 -139
  296. package/dist/ui/hooks/useLoadingIndicator.ts +0 -57
  297. package/dist/ui/hooks/useLogger.ts +0 -32
  298. package/dist/ui/hooks/useMessageQueue.test.ts +0 -226
  299. package/dist/ui/hooks/useMessageQueue.ts +0 -69
  300. package/dist/ui/hooks/usePhraseCycler.test.ts +0 -145
  301. package/dist/ui/hooks/usePhraseCycler.ts +0 -198
  302. package/dist/ui/hooks/usePrivacySettings.test.ts +0 -242
  303. package/dist/ui/hooks/usePrivacySettings.ts +0 -150
  304. package/dist/ui/hooks/useReactToolScheduler.ts +0 -309
  305. package/dist/ui/hooks/useRefreshMemoryCommand.ts +0 -7
  306. package/dist/ui/hooks/useReverseSearchCompletion.test.tsx +0 -260
  307. package/dist/ui/hooks/useReverseSearchCompletion.tsx +0 -95
  308. package/dist/ui/hooks/useSettingsCommand.ts +0 -25
  309. package/dist/ui/hooks/useShellHistory.test.ts +0 -219
  310. package/dist/ui/hooks/useShellHistory.ts +0 -133
  311. package/dist/ui/hooks/useShowMemoryCommand.ts +0 -75
  312. package/dist/ui/hooks/useSlashCompletion.test.ts +0 -434
  313. package/dist/ui/hooks/useSlashCompletion.ts +0 -187
  314. package/dist/ui/hooks/useStateAndRef.ts +0 -36
  315. package/dist/ui/hooks/useTerminalSize.ts +0 -32
  316. package/dist/ui/hooks/useThemeCommand.ts +0 -110
  317. package/dist/ui/hooks/useTimer.test.ts +0 -120
  318. package/dist/ui/hooks/useTimer.ts +0 -65
  319. package/dist/ui/hooks/useToolScheduler.test.ts +0 -1123
  320. package/dist/ui/hooks/useWelcomeBack.ts +0 -253
  321. package/dist/ui/hooks/vim.test.ts +0 -1691
  322. package/dist/ui/hooks/vim.ts +0 -784
  323. package/dist/ui/keyMatchers.test.ts +0 -337
  324. package/dist/ui/keyMatchers.ts +0 -105
  325. package/dist/ui/privacy/CloudFreePrivacyNotice.tsx +0 -117
  326. package/dist/ui/privacy/CloudPaidPrivacyNotice.tsx +0 -59
  327. package/dist/ui/privacy/GeminiPrivacyNotice.tsx +0 -62
  328. package/dist/ui/privacy/PrivacyNotice.tsx +0 -42
  329. package/dist/ui/semantic-colors.ts +0 -26
  330. package/dist/ui/themes/ansi-light.ts +0 -150
  331. package/dist/ui/themes/ansi.ts +0 -159
  332. package/dist/ui/themes/atom-one-dark.ts +0 -147
  333. package/dist/ui/themes/ayu-light.ts +0 -139
  334. package/dist/ui/themes/ayu.ts +0 -113
  335. package/dist/ui/themes/color-utils.test.ts +0 -221
  336. package/dist/ui/themes/color-utils.ts +0 -231
  337. package/dist/ui/themes/default-light.ts +0 -108
  338. package/dist/ui/themes/default.ts +0 -151
  339. package/dist/ui/themes/dracula.ts +0 -124
  340. package/dist/ui/themes/fss-code-dark.ts +0 -156
  341. package/dist/ui/themes/fss-dark.ts +0 -113
  342. package/dist/ui/themes/fss-light.ts +0 -139
  343. package/dist/ui/themes/github-dark.ts +0 -147
  344. package/dist/ui/themes/github-light.ts +0 -149
  345. package/dist/ui/themes/googlecode.ts +0 -146
  346. package/dist/ui/themes/no-color.ts +0 -125
  347. package/dist/ui/themes/qwen-dark.ts +0 -118
  348. package/dist/ui/themes/qwen-light.ts +0 -144
  349. package/dist/ui/themes/semantic-tokens.ts +0 -127
  350. package/dist/ui/themes/shades-of-purple.ts +0 -352
  351. package/dist/ui/themes/theme-manager.test.ts +0 -99
  352. package/dist/ui/themes/theme-manager.ts +0 -257
  353. package/dist/ui/themes/theme.test.ts +0 -97
  354. package/dist/ui/themes/theme.ts +0 -451
  355. package/dist/ui/themes/xcode.ts +0 -154
  356. package/dist/ui/types.ts +0 -255
  357. package/dist/ui/utils/CodeColorizer.tsx +0 -217
  358. package/dist/ui/utils/ConsolePatcher.ts +0 -71
  359. package/dist/ui/utils/InlineMarkdownRenderer.tsx +0 -173
  360. package/dist/ui/utils/MarkdownDisplay.test.tsx +0 -244
  361. package/dist/ui/utils/MarkdownDisplay.tsx +0 -415
  362. package/dist/ui/utils/TableRenderer.tsx +0 -159
  363. package/dist/ui/utils/__snapshots__/MarkdownDisplay.test.tsx.snap +0 -93
  364. package/dist/ui/utils/clipboardUtils.test.ts +0 -76
  365. package/dist/ui/utils/clipboardUtils.ts +0 -149
  366. package/dist/ui/utils/commandUtils.test.ts +0 -384
  367. package/dist/ui/utils/commandUtils.ts +0 -106
  368. package/dist/ui/utils/computeStats.test.ts +0 -292
  369. package/dist/ui/utils/computeStats.ts +0 -86
  370. package/dist/ui/utils/displayUtils.test.ts +0 -58
  371. package/dist/ui/utils/displayUtils.ts +0 -32
  372. package/dist/ui/utils/formatters.test.ts +0 -72
  373. package/dist/ui/utils/formatters.ts +0 -63
  374. package/dist/ui/utils/isNarrowWidth.ts +0 -9
  375. package/dist/ui/utils/kittyProtocolDetector.ts +0 -105
  376. package/dist/ui/utils/markdownUtilities.test.ts +0 -50
  377. package/dist/ui/utils/markdownUtilities.ts +0 -125
  378. package/dist/ui/utils/platformConstants.ts +0 -52
  379. package/dist/ui/utils/terminalSetup.ts +0 -342
  380. package/dist/ui/utils/textUtils.ts +0 -40
  381. package/dist/ui/utils/updateCheck.test.ts +0 -163
  382. package/dist/ui/utils/updateCheck.ts +0 -100
  383. package/dist/utils/checks.ts +0 -28
  384. package/dist/utils/cleanup.test.ts +0 -68
  385. package/dist/utils/cleanup.ts +0 -36
  386. package/dist/utils/dialogScopeUtils.ts +0 -64
  387. package/dist/utils/events.ts +0 -14
  388. package/dist/utils/gitUtils.test.ts +0 -149
  389. package/dist/utils/gitUtils.ts +0 -116
  390. package/dist/utils/handleAutoUpdate.test.ts +0 -272
  391. package/dist/utils/handleAutoUpdate.ts +0 -145
  392. package/dist/utils/installationInfo.test.ts +0 -315
  393. package/dist/utils/installationInfo.ts +0 -176
  394. package/dist/utils/package.ts +0 -38
  395. package/dist/utils/readStdin.ts +0 -51
  396. package/dist/utils/resolvePath.ts +0 -21
  397. package/dist/utils/sandbox-macos-permissive-closed.sb +0 -32
  398. package/dist/utils/sandbox-macos-permissive-open.sb +0 -25
  399. package/dist/utils/sandbox-macos-permissive-proxied.sb +0 -37
  400. package/dist/utils/sandbox-macos-restrictive-closed.sb +0 -93
  401. package/dist/utils/sandbox-macos-restrictive-open.sb +0 -96
  402. package/dist/utils/sandbox-macos-restrictive-proxied.sb +0 -98
  403. package/dist/utils/sandbox.ts +0 -962
  404. package/dist/utils/settingsUtils.test.ts +0 -797
  405. package/dist/utils/settingsUtils.ts +0 -489
  406. package/dist/utils/spawnWrapper.ts +0 -9
  407. package/dist/utils/startupWarnings.test.ts +0 -83
  408. package/dist/utils/startupWarnings.ts +0 -40
  409. package/dist/utils/updateEventEmitter.ts +0 -13
  410. package/dist/utils/userStartupWarnings.test.ts +0 -87
  411. package/dist/utils/userStartupWarnings.ts +0 -69
  412. package/dist/utils/version.ts +0 -12
  413. package/dist/validateNonInterActiveAuth.test.ts +0 -260
  414. package/dist/validateNonInterActiveAuth.ts +0 -51
  415. package/dist/vitest.config.ts +0 -37
  416. package/dist/zed-integration/acp.ts +0 -366
  417. package/dist/zed-integration/fileSystemService.ts +0 -47
  418. package/dist/zed-integration/schema.ts +0 -466
  419. package/dist/zed-integration/zedIntegration.ts +0 -944
@@ -1,2227 +0,0 @@
1
- /**
2
- * @license
3
- * Copyright 2025 Google LLC
4
- * SPDX-License-Identifier: Apache-2.0
5
- */
6
-
7
- import stripAnsi from 'strip-ansi';
8
- import { stripVTControlCharacters } from 'util';
9
- import { spawnSync } from 'child_process';
10
- import fs from 'fs';
11
- import os from 'os';
12
- import pathMod from 'path';
13
- import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
14
- import stringWidth from 'string-width';
15
- import { unescapePath } from 'fss-link-core';
16
- import { toCodePoints, cpLen, cpSlice } from '../../utils/textUtils.js';
17
- import { handleVimAction, VimAction } from './vim-buffer-actions.js';
18
-
19
- export type Direction =
20
- | 'left'
21
- | 'right'
22
- | 'up'
23
- | 'down'
24
- | 'wordLeft'
25
- | 'wordRight'
26
- | 'home'
27
- | 'end';
28
-
29
- // Simple helper for word‑wise ops.
30
- function isWordChar(ch: string | undefined): boolean {
31
- if (ch === undefined) {
32
- return false;
33
- }
34
- return !/[\s,.;!?]/.test(ch);
35
- }
36
-
37
- // Helper functions for line-based word navigation
38
- export const isWordCharStrict = (char: string): boolean =>
39
- /[\w\p{L}\p{N}]/u.test(char); // Matches a single character that is any Unicode letter, any Unicode number, or an underscore
40
-
41
- export const isWhitespace = (char: string): boolean => /\s/.test(char);
42
-
43
- // Check if a character is a combining mark (only diacritics for now)
44
- export const isCombiningMark = (char: string): boolean => /\p{M}/u.test(char);
45
-
46
- // Check if a character should be considered part of a word (including combining marks)
47
- export const isWordCharWithCombining = (char: string): boolean =>
48
- isWordCharStrict(char) || isCombiningMark(char);
49
-
50
- // Get the script of a character (simplified for common scripts)
51
- export const getCharScript = (char: string): string => {
52
- if (/[\p{Script=Latin}]/u.test(char)) return 'latin'; // All Latin script chars including diacritics
53
- if (/[\p{Script=Han}]/u.test(char)) return 'han'; // Chinese
54
- if (/[\p{Script=Arabic}]/u.test(char)) return 'arabic';
55
- if (/[\p{Script=Hiragana}]/u.test(char)) return 'hiragana';
56
- if (/[\p{Script=Katakana}]/u.test(char)) return 'katakana';
57
- if (/[\p{Script=Cyrillic}]/u.test(char)) return 'cyrillic';
58
- return 'other';
59
- };
60
-
61
- // Check if two characters are from different scripts (indicating word boundary)
62
- export const isDifferentScript = (char1: string, char2: string): boolean => {
63
- if (!isWordCharStrict(char1) || !isWordCharStrict(char2)) return false;
64
- return getCharScript(char1) !== getCharScript(char2);
65
- };
66
-
67
- // Find next word start within a line, starting from col
68
- export const findNextWordStartInLine = (
69
- line: string,
70
- col: number,
71
- ): number | null => {
72
- const chars = toCodePoints(line);
73
- let i = col;
74
-
75
- if (i >= chars.length) return null;
76
-
77
- const currentChar = chars[i];
78
-
79
- // Skip current word/sequence based on character type
80
- if (isWordCharStrict(currentChar)) {
81
- while (i < chars.length && isWordCharWithCombining(chars[i])) {
82
- // Check for script boundary - if next character is from different script, stop here
83
- if (
84
- i + 1 < chars.length &&
85
- isWordCharStrict(chars[i + 1]) &&
86
- isDifferentScript(chars[i], chars[i + 1])
87
- ) {
88
- i++; // Include current character
89
- break; // Stop at script boundary
90
- }
91
- i++;
92
- }
93
- } else if (!isWhitespace(currentChar)) {
94
- while (
95
- i < chars.length &&
96
- !isWordCharStrict(chars[i]) &&
97
- !isWhitespace(chars[i])
98
- ) {
99
- i++;
100
- }
101
- }
102
-
103
- // Skip whitespace
104
- while (i < chars.length && isWhitespace(chars[i])) {
105
- i++;
106
- }
107
-
108
- return i < chars.length ? i : null;
109
- };
110
-
111
- // Find previous word start within a line
112
- export const findPrevWordStartInLine = (
113
- line: string,
114
- col: number,
115
- ): number | null => {
116
- const chars = toCodePoints(line);
117
- let i = col;
118
-
119
- if (i <= 0) return null;
120
-
121
- i--;
122
-
123
- // Skip whitespace moving backwards
124
- while (i >= 0 && isWhitespace(chars[i])) {
125
- i--;
126
- }
127
-
128
- if (i < 0) return null;
129
-
130
- if (isWordCharStrict(chars[i])) {
131
- // We're in a word, move to its beginning
132
- while (i >= 0 && isWordCharStrict(chars[i])) {
133
- // Check for script boundary - if previous character is from different script, stop here
134
- if (
135
- i - 1 >= 0 &&
136
- isWordCharStrict(chars[i - 1]) &&
137
- isDifferentScript(chars[i], chars[i - 1])
138
- ) {
139
- return i; // Return current position at script boundary
140
- }
141
- i--;
142
- }
143
- return i + 1;
144
- } else {
145
- // We're in punctuation, move to its beginning
146
- while (i >= 0 && !isWordCharStrict(chars[i]) && !isWhitespace(chars[i])) {
147
- i--;
148
- }
149
- return i + 1;
150
- }
151
- };
152
-
153
- // Find word end within a line
154
- export const findWordEndInLine = (line: string, col: number): number | null => {
155
- const chars = toCodePoints(line);
156
- let i = col;
157
-
158
- // If we're already at the end of a word (including punctuation sequences), advance to next word
159
- // This includes both regular word endings and script boundaries
160
- const atEndOfWordChar =
161
- i < chars.length &&
162
- isWordCharWithCombining(chars[i]) &&
163
- (i + 1 >= chars.length ||
164
- !isWordCharWithCombining(chars[i + 1]) ||
165
- (isWordCharStrict(chars[i]) &&
166
- i + 1 < chars.length &&
167
- isWordCharStrict(chars[i + 1]) &&
168
- isDifferentScript(chars[i], chars[i + 1])));
169
-
170
- const atEndOfPunctuation =
171
- i < chars.length &&
172
- !isWordCharWithCombining(chars[i]) &&
173
- !isWhitespace(chars[i]) &&
174
- (i + 1 >= chars.length ||
175
- isWhitespace(chars[i + 1]) ||
176
- isWordCharWithCombining(chars[i + 1]));
177
-
178
- if (atEndOfWordChar || atEndOfPunctuation) {
179
- // We're at the end of a word or punctuation sequence, move forward to find next word
180
- i++;
181
- // Skip whitespace to find next word or punctuation
182
- while (i < chars.length && isWhitespace(chars[i])) {
183
- i++;
184
- }
185
- }
186
-
187
- // If we're not on a word character, find the next word or punctuation sequence
188
- if (i < chars.length && !isWordCharWithCombining(chars[i])) {
189
- // Skip whitespace to find next word or punctuation
190
- while (i < chars.length && isWhitespace(chars[i])) {
191
- i++;
192
- }
193
- }
194
-
195
- // Move to end of current word (including combining marks, but stop at script boundaries)
196
- let foundWord = false;
197
- let lastBaseCharPos = -1;
198
-
199
- if (i < chars.length && isWordCharWithCombining(chars[i])) {
200
- // Handle word characters
201
- while (i < chars.length && isWordCharWithCombining(chars[i])) {
202
- foundWord = true;
203
-
204
- // Track the position of the last base character (not combining mark)
205
- if (isWordCharStrict(chars[i])) {
206
- lastBaseCharPos = i;
207
- }
208
-
209
- // Check if next character is from a different script (word boundary)
210
- if (
211
- i + 1 < chars.length &&
212
- isWordCharStrict(chars[i + 1]) &&
213
- isDifferentScript(chars[i], chars[i + 1])
214
- ) {
215
- i++; // Include current character
216
- if (isWordCharStrict(chars[i - 1])) {
217
- lastBaseCharPos = i - 1;
218
- }
219
- break; // Stop at script boundary
220
- }
221
-
222
- i++;
223
- }
224
- } else if (i < chars.length && !isWhitespace(chars[i])) {
225
- // Handle punctuation sequences (like ████)
226
- while (
227
- i < chars.length &&
228
- !isWordCharStrict(chars[i]) &&
229
- !isWhitespace(chars[i])
230
- ) {
231
- foundWord = true;
232
- lastBaseCharPos = i;
233
- i++;
234
- }
235
- }
236
-
237
- // Only return a position if we actually found a word
238
- // Return the position of the last base character, not combining marks
239
- if (foundWord && lastBaseCharPos >= col) {
240
- return lastBaseCharPos;
241
- }
242
-
243
- return null;
244
- };
245
-
246
- // Find next word across lines
247
- export const findNextWordAcrossLines = (
248
- lines: string[],
249
- cursorRow: number,
250
- cursorCol: number,
251
- searchForWordStart: boolean,
252
- ): { row: number; col: number } | null => {
253
- // First try current line
254
- const currentLine = lines[cursorRow] || '';
255
- const colInCurrentLine = searchForWordStart
256
- ? findNextWordStartInLine(currentLine, cursorCol)
257
- : findWordEndInLine(currentLine, cursorCol);
258
-
259
- if (colInCurrentLine !== null) {
260
- return { row: cursorRow, col: colInCurrentLine };
261
- }
262
-
263
- // Search subsequent lines
264
- for (let row = cursorRow + 1; row < lines.length; row++) {
265
- const line = lines[row] || '';
266
- const chars = toCodePoints(line);
267
-
268
- // For empty lines, if we haven't found any words yet, return the empty line
269
- if (chars.length === 0) {
270
- // Check if there are any words in remaining lines
271
- let hasWordsInLaterLines = false;
272
- for (let laterRow = row + 1; laterRow < lines.length; laterRow++) {
273
- const laterLine = lines[laterRow] || '';
274
- const laterChars = toCodePoints(laterLine);
275
- let firstNonWhitespace = 0;
276
- while (
277
- firstNonWhitespace < laterChars.length &&
278
- isWhitespace(laterChars[firstNonWhitespace])
279
- ) {
280
- firstNonWhitespace++;
281
- }
282
- if (firstNonWhitespace < laterChars.length) {
283
- hasWordsInLaterLines = true;
284
- break;
285
- }
286
- }
287
-
288
- // If no words in later lines, return the empty line
289
- if (!hasWordsInLaterLines) {
290
- return { row, col: 0 };
291
- }
292
- continue;
293
- }
294
-
295
- // Find first non-whitespace
296
- let firstNonWhitespace = 0;
297
- while (
298
- firstNonWhitespace < chars.length &&
299
- isWhitespace(chars[firstNonWhitespace])
300
- ) {
301
- firstNonWhitespace++;
302
- }
303
-
304
- if (firstNonWhitespace < chars.length) {
305
- if (searchForWordStart) {
306
- return { row, col: firstNonWhitespace };
307
- } else {
308
- // For word end, find the end of the first word
309
- const endCol = findWordEndInLine(line, firstNonWhitespace);
310
- if (endCol !== null) {
311
- return { row, col: endCol };
312
- }
313
- }
314
- }
315
- }
316
-
317
- return null;
318
- };
319
-
320
- // Find previous word across lines
321
- export const findPrevWordAcrossLines = (
322
- lines: string[],
323
- cursorRow: number,
324
- cursorCol: number,
325
- ): { row: number; col: number } | null => {
326
- // First try current line
327
- const currentLine = lines[cursorRow] || '';
328
- const colInCurrentLine = findPrevWordStartInLine(currentLine, cursorCol);
329
-
330
- if (colInCurrentLine !== null) {
331
- return { row: cursorRow, col: colInCurrentLine };
332
- }
333
-
334
- // Search previous lines
335
- for (let row = cursorRow - 1; row >= 0; row--) {
336
- const line = lines[row] || '';
337
- const chars = toCodePoints(line);
338
-
339
- if (chars.length === 0) continue;
340
-
341
- // Find last word start
342
- let lastWordStart = chars.length;
343
- while (lastWordStart > 0 && isWhitespace(chars[lastWordStart - 1])) {
344
- lastWordStart--;
345
- }
346
-
347
- if (lastWordStart > 0) {
348
- // Find start of this word
349
- const wordStart = findPrevWordStartInLine(line, lastWordStart);
350
- if (wordStart !== null) {
351
- return { row, col: wordStart };
352
- }
353
- }
354
- }
355
-
356
- return null;
357
- };
358
-
359
- // Helper functions for vim line operations
360
- export const getPositionFromOffsets = (
361
- startOffset: number,
362
- endOffset: number,
363
- lines: string[],
364
- ) => {
365
- let offset = 0;
366
- let startRow = 0;
367
- let startCol = 0;
368
- let endRow = 0;
369
- let endCol = 0;
370
-
371
- // Find start position
372
- for (let i = 0; i < lines.length; i++) {
373
- const lineLength = lines[i].length + 1; // +1 for newline
374
- if (offset + lineLength > startOffset) {
375
- startRow = i;
376
- startCol = startOffset - offset;
377
- break;
378
- }
379
- offset += lineLength;
380
- }
381
-
382
- // Find end position
383
- offset = 0;
384
- for (let i = 0; i < lines.length; i++) {
385
- const lineLength = lines[i].length + (i < lines.length - 1 ? 1 : 0); // +1 for newline except last line
386
- if (offset + lineLength >= endOffset) {
387
- endRow = i;
388
- endCol = endOffset - offset;
389
- break;
390
- }
391
- offset += lineLength;
392
- }
393
-
394
- return { startRow, startCol, endRow, endCol };
395
- };
396
-
397
- export const getLineRangeOffsets = (
398
- startRow: number,
399
- lineCount: number,
400
- lines: string[],
401
- ) => {
402
- let startOffset = 0;
403
-
404
- // Calculate start offset
405
- for (let i = 0; i < startRow; i++) {
406
- startOffset += lines[i].length + 1; // +1 for newline
407
- }
408
-
409
- // Calculate end offset
410
- let endOffset = startOffset;
411
- for (let i = 0; i < lineCount; i++) {
412
- const lineIndex = startRow + i;
413
- if (lineIndex < lines.length) {
414
- endOffset += lines[lineIndex].length;
415
- if (lineIndex < lines.length - 1) {
416
- endOffset += 1; // +1 for newline
417
- }
418
- }
419
- }
420
-
421
- return { startOffset, endOffset };
422
- };
423
-
424
- export const replaceRangeInternal = (
425
- state: TextBufferState,
426
- startRow: number,
427
- startCol: number,
428
- endRow: number,
429
- endCol: number,
430
- text: string,
431
- ): TextBufferState => {
432
- const currentLine = (row: number) => state.lines[row] || '';
433
- const currentLineLen = (row: number) => cpLen(currentLine(row));
434
- const clamp = (value: number, min: number, max: number) =>
435
- Math.min(Math.max(value, min), max);
436
-
437
- if (
438
- startRow > endRow ||
439
- (startRow === endRow && startCol > endCol) ||
440
- startRow < 0 ||
441
- startCol < 0 ||
442
- endRow >= state.lines.length ||
443
- (endRow < state.lines.length && endCol > currentLineLen(endRow))
444
- ) {
445
- return state; // Invalid range
446
- }
447
-
448
- const newLines = [...state.lines];
449
-
450
- const sCol = clamp(startCol, 0, currentLineLen(startRow));
451
- const eCol = clamp(endCol, 0, currentLineLen(endRow));
452
-
453
- const prefix = cpSlice(currentLine(startRow), 0, sCol);
454
- const suffix = cpSlice(currentLine(endRow), eCol);
455
-
456
- const normalisedReplacement = text
457
- .replace(/\r\n/g, '\n')
458
- .replace(/\r/g, '\n');
459
- const replacementParts = normalisedReplacement.split('\n');
460
-
461
- // The combined first line of the new text
462
- const firstLine = prefix + replacementParts[0];
463
-
464
- if (replacementParts.length === 1) {
465
- // No newlines in replacement: combine prefix, replacement, and suffix on one line.
466
- newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix);
467
- } else {
468
- // Newlines in replacement: create new lines.
469
- const lastLine = replacementParts[replacementParts.length - 1] + suffix;
470
- const middleLines = replacementParts.slice(1, -1);
471
- newLines.splice(
472
- startRow,
473
- endRow - startRow + 1,
474
- firstLine,
475
- ...middleLines,
476
- lastLine,
477
- );
478
- }
479
-
480
- const finalCursorRow = startRow + replacementParts.length - 1;
481
- const finalCursorCol =
482
- (replacementParts.length > 1 ? 0 : sCol) +
483
- cpLen(replacementParts[replacementParts.length - 1]);
484
-
485
- return {
486
- ...state,
487
- lines: newLines,
488
- cursorRow: Math.min(Math.max(finalCursorRow, 0), newLines.length - 1),
489
- cursorCol: Math.max(
490
- 0,
491
- Math.min(finalCursorCol, cpLen(newLines[finalCursorRow] || '')),
492
- ),
493
- preferredCol: null,
494
- };
495
- };
496
-
497
- /**
498
- * Strip characters that can break terminal rendering.
499
- *
500
- * Uses Node.js built-in stripVTControlCharacters to handle VT sequences,
501
- * then filters remaining control characters that can disrupt display.
502
- *
503
- * Characters stripped:
504
- * - ANSI escape sequences (via strip-ansi)
505
- * - VT control sequences (via Node.js util.stripVTControlCharacters)
506
- * - C0 control chars (0x00-0x1F) except CR/LF which are handled elsewhere
507
- * - C1 control chars (0x80-0x9F) that can cause display issues
508
- *
509
- * Characters preserved:
510
- * - All printable Unicode including emojis
511
- * - DEL (0x7F) - handled functionally by applyOperations, not a display issue
512
- * - CR/LF (0x0D/0x0A) - needed for line breaks
513
- */
514
- function stripUnsafeCharacters(str: string): string {
515
- const strippedAnsi = stripAnsi(str);
516
- const strippedVT = stripVTControlCharacters(strippedAnsi);
517
-
518
- return toCodePoints(strippedVT)
519
- .filter((char) => {
520
- const code = char.codePointAt(0);
521
- if (code === undefined) return false;
522
-
523
- // Preserve CR/LF for line handling
524
- if (code === 0x0a || code === 0x0d) return true;
525
-
526
- // Remove C0 control chars (except CR/LF) that can break display
527
- // Examples: BELL(0x07) makes noise, BS(0x08) moves cursor, VT(0x0B), FF(0x0C)
528
- if (code >= 0x00 && code <= 0x1f) return false;
529
-
530
- // Remove C1 control chars (0x80-0x9F) - legacy 8-bit control codes
531
- if (code >= 0x80 && code <= 0x9f) return false;
532
-
533
- // Preserve DEL (0x7F) - it's handled functionally by applyOperations as backspace
534
- // and doesn't cause rendering issues when displayed
535
-
536
- // Preserve all other characters including Unicode/emojis
537
- return true;
538
- })
539
- .join('');
540
- }
541
-
542
- export interface Viewport {
543
- height: number;
544
- width: number;
545
- }
546
-
547
- function clamp(v: number, min: number, max: number): number {
548
- return v < min ? min : v > max ? max : v;
549
- }
550
-
551
- /* ────────────────────────────────────────────────────────────────────────── */
552
-
553
- interface UseTextBufferProps {
554
- initialText?: string;
555
- initialCursorOffset?: number;
556
- viewport: Viewport; // Viewport dimensions needed for scrolling
557
- stdin?: NodeJS.ReadStream | null; // For external editor
558
- setRawMode?: (mode: boolean) => void; // For external editor
559
- onChange?: (text: string) => void; // Callback for when text changes
560
- isValidPath: (path: string) => boolean;
561
- shellModeActive?: boolean; // Whether the text buffer is in shell mode
562
- }
563
-
564
- interface UndoHistoryEntry {
565
- lines: string[];
566
- cursorRow: number;
567
- cursorCol: number;
568
- }
569
-
570
- function calculateInitialCursorPosition(
571
- initialLines: string[],
572
- offset: number,
573
- ): [number, number] {
574
- let remainingChars = offset;
575
- let row = 0;
576
- while (row < initialLines.length) {
577
- const lineLength = cpLen(initialLines[row]);
578
- // Add 1 for the newline character (except for the last line)
579
- const totalCharsInLineAndNewline =
580
- lineLength + (row < initialLines.length - 1 ? 1 : 0);
581
-
582
- if (remainingChars <= lineLength) {
583
- // Cursor is on this line
584
- return [row, remainingChars];
585
- }
586
- remainingChars -= totalCharsInLineAndNewline;
587
- row++;
588
- }
589
- // Offset is beyond the text, place cursor at the end of the last line
590
- if (initialLines.length > 0) {
591
- const lastRow = initialLines.length - 1;
592
- return [lastRow, cpLen(initialLines[lastRow])];
593
- }
594
- return [0, 0]; // Default for empty text
595
- }
596
-
597
- export function offsetToLogicalPos(
598
- text: string,
599
- offset: number,
600
- ): [number, number] {
601
- let row = 0;
602
- let col = 0;
603
- let currentOffset = 0;
604
-
605
- if (offset === 0) return [0, 0];
606
-
607
- const lines = text.split('\n');
608
- for (let i = 0; i < lines.length; i++) {
609
- const line = lines[i];
610
- const lineLength = cpLen(line);
611
- const lineLengthWithNewline = lineLength + (i < lines.length - 1 ? 1 : 0);
612
-
613
- if (offset <= currentOffset + lineLength) {
614
- // Check against lineLength first
615
- row = i;
616
- col = offset - currentOffset;
617
- return [row, col];
618
- } else if (offset <= currentOffset + lineLengthWithNewline) {
619
- // Check if offset is the newline itself
620
- row = i;
621
- col = lineLength; // Position cursor at the end of the current line content
622
- // If the offset IS the newline, and it's not the last line, advance to next line, col 0
623
- if (
624
- offset === currentOffset + lineLengthWithNewline &&
625
- i < lines.length - 1
626
- ) {
627
- return [i + 1, 0];
628
- }
629
- return [row, col]; // Otherwise, it's at the end of the current line content
630
- }
631
- currentOffset += lineLengthWithNewline;
632
- }
633
-
634
- // If offset is beyond the text length, place cursor at the end of the last line
635
- // or [0,0] if text is empty
636
- if (lines.length > 0) {
637
- row = lines.length - 1;
638
- col = cpLen(lines[row]);
639
- } else {
640
- row = 0;
641
- col = 0;
642
- }
643
- return [row, col];
644
- }
645
-
646
- /**
647
- * Converts logical row/col position to absolute text offset
648
- * Inverse operation of offsetToLogicalPos
649
- */
650
- export function logicalPosToOffset(
651
- lines: string[],
652
- row: number,
653
- col: number,
654
- ): number {
655
- let offset = 0;
656
-
657
- // Clamp row to valid range
658
- const actualRow = Math.min(row, lines.length - 1);
659
-
660
- // Add lengths of all lines before the target row
661
- for (let i = 0; i < actualRow; i++) {
662
- offset += cpLen(lines[i]) + 1; // +1 for newline
663
- }
664
-
665
- // Add column offset within the target row
666
- if (actualRow >= 0 && actualRow < lines.length) {
667
- offset += Math.min(col, cpLen(lines[actualRow]));
668
- }
669
-
670
- return offset;
671
- }
672
-
673
- // Helper to calculate visual lines and map cursor positions
674
- function calculateVisualLayout(
675
- logicalLines: string[],
676
- logicalCursor: [number, number],
677
- viewportWidth: number,
678
- ): {
679
- visualLines: string[];
680
- visualCursor: [number, number];
681
- logicalToVisualMap: Array<Array<[number, number]>>; // For each logical line, an array of [visualLineIndex, startColInLogical]
682
- visualToLogicalMap: Array<[number, number]>; // For each visual line, its [logicalLineIndex, startColInLogical]
683
- } {
684
- const visualLines: string[] = [];
685
- const logicalToVisualMap: Array<Array<[number, number]>> = [];
686
- const visualToLogicalMap: Array<[number, number]> = [];
687
- let currentVisualCursor: [number, number] = [0, 0];
688
-
689
- logicalLines.forEach((logLine, logIndex) => {
690
- logicalToVisualMap[logIndex] = [];
691
- if (logLine.length === 0) {
692
- // Handle empty logical line
693
- logicalToVisualMap[logIndex].push([visualLines.length, 0]);
694
- visualToLogicalMap.push([logIndex, 0]);
695
- visualLines.push('');
696
- if (logIndex === logicalCursor[0] && logicalCursor[1] === 0) {
697
- currentVisualCursor = [visualLines.length - 1, 0];
698
- }
699
- } else {
700
- // Non-empty logical line
701
- let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index)
702
- const codePointsInLogLine = toCodePoints(logLine);
703
-
704
- while (currentPosInLogLine < codePointsInLogLine.length) {
705
- let currentChunk = '';
706
- let currentChunkVisualWidth = 0;
707
- let numCodePointsInChunk = 0;
708
- let lastWordBreakPoint = -1; // Index in codePointsInLogLine for word break
709
- let numCodePointsAtLastWordBreak = 0;
710
-
711
- // Iterate through code points to build the current visual line (chunk)
712
- for (let i = currentPosInLogLine; i < codePointsInLogLine.length; i++) {
713
- const char = codePointsInLogLine[i];
714
- const charVisualWidth = stringWidth(char);
715
-
716
- if (currentChunkVisualWidth + charVisualWidth > viewportWidth) {
717
- // Character would exceed viewport width
718
- if (
719
- lastWordBreakPoint !== -1 &&
720
- numCodePointsAtLastWordBreak > 0 &&
721
- currentPosInLogLine + numCodePointsAtLastWordBreak < i
722
- ) {
723
- // We have a valid word break point to use, and it's not the start of the current segment
724
- currentChunk = codePointsInLogLine
725
- .slice(
726
- currentPosInLogLine,
727
- currentPosInLogLine + numCodePointsAtLastWordBreak,
728
- )
729
- .join('');
730
- numCodePointsInChunk = numCodePointsAtLastWordBreak;
731
- } else {
732
- // No word break, or word break is at the start of this potential chunk, or word break leads to empty chunk.
733
- // Hard break: take characters up to viewportWidth, or just the current char if it alone is too wide.
734
- if (
735
- numCodePointsInChunk === 0 &&
736
- charVisualWidth > viewportWidth
737
- ) {
738
- // Single character is wider than viewport, take it anyway
739
- currentChunk = char;
740
- numCodePointsInChunk = 1;
741
- } else if (
742
- numCodePointsInChunk === 0 &&
743
- charVisualWidth <= viewportWidth
744
- ) {
745
- // This case should ideally be caught by the next iteration if the char fits.
746
- // If it doesn't fit (because currentChunkVisualWidth was already > 0 from a previous char that filled the line),
747
- // then numCodePointsInChunk would not be 0.
748
- // This branch means the current char *itself* doesn't fit an empty line, which is handled by the above.
749
- // If we are here, it means the loop should break and the current chunk (which is empty) is finalized.
750
- }
751
- }
752
- break; // Break from inner loop to finalize this chunk
753
- }
754
-
755
- currentChunk += char;
756
- currentChunkVisualWidth += charVisualWidth;
757
- numCodePointsInChunk++;
758
-
759
- // Check for word break opportunity (space)
760
- if (char === ' ') {
761
- lastWordBreakPoint = i; // Store code point index of the space
762
- // Store the state *before* adding the space, if we decide to break here.
763
- numCodePointsAtLastWordBreak = numCodePointsInChunk - 1; // Chars *before* the space
764
- }
765
- }
766
-
767
- // If the inner loop completed without breaking (i.e., remaining text fits)
768
- // or if the loop broke but numCodePointsInChunk is still 0 (e.g. first char too wide for empty line)
769
- if (
770
- numCodePointsInChunk === 0 &&
771
- currentPosInLogLine < codePointsInLogLine.length
772
- ) {
773
- // This can happen if the very first character considered for a new visual line is wider than the viewport.
774
- // In this case, we take that single character.
775
- const firstChar = codePointsInLogLine[currentPosInLogLine];
776
- currentChunk = firstChar;
777
- numCodePointsInChunk = 1; // Ensure we advance
778
- }
779
-
780
- // If after everything, numCodePointsInChunk is still 0 but we haven't processed the whole logical line,
781
- // it implies an issue, like viewportWidth being 0 or less. Avoid infinite loop.
782
- if (
783
- numCodePointsInChunk === 0 &&
784
- currentPosInLogLine < codePointsInLogLine.length
785
- ) {
786
- // Force advance by one character to prevent infinite loop if something went wrong
787
- currentChunk = codePointsInLogLine[currentPosInLogLine];
788
- numCodePointsInChunk = 1;
789
- }
790
-
791
- logicalToVisualMap[logIndex].push([
792
- visualLines.length,
793
- currentPosInLogLine,
794
- ]);
795
- visualToLogicalMap.push([logIndex, currentPosInLogLine]);
796
- visualLines.push(currentChunk);
797
-
798
- // Cursor mapping logic
799
- // Note: currentPosInLogLine here is the start of the currentChunk within the logical line.
800
- if (logIndex === logicalCursor[0]) {
801
- const cursorLogCol = logicalCursor[1]; // This is a code point index
802
- if (
803
- cursorLogCol >= currentPosInLogLine &&
804
- cursorLogCol < currentPosInLogLine + numCodePointsInChunk // Cursor is within this chunk
805
- ) {
806
- currentVisualCursor = [
807
- visualLines.length - 1,
808
- cursorLogCol - currentPosInLogLine, // Visual col is also code point index within visual line
809
- ];
810
- } else if (
811
- cursorLogCol === currentPosInLogLine + numCodePointsInChunk &&
812
- numCodePointsInChunk > 0
813
- ) {
814
- // Cursor is exactly at the end of this non-empty chunk
815
- currentVisualCursor = [
816
- visualLines.length - 1,
817
- numCodePointsInChunk,
818
- ];
819
- }
820
- }
821
-
822
- const logicalStartOfThisChunk = currentPosInLogLine;
823
- currentPosInLogLine += numCodePointsInChunk;
824
-
825
- // If the chunk processed did not consume the entire logical line,
826
- // and the character immediately following the chunk is a space,
827
- // advance past this space as it acted as a delimiter for word wrapping.
828
- if (
829
- logicalStartOfThisChunk + numCodePointsInChunk <
830
- codePointsInLogLine.length &&
831
- currentPosInLogLine < codePointsInLogLine.length && // Redundant if previous is true, but safe
832
- codePointsInLogLine[currentPosInLogLine] === ' '
833
- ) {
834
- currentPosInLogLine++;
835
- }
836
- }
837
- // After all chunks of a non-empty logical line are processed,
838
- // if the cursor is at the very end of this logical line, update visual cursor.
839
- if (
840
- logIndex === logicalCursor[0] &&
841
- logicalCursor[1] === codePointsInLogLine.length // Cursor at end of logical line
842
- ) {
843
- const lastVisualLineIdx = visualLines.length - 1;
844
- if (
845
- lastVisualLineIdx >= 0 &&
846
- visualLines[lastVisualLineIdx] !== undefined
847
- ) {
848
- currentVisualCursor = [
849
- lastVisualLineIdx,
850
- cpLen(visualLines[lastVisualLineIdx]), // Cursor at end of last visual line for this logical line
851
- ];
852
- }
853
- }
854
- }
855
- });
856
-
857
- // If the entire logical text was empty, ensure there's one empty visual line.
858
- if (
859
- logicalLines.length === 0 ||
860
- (logicalLines.length === 1 && logicalLines[0] === '')
861
- ) {
862
- if (visualLines.length === 0) {
863
- visualLines.push('');
864
- if (!logicalToVisualMap[0]) logicalToVisualMap[0] = [];
865
- logicalToVisualMap[0].push([0, 0]);
866
- visualToLogicalMap.push([0, 0]);
867
- }
868
- currentVisualCursor = [0, 0];
869
- }
870
- // Handle cursor at the very end of the text (after all processing)
871
- // This case might be covered by the loop end condition now, but kept for safety.
872
- else if (
873
- logicalCursor[0] === logicalLines.length - 1 &&
874
- logicalCursor[1] === cpLen(logicalLines[logicalLines.length - 1]) &&
875
- visualLines.length > 0
876
- ) {
877
- const lastVisLineIdx = visualLines.length - 1;
878
- currentVisualCursor = [lastVisLineIdx, cpLen(visualLines[lastVisLineIdx])];
879
- }
880
-
881
- return {
882
- visualLines,
883
- visualCursor: currentVisualCursor,
884
- logicalToVisualMap,
885
- visualToLogicalMap,
886
- };
887
- }
888
-
889
- // --- Start of reducer logic ---
890
-
891
- export interface TextBufferState {
892
- lines: string[];
893
- cursorRow: number;
894
- cursorCol: number;
895
- preferredCol: number | null; // This is visual preferred col
896
- undoStack: UndoHistoryEntry[];
897
- redoStack: UndoHistoryEntry[];
898
- clipboard: string | null;
899
- selectionAnchor: [number, number] | null;
900
- viewportWidth: number;
901
- }
902
-
903
- const historyLimit = 100;
904
-
905
- export const pushUndo = (currentState: TextBufferState): TextBufferState => {
906
- const snapshot = {
907
- lines: [...currentState.lines],
908
- cursorRow: currentState.cursorRow,
909
- cursorCol: currentState.cursorCol,
910
- };
911
- const newStack = [...currentState.undoStack, snapshot];
912
- if (newStack.length > historyLimit) {
913
- newStack.shift();
914
- }
915
- return { ...currentState, undoStack: newStack, redoStack: [] };
916
- };
917
-
918
- export type TextBufferAction =
919
- | { type: 'set_text'; payload: string; pushToUndo?: boolean }
920
- | { type: 'insert'; payload: string }
921
- | { type: 'backspace' }
922
- | {
923
- type: 'move';
924
- payload: {
925
- dir: Direction;
926
- };
927
- }
928
- | { type: 'delete' }
929
- | { type: 'delete_word_left' }
930
- | { type: 'delete_word_right' }
931
- | { type: 'kill_line_right' }
932
- | { type: 'kill_line_left' }
933
- | { type: 'undo' }
934
- | { type: 'redo' }
935
- | {
936
- type: 'replace_range';
937
- payload: {
938
- startRow: number;
939
- startCol: number;
940
- endRow: number;
941
- endCol: number;
942
- text: string;
943
- };
944
- }
945
- | { type: 'move_to_offset'; payload: { offset: number } }
946
- | { type: 'create_undo_snapshot' }
947
- | { type: 'set_viewport_width'; payload: number }
948
- | { type: 'vim_delete_word_forward'; payload: { count: number } }
949
- | { type: 'vim_delete_word_backward'; payload: { count: number } }
950
- | { type: 'vim_delete_word_end'; payload: { count: number } }
951
- | { type: 'vim_change_word_forward'; payload: { count: number } }
952
- | { type: 'vim_change_word_backward'; payload: { count: number } }
953
- | { type: 'vim_change_word_end'; payload: { count: number } }
954
- | { type: 'vim_delete_line'; payload: { count: number } }
955
- | { type: 'vim_change_line'; payload: { count: number } }
956
- | { type: 'vim_delete_to_end_of_line' }
957
- | { type: 'vim_change_to_end_of_line' }
958
- | {
959
- type: 'vim_change_movement';
960
- payload: { movement: 'h' | 'j' | 'k' | 'l'; count: number };
961
- }
962
- // New vim actions for stateless command handling
963
- | { type: 'vim_move_left'; payload: { count: number } }
964
- | { type: 'vim_move_right'; payload: { count: number } }
965
- | { type: 'vim_move_up'; payload: { count: number } }
966
- | { type: 'vim_move_down'; payload: { count: number } }
967
- | { type: 'vim_move_word_forward'; payload: { count: number } }
968
- | { type: 'vim_move_word_backward'; payload: { count: number } }
969
- | { type: 'vim_move_word_end'; payload: { count: number } }
970
- | { type: 'vim_delete_char'; payload: { count: number } }
971
- | { type: 'vim_insert_at_cursor' }
972
- | { type: 'vim_append_at_cursor' }
973
- | { type: 'vim_open_line_below' }
974
- | { type: 'vim_open_line_above' }
975
- | { type: 'vim_append_at_line_end' }
976
- | { type: 'vim_insert_at_line_start' }
977
- | { type: 'vim_move_to_line_start' }
978
- | { type: 'vim_move_to_line_end' }
979
- | { type: 'vim_move_to_first_nonwhitespace' }
980
- | { type: 'vim_move_to_first_line' }
981
- | { type: 'vim_move_to_last_line' }
982
- | { type: 'vim_move_to_line'; payload: { lineNumber: number } }
983
- | { type: 'vim_escape_insert_mode' };
984
-
985
- export function textBufferReducer(
986
- state: TextBufferState,
987
- action: TextBufferAction,
988
- ): TextBufferState {
989
- const pushUndoLocal = pushUndo;
990
-
991
- const currentLine = (r: number): string => state.lines[r] ?? '';
992
- const currentLineLen = (r: number): number => cpLen(currentLine(r));
993
-
994
- switch (action.type) {
995
- case 'set_text': {
996
- let nextState = state;
997
- if (action.pushToUndo !== false) {
998
- nextState = pushUndoLocal(state);
999
- }
1000
- const newContentLines = action.payload
1001
- .replace(/\r\n?/g, '\n')
1002
- .split('\n');
1003
- const lines = newContentLines.length === 0 ? [''] : newContentLines;
1004
- const lastNewLineIndex = lines.length - 1;
1005
- return {
1006
- ...nextState,
1007
- lines,
1008
- cursorRow: lastNewLineIndex,
1009
- cursorCol: cpLen(lines[lastNewLineIndex] ?? ''),
1010
- preferredCol: null,
1011
- };
1012
- }
1013
-
1014
- case 'insert': {
1015
- const nextState = pushUndoLocal(state);
1016
- const newLines = [...nextState.lines];
1017
- let newCursorRow = nextState.cursorRow;
1018
- let newCursorCol = nextState.cursorCol;
1019
-
1020
- const currentLine = (r: number) => newLines[r] ?? '';
1021
-
1022
- const str = stripUnsafeCharacters(
1023
- action.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'),
1024
- );
1025
- const parts = str.split('\n');
1026
- const lineContent = currentLine(newCursorRow);
1027
- const before = cpSlice(lineContent, 0, newCursorCol);
1028
- const after = cpSlice(lineContent, newCursorCol);
1029
-
1030
- if (parts.length > 1) {
1031
- newLines[newCursorRow] = before + parts[0];
1032
- const remainingParts = parts.slice(1);
1033
- const lastPartOriginal = remainingParts.pop() ?? '';
1034
- newLines.splice(newCursorRow + 1, 0, ...remainingParts);
1035
- newLines.splice(
1036
- newCursorRow + parts.length - 1,
1037
- 0,
1038
- lastPartOriginal + after,
1039
- );
1040
- newCursorRow = newCursorRow + parts.length - 1;
1041
- newCursorCol = cpLen(lastPartOriginal);
1042
- } else {
1043
- newLines[newCursorRow] = before + parts[0] + after;
1044
- newCursorCol = cpLen(before) + cpLen(parts[0]);
1045
- }
1046
-
1047
- return {
1048
- ...nextState,
1049
- lines: newLines,
1050
- cursorRow: newCursorRow,
1051
- cursorCol: newCursorCol,
1052
- preferredCol: null,
1053
- };
1054
- }
1055
-
1056
- case 'backspace': {
1057
- const nextState = pushUndoLocal(state);
1058
- const newLines = [...nextState.lines];
1059
- let newCursorRow = nextState.cursorRow;
1060
- let newCursorCol = nextState.cursorCol;
1061
-
1062
- const currentLine = (r: number) => newLines[r] ?? '';
1063
-
1064
- if (newCursorCol === 0 && newCursorRow === 0) return state;
1065
-
1066
- if (newCursorCol > 0) {
1067
- const lineContent = currentLine(newCursorRow);
1068
- newLines[newCursorRow] =
1069
- cpSlice(lineContent, 0, newCursorCol - 1) +
1070
- cpSlice(lineContent, newCursorCol);
1071
- newCursorCol--;
1072
- } else if (newCursorRow > 0) {
1073
- const prevLineContent = currentLine(newCursorRow - 1);
1074
- const currentLineContentVal = currentLine(newCursorRow);
1075
- const newCol = cpLen(prevLineContent);
1076
- newLines[newCursorRow - 1] = prevLineContent + currentLineContentVal;
1077
- newLines.splice(newCursorRow, 1);
1078
- newCursorRow--;
1079
- newCursorCol = newCol;
1080
- }
1081
-
1082
- return {
1083
- ...nextState,
1084
- lines: newLines,
1085
- cursorRow: newCursorRow,
1086
- cursorCol: newCursorCol,
1087
- preferredCol: null,
1088
- };
1089
- }
1090
-
1091
- case 'set_viewport_width': {
1092
- if (action.payload === state.viewportWidth) {
1093
- return state;
1094
- }
1095
- return { ...state, viewportWidth: action.payload };
1096
- }
1097
-
1098
- case 'move': {
1099
- const { dir } = action.payload;
1100
- const { lines, cursorRow, cursorCol, viewportWidth } = state;
1101
- const visualLayout = calculateVisualLayout(
1102
- lines,
1103
- [cursorRow, cursorCol],
1104
- viewportWidth,
1105
- );
1106
- const { visualLines, visualCursor, visualToLogicalMap } = visualLayout;
1107
-
1108
- let newVisualRow = visualCursor[0];
1109
- let newVisualCol = visualCursor[1];
1110
- let newPreferredCol = state.preferredCol;
1111
-
1112
- const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');
1113
-
1114
- switch (dir) {
1115
- case 'left':
1116
- newPreferredCol = null;
1117
- if (newVisualCol > 0) {
1118
- newVisualCol--;
1119
- } else if (newVisualRow > 0) {
1120
- newVisualRow--;
1121
- newVisualCol = cpLen(visualLines[newVisualRow] ?? '');
1122
- }
1123
- break;
1124
- case 'right':
1125
- newPreferredCol = null;
1126
- if (newVisualCol < currentVisLineLen) {
1127
- newVisualCol++;
1128
- } else if (newVisualRow < visualLines.length - 1) {
1129
- newVisualRow++;
1130
- newVisualCol = 0;
1131
- }
1132
- break;
1133
- case 'up':
1134
- if (newVisualRow > 0) {
1135
- if (newPreferredCol === null) newPreferredCol = newVisualCol;
1136
- newVisualRow--;
1137
- newVisualCol = clamp(
1138
- newPreferredCol,
1139
- 0,
1140
- cpLen(visualLines[newVisualRow] ?? ''),
1141
- );
1142
- }
1143
- break;
1144
- case 'down':
1145
- if (newVisualRow < visualLines.length - 1) {
1146
- if (newPreferredCol === null) newPreferredCol = newVisualCol;
1147
- newVisualRow++;
1148
- newVisualCol = clamp(
1149
- newPreferredCol,
1150
- 0,
1151
- cpLen(visualLines[newVisualRow] ?? ''),
1152
- );
1153
- }
1154
- break;
1155
- case 'home':
1156
- newPreferredCol = null;
1157
- newVisualCol = 0;
1158
- break;
1159
- case 'end':
1160
- newPreferredCol = null;
1161
- newVisualCol = currentVisLineLen;
1162
- break;
1163
- case 'wordLeft': {
1164
- const { cursorRow, cursorCol, lines } = state;
1165
- if (cursorCol === 0 && cursorRow === 0) return state;
1166
-
1167
- let newCursorRow = cursorRow;
1168
- let newCursorCol = cursorCol;
1169
-
1170
- if (cursorCol === 0) {
1171
- newCursorRow--;
1172
- newCursorCol = cpLen(lines[newCursorRow] ?? '');
1173
- } else {
1174
- const lineContent = lines[cursorRow];
1175
- const arr = toCodePoints(lineContent);
1176
- let start = cursorCol;
1177
- let onlySpaces = true;
1178
- for (let i = 0; i < start; i++) {
1179
- if (isWordChar(arr[i])) {
1180
- onlySpaces = false;
1181
- break;
1182
- }
1183
- }
1184
- if (onlySpaces && start > 0) {
1185
- start--;
1186
- } else {
1187
- while (start > 0 && !isWordChar(arr[start - 1])) start--;
1188
- while (start > 0 && isWordChar(arr[start - 1])) start--;
1189
- }
1190
- newCursorCol = start;
1191
- }
1192
- return {
1193
- ...state,
1194
- cursorRow: newCursorRow,
1195
- cursorCol: newCursorCol,
1196
- preferredCol: null,
1197
- };
1198
- }
1199
- case 'wordRight': {
1200
- const { cursorRow, cursorCol, lines } = state;
1201
- if (
1202
- cursorRow === lines.length - 1 &&
1203
- cursorCol === cpLen(lines[cursorRow] ?? '')
1204
- ) {
1205
- return state;
1206
- }
1207
-
1208
- let newCursorRow = cursorRow;
1209
- let newCursorCol = cursorCol;
1210
- const lineContent = lines[cursorRow] ?? '';
1211
- const arr = toCodePoints(lineContent);
1212
-
1213
- if (cursorCol >= arr.length) {
1214
- newCursorRow++;
1215
- newCursorCol = 0;
1216
- } else {
1217
- let end = cursorCol;
1218
- while (end < arr.length && !isWordChar(arr[end])) end++;
1219
- while (end < arr.length && isWordChar(arr[end])) end++;
1220
- newCursorCol = end;
1221
- }
1222
- return {
1223
- ...state,
1224
- cursorRow: newCursorRow,
1225
- cursorCol: newCursorCol,
1226
- preferredCol: null,
1227
- };
1228
- }
1229
- default:
1230
- break;
1231
- }
1232
-
1233
- if (visualToLogicalMap[newVisualRow]) {
1234
- const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
1235
- return {
1236
- ...state,
1237
- cursorRow: logRow,
1238
- cursorCol: clamp(
1239
- logStartCol + newVisualCol,
1240
- 0,
1241
- cpLen(state.lines[logRow] ?? ''),
1242
- ),
1243
- preferredCol: newPreferredCol,
1244
- };
1245
- }
1246
- return state;
1247
- }
1248
-
1249
- case 'delete': {
1250
- const { cursorRow, cursorCol, lines } = state;
1251
- const lineContent = currentLine(cursorRow);
1252
- if (cursorCol < currentLineLen(cursorRow)) {
1253
- const nextState = pushUndoLocal(state);
1254
- const newLines = [...nextState.lines];
1255
- newLines[cursorRow] =
1256
- cpSlice(lineContent, 0, cursorCol) +
1257
- cpSlice(lineContent, cursorCol + 1);
1258
- return { ...nextState, lines: newLines, preferredCol: null };
1259
- } else if (cursorRow < lines.length - 1) {
1260
- const nextState = pushUndoLocal(state);
1261
- const nextLineContent = currentLine(cursorRow + 1);
1262
- const newLines = [...nextState.lines];
1263
- newLines[cursorRow] = lineContent + nextLineContent;
1264
- newLines.splice(cursorRow + 1, 1);
1265
- return { ...nextState, lines: newLines, preferredCol: null };
1266
- }
1267
- return state;
1268
- }
1269
-
1270
- case 'delete_word_left': {
1271
- const { cursorRow, cursorCol } = state;
1272
- if (cursorCol === 0 && cursorRow === 0) return state;
1273
- if (cursorCol === 0) {
1274
- // Act as a backspace
1275
- const nextState = pushUndoLocal(state);
1276
- const prevLineContent = currentLine(cursorRow - 1);
1277
- const currentLineContentVal = currentLine(cursorRow);
1278
- const newCol = cpLen(prevLineContent);
1279
- const newLines = [...nextState.lines];
1280
- newLines[cursorRow - 1] = prevLineContent + currentLineContentVal;
1281
- newLines.splice(cursorRow, 1);
1282
- return {
1283
- ...nextState,
1284
- lines: newLines,
1285
- cursorRow: cursorRow - 1,
1286
- cursorCol: newCol,
1287
- preferredCol: null,
1288
- };
1289
- }
1290
- const nextState = pushUndoLocal(state);
1291
- const lineContent = currentLine(cursorRow);
1292
- const arr = toCodePoints(lineContent);
1293
- let start = cursorCol;
1294
- let onlySpaces = true;
1295
- for (let i = 0; i < start; i++) {
1296
- if (isWordChar(arr[i])) {
1297
- onlySpaces = false;
1298
- break;
1299
- }
1300
- }
1301
- if (onlySpaces && start > 0) {
1302
- start--;
1303
- } else {
1304
- while (start > 0 && !isWordChar(arr[start - 1])) start--;
1305
- while (start > 0 && isWordChar(arr[start - 1])) start--;
1306
- }
1307
- const newLines = [...nextState.lines];
1308
- newLines[cursorRow] =
1309
- cpSlice(lineContent, 0, start) + cpSlice(lineContent, cursorCol);
1310
- return {
1311
- ...nextState,
1312
- lines: newLines,
1313
- cursorCol: start,
1314
- preferredCol: null,
1315
- };
1316
- }
1317
-
1318
- case 'delete_word_right': {
1319
- const { cursorRow, cursorCol, lines } = state;
1320
- const lineContent = currentLine(cursorRow);
1321
- const arr = toCodePoints(lineContent);
1322
- if (cursorCol >= arr.length && cursorRow === lines.length - 1)
1323
- return state;
1324
- if (cursorCol >= arr.length) {
1325
- // Act as a delete
1326
- const nextState = pushUndoLocal(state);
1327
- const nextLineContent = currentLine(cursorRow + 1);
1328
- const newLines = [...nextState.lines];
1329
- newLines[cursorRow] = lineContent + nextLineContent;
1330
- newLines.splice(cursorRow + 1, 1);
1331
- return { ...nextState, lines: newLines, preferredCol: null };
1332
- }
1333
- const nextState = pushUndoLocal(state);
1334
- let end = cursorCol;
1335
- while (end < arr.length && !isWordChar(arr[end])) end++;
1336
- while (end < arr.length && isWordChar(arr[end])) end++;
1337
- const newLines = [...nextState.lines];
1338
- newLines[cursorRow] =
1339
- cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
1340
- return { ...nextState, lines: newLines, preferredCol: null };
1341
- }
1342
-
1343
- case 'kill_line_right': {
1344
- const { cursorRow, cursorCol, lines } = state;
1345
- const lineContent = currentLine(cursorRow);
1346
- if (cursorCol < currentLineLen(cursorRow)) {
1347
- const nextState = pushUndoLocal(state);
1348
- const newLines = [...nextState.lines];
1349
- newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
1350
- return { ...nextState, lines: newLines };
1351
- } else if (cursorRow < lines.length - 1) {
1352
- // Act as a delete
1353
- const nextState = pushUndoLocal(state);
1354
- const nextLineContent = currentLine(cursorRow + 1);
1355
- const newLines = [...nextState.lines];
1356
- newLines[cursorRow] = lineContent + nextLineContent;
1357
- newLines.splice(cursorRow + 1, 1);
1358
- return { ...nextState, lines: newLines, preferredCol: null };
1359
- }
1360
- return state;
1361
- }
1362
-
1363
- case 'kill_line_left': {
1364
- const { cursorRow, cursorCol } = state;
1365
- if (cursorCol > 0) {
1366
- const nextState = pushUndoLocal(state);
1367
- const lineContent = currentLine(cursorRow);
1368
- const newLines = [...nextState.lines];
1369
- newLines[cursorRow] = cpSlice(lineContent, cursorCol);
1370
- return {
1371
- ...nextState,
1372
- lines: newLines,
1373
- cursorCol: 0,
1374
- preferredCol: null,
1375
- };
1376
- }
1377
- return state;
1378
- }
1379
-
1380
- case 'undo': {
1381
- const stateToRestore = state.undoStack[state.undoStack.length - 1];
1382
- if (!stateToRestore) return state;
1383
-
1384
- const currentSnapshot = {
1385
- lines: [...state.lines],
1386
- cursorRow: state.cursorRow,
1387
- cursorCol: state.cursorCol,
1388
- };
1389
- return {
1390
- ...state,
1391
- ...stateToRestore,
1392
- undoStack: state.undoStack.slice(0, -1),
1393
- redoStack: [...state.redoStack, currentSnapshot],
1394
- };
1395
- }
1396
-
1397
- case 'redo': {
1398
- const stateToRestore = state.redoStack[state.redoStack.length - 1];
1399
- if (!stateToRestore) return state;
1400
-
1401
- const currentSnapshot = {
1402
- lines: [...state.lines],
1403
- cursorRow: state.cursorRow,
1404
- cursorCol: state.cursorCol,
1405
- };
1406
- return {
1407
- ...state,
1408
- ...stateToRestore,
1409
- redoStack: state.redoStack.slice(0, -1),
1410
- undoStack: [...state.undoStack, currentSnapshot],
1411
- };
1412
- }
1413
-
1414
- case 'replace_range': {
1415
- const { startRow, startCol, endRow, endCol, text } = action.payload;
1416
- const nextState = pushUndoLocal(state);
1417
- return replaceRangeInternal(
1418
- nextState,
1419
- startRow,
1420
- startCol,
1421
- endRow,
1422
- endCol,
1423
- text,
1424
- );
1425
- }
1426
-
1427
- case 'move_to_offset': {
1428
- const { offset } = action.payload;
1429
- const [newRow, newCol] = offsetToLogicalPos(
1430
- state.lines.join('\n'),
1431
- offset,
1432
- );
1433
- return {
1434
- ...state,
1435
- cursorRow: newRow,
1436
- cursorCol: newCol,
1437
- preferredCol: null,
1438
- };
1439
- }
1440
-
1441
- case 'create_undo_snapshot': {
1442
- return pushUndoLocal(state);
1443
- }
1444
-
1445
- // Vim-specific operations
1446
- case 'vim_delete_word_forward':
1447
- case 'vim_delete_word_backward':
1448
- case 'vim_delete_word_end':
1449
- case 'vim_change_word_forward':
1450
- case 'vim_change_word_backward':
1451
- case 'vim_change_word_end':
1452
- case 'vim_delete_line':
1453
- case 'vim_change_line':
1454
- case 'vim_delete_to_end_of_line':
1455
- case 'vim_change_to_end_of_line':
1456
- case 'vim_change_movement':
1457
- case 'vim_move_left':
1458
- case 'vim_move_right':
1459
- case 'vim_move_up':
1460
- case 'vim_move_down':
1461
- case 'vim_move_word_forward':
1462
- case 'vim_move_word_backward':
1463
- case 'vim_move_word_end':
1464
- case 'vim_delete_char':
1465
- case 'vim_insert_at_cursor':
1466
- case 'vim_append_at_cursor':
1467
- case 'vim_open_line_below':
1468
- case 'vim_open_line_above':
1469
- case 'vim_append_at_line_end':
1470
- case 'vim_insert_at_line_start':
1471
- case 'vim_move_to_line_start':
1472
- case 'vim_move_to_line_end':
1473
- case 'vim_move_to_first_nonwhitespace':
1474
- case 'vim_move_to_first_line':
1475
- case 'vim_move_to_last_line':
1476
- case 'vim_move_to_line':
1477
- case 'vim_escape_insert_mode':
1478
- return handleVimAction(state, action as VimAction);
1479
-
1480
- default: {
1481
- const exhaustiveCheck: never = action;
1482
- console.error(`Unknown action encountered: ${exhaustiveCheck}`);
1483
- return state;
1484
- }
1485
- }
1486
- }
1487
-
1488
- // --- End of reducer logic ---
1489
-
1490
- export function useTextBuffer({
1491
- initialText = '',
1492
- initialCursorOffset = 0,
1493
- viewport,
1494
- stdin,
1495
- setRawMode,
1496
- onChange,
1497
- isValidPath,
1498
- shellModeActive = false,
1499
- }: UseTextBufferProps): TextBuffer {
1500
- const initialState = useMemo((): TextBufferState => {
1501
- const lines = initialText.split('\n');
1502
- const [initialCursorRow, initialCursorCol] = calculateInitialCursorPosition(
1503
- lines.length === 0 ? [''] : lines,
1504
- initialCursorOffset,
1505
- );
1506
- return {
1507
- lines: lines.length === 0 ? [''] : lines,
1508
- cursorRow: initialCursorRow,
1509
- cursorCol: initialCursorCol,
1510
- preferredCol: null,
1511
- undoStack: [],
1512
- redoStack: [],
1513
- clipboard: null,
1514
- selectionAnchor: null,
1515
- viewportWidth: viewport.width,
1516
- };
1517
- }, [initialText, initialCursorOffset, viewport.width]);
1518
-
1519
- const [state, dispatch] = useReducer(textBufferReducer, initialState);
1520
- const { lines, cursorRow, cursorCol, preferredCol, selectionAnchor } = state;
1521
-
1522
- const text = useMemo(() => lines.join('\n'), [lines]);
1523
-
1524
- const visualLayout = useMemo(
1525
- () =>
1526
- calculateVisualLayout(lines, [cursorRow, cursorCol], state.viewportWidth),
1527
- [lines, cursorRow, cursorCol, state.viewportWidth],
1528
- );
1529
-
1530
- const { visualLines, visualCursor } = visualLayout;
1531
-
1532
- const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
1533
-
1534
- useEffect(() => {
1535
- if (onChange) {
1536
- onChange(text);
1537
- }
1538
- }, [text, onChange]);
1539
-
1540
- useEffect(() => {
1541
- dispatch({ type: 'set_viewport_width', payload: viewport.width });
1542
- }, [viewport.width]);
1543
-
1544
- // Update visual scroll (vertical)
1545
- useEffect(() => {
1546
- const { height } = viewport;
1547
- let newVisualScrollRow = visualScrollRow;
1548
-
1549
- if (visualCursor[0] < visualScrollRow) {
1550
- newVisualScrollRow = visualCursor[0];
1551
- } else if (visualCursor[0] >= visualScrollRow + height) {
1552
- newVisualScrollRow = visualCursor[0] - height + 1;
1553
- }
1554
- if (newVisualScrollRow !== visualScrollRow) {
1555
- setVisualScrollRow(newVisualScrollRow);
1556
- }
1557
- }, [visualCursor, visualScrollRow, viewport]);
1558
-
1559
- const insert = useCallback(
1560
- (ch: string, { paste = false }: { paste?: boolean } = {}): void => {
1561
- if (/[\n\r]/.test(ch)) {
1562
- dispatch({ type: 'insert', payload: ch });
1563
- return;
1564
- }
1565
-
1566
- const minLengthToInferAsDragDrop = 3;
1567
- if (
1568
- ch.length >= minLengthToInferAsDragDrop &&
1569
- !shellModeActive &&
1570
- paste
1571
- ) {
1572
- let potentialPath = ch.trim();
1573
- const quoteMatch = potentialPath.match(/^'(.*)'$/);
1574
- if (quoteMatch) {
1575
- potentialPath = quoteMatch[1];
1576
- }
1577
-
1578
- potentialPath = potentialPath.trim();
1579
- if (isValidPath(unescapePath(potentialPath))) {
1580
- ch = `@${potentialPath} `;
1581
- }
1582
- }
1583
-
1584
- let currentText = '';
1585
- for (const char of toCodePoints(ch)) {
1586
- if (char.codePointAt(0) === 127) {
1587
- if (currentText.length > 0) {
1588
- dispatch({ type: 'insert', payload: currentText });
1589
- currentText = '';
1590
- }
1591
- dispatch({ type: 'backspace' });
1592
- } else {
1593
- currentText += char;
1594
- }
1595
- }
1596
- if (currentText.length > 0) {
1597
- dispatch({ type: 'insert', payload: currentText });
1598
- }
1599
- },
1600
- [isValidPath, shellModeActive],
1601
- );
1602
-
1603
- const newline = useCallback((): void => {
1604
- dispatch({ type: 'insert', payload: '\n' });
1605
- }, []);
1606
-
1607
- const backspace = useCallback((): void => {
1608
- dispatch({ type: 'backspace' });
1609
- }, []);
1610
-
1611
- const del = useCallback((): void => {
1612
- dispatch({ type: 'delete' });
1613
- }, []);
1614
-
1615
- const move = useCallback((dir: Direction): void => {
1616
- dispatch({ type: 'move', payload: { dir } });
1617
- }, []);
1618
-
1619
- const undo = useCallback((): void => {
1620
- dispatch({ type: 'undo' });
1621
- }, []);
1622
-
1623
- const redo = useCallback((): void => {
1624
- dispatch({ type: 'redo' });
1625
- }, []);
1626
-
1627
- const setText = useCallback((newText: string): void => {
1628
- dispatch({ type: 'set_text', payload: newText });
1629
- }, []);
1630
-
1631
- const deleteWordLeft = useCallback((): void => {
1632
- dispatch({ type: 'delete_word_left' });
1633
- }, []);
1634
-
1635
- const deleteWordRight = useCallback((): void => {
1636
- dispatch({ type: 'delete_word_right' });
1637
- }, []);
1638
-
1639
- const killLineRight = useCallback((): void => {
1640
- dispatch({ type: 'kill_line_right' });
1641
- }, []);
1642
-
1643
- const killLineLeft = useCallback((): void => {
1644
- dispatch({ type: 'kill_line_left' });
1645
- }, []);
1646
-
1647
- // Vim-specific operations
1648
- const vimDeleteWordForward = useCallback((count: number): void => {
1649
- dispatch({ type: 'vim_delete_word_forward', payload: { count } });
1650
- }, []);
1651
-
1652
- const vimDeleteWordBackward = useCallback((count: number): void => {
1653
- dispatch({ type: 'vim_delete_word_backward', payload: { count } });
1654
- }, []);
1655
-
1656
- const vimDeleteWordEnd = useCallback((count: number): void => {
1657
- dispatch({ type: 'vim_delete_word_end', payload: { count } });
1658
- }, []);
1659
-
1660
- const vimChangeWordForward = useCallback((count: number): void => {
1661
- dispatch({ type: 'vim_change_word_forward', payload: { count } });
1662
- }, []);
1663
-
1664
- const vimChangeWordBackward = useCallback((count: number): void => {
1665
- dispatch({ type: 'vim_change_word_backward', payload: { count } });
1666
- }, []);
1667
-
1668
- const vimChangeWordEnd = useCallback((count: number): void => {
1669
- dispatch({ type: 'vim_change_word_end', payload: { count } });
1670
- }, []);
1671
-
1672
- const vimDeleteLine = useCallback((count: number): void => {
1673
- dispatch({ type: 'vim_delete_line', payload: { count } });
1674
- }, []);
1675
-
1676
- const vimChangeLine = useCallback((count: number): void => {
1677
- dispatch({ type: 'vim_change_line', payload: { count } });
1678
- }, []);
1679
-
1680
- const vimDeleteToEndOfLine = useCallback((): void => {
1681
- dispatch({ type: 'vim_delete_to_end_of_line' });
1682
- }, []);
1683
-
1684
- const vimChangeToEndOfLine = useCallback((): void => {
1685
- dispatch({ type: 'vim_change_to_end_of_line' });
1686
- }, []);
1687
-
1688
- const vimChangeMovement = useCallback(
1689
- (movement: 'h' | 'j' | 'k' | 'l', count: number): void => {
1690
- dispatch({ type: 'vim_change_movement', payload: { movement, count } });
1691
- },
1692
- [],
1693
- );
1694
-
1695
- // New vim navigation and operation methods
1696
- const vimMoveLeft = useCallback((count: number): void => {
1697
- dispatch({ type: 'vim_move_left', payload: { count } });
1698
- }, []);
1699
-
1700
- const vimMoveRight = useCallback((count: number): void => {
1701
- dispatch({ type: 'vim_move_right', payload: { count } });
1702
- }, []);
1703
-
1704
- const vimMoveUp = useCallback((count: number): void => {
1705
- dispatch({ type: 'vim_move_up', payload: { count } });
1706
- }, []);
1707
-
1708
- const vimMoveDown = useCallback((count: number): void => {
1709
- dispatch({ type: 'vim_move_down', payload: { count } });
1710
- }, []);
1711
-
1712
- const vimMoveWordForward = useCallback((count: number): void => {
1713
- dispatch({ type: 'vim_move_word_forward', payload: { count } });
1714
- }, []);
1715
-
1716
- const vimMoveWordBackward = useCallback((count: number): void => {
1717
- dispatch({ type: 'vim_move_word_backward', payload: { count } });
1718
- }, []);
1719
-
1720
- const vimMoveWordEnd = useCallback((count: number): void => {
1721
- dispatch({ type: 'vim_move_word_end', payload: { count } });
1722
- }, []);
1723
-
1724
- const vimDeleteChar = useCallback((count: number): void => {
1725
- dispatch({ type: 'vim_delete_char', payload: { count } });
1726
- }, []);
1727
-
1728
- const vimInsertAtCursor = useCallback((): void => {
1729
- dispatch({ type: 'vim_insert_at_cursor' });
1730
- }, []);
1731
-
1732
- const vimAppendAtCursor = useCallback((): void => {
1733
- dispatch({ type: 'vim_append_at_cursor' });
1734
- }, []);
1735
-
1736
- const vimOpenLineBelow = useCallback((): void => {
1737
- dispatch({ type: 'vim_open_line_below' });
1738
- }, []);
1739
-
1740
- const vimOpenLineAbove = useCallback((): void => {
1741
- dispatch({ type: 'vim_open_line_above' });
1742
- }, []);
1743
-
1744
- const vimAppendAtLineEnd = useCallback((): void => {
1745
- dispatch({ type: 'vim_append_at_line_end' });
1746
- }, []);
1747
-
1748
- const vimInsertAtLineStart = useCallback((): void => {
1749
- dispatch({ type: 'vim_insert_at_line_start' });
1750
- }, []);
1751
-
1752
- const vimMoveToLineStart = useCallback((): void => {
1753
- dispatch({ type: 'vim_move_to_line_start' });
1754
- }, []);
1755
-
1756
- const vimMoveToLineEnd = useCallback((): void => {
1757
- dispatch({ type: 'vim_move_to_line_end' });
1758
- }, []);
1759
-
1760
- const vimMoveToFirstNonWhitespace = useCallback((): void => {
1761
- dispatch({ type: 'vim_move_to_first_nonwhitespace' });
1762
- }, []);
1763
-
1764
- const vimMoveToFirstLine = useCallback((): void => {
1765
- dispatch({ type: 'vim_move_to_first_line' });
1766
- }, []);
1767
-
1768
- const vimMoveToLastLine = useCallback((): void => {
1769
- dispatch({ type: 'vim_move_to_last_line' });
1770
- }, []);
1771
-
1772
- const vimMoveToLine = useCallback((lineNumber: number): void => {
1773
- dispatch({ type: 'vim_move_to_line', payload: { lineNumber } });
1774
- }, []);
1775
-
1776
- const vimEscapeInsertMode = useCallback((): void => {
1777
- dispatch({ type: 'vim_escape_insert_mode' });
1778
- }, []);
1779
-
1780
- const openInExternalEditor = useCallback(
1781
- async (opts: { editor?: string } = {}): Promise<void> => {
1782
- const editor =
1783
- opts.editor ??
1784
- process.env['VISUAL'] ??
1785
- process.env['EDITOR'] ??
1786
- (process.platform === 'win32' ? 'notepad' : 'vi');
1787
- const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-'));
1788
- const filePath = pathMod.join(tmpDir, 'buffer.txt');
1789
- fs.writeFileSync(filePath, text, 'utf8');
1790
-
1791
- dispatch({ type: 'create_undo_snapshot' });
1792
-
1793
- const wasRaw = stdin?.isRaw ?? false;
1794
- try {
1795
- setRawMode?.(false);
1796
- const { status, error } = spawnSync(editor, [filePath], {
1797
- stdio: 'inherit',
1798
- });
1799
- if (error) throw error;
1800
- if (typeof status === 'number' && status !== 0)
1801
- throw new Error(`External editor exited with status ${status}`);
1802
-
1803
- let newText = fs.readFileSync(filePath, 'utf8');
1804
- newText = newText.replace(/\r\n?/g, '\n');
1805
- dispatch({ type: 'set_text', payload: newText, pushToUndo: false });
1806
- } catch (err) {
1807
- console.error('[useTextBuffer] external editor error', err);
1808
- } finally {
1809
- if (wasRaw) setRawMode?.(true);
1810
- try {
1811
- fs.unlinkSync(filePath);
1812
- } catch {
1813
- /* ignore */
1814
- }
1815
- try {
1816
- fs.rmdirSync(tmpDir);
1817
- } catch {
1818
- /* ignore */
1819
- }
1820
- }
1821
- },
1822
- [text, stdin, setRawMode],
1823
- );
1824
-
1825
- const handleInput = useCallback(
1826
- (key: {
1827
- name: string;
1828
- ctrl: boolean;
1829
- meta: boolean;
1830
- shift: boolean;
1831
- paste: boolean;
1832
- sequence: string;
1833
- }): void => {
1834
- const { sequence: input } = key;
1835
-
1836
- if (key.paste) {
1837
- // Do not do any other processing on pastes so ensure we handle them
1838
- // before all other cases.
1839
- insert(input, { paste: key.paste });
1840
- return;
1841
- }
1842
-
1843
- if (
1844
- key.name === 'return' ||
1845
- input === '\r' ||
1846
- input === '\n' ||
1847
- input === '\\\r' // VSCode terminal represents shift + enter this way
1848
- )
1849
- newline();
1850
- else if (key.name === 'left' && !key.meta && !key.ctrl) move('left');
1851
- else if (key.ctrl && key.name === 'b') move('left');
1852
- else if (key.name === 'right' && !key.meta && !key.ctrl) move('right');
1853
- else if (key.ctrl && key.name === 'f') move('right');
1854
- else if (key.name === 'up') move('up');
1855
- else if (key.name === 'down') move('down');
1856
- else if ((key.ctrl || key.meta) && key.name === 'left') move('wordLeft');
1857
- else if (key.meta && key.name === 'b') move('wordLeft');
1858
- else if ((key.ctrl || key.meta) && key.name === 'right')
1859
- move('wordRight');
1860
- else if (key.meta && key.name === 'f') move('wordRight');
1861
- else if (key.name === 'home') move('home');
1862
- else if (key.ctrl && key.name === 'a') move('home');
1863
- else if (key.name === 'end') move('end');
1864
- else if (key.ctrl && key.name === 'e') move('end');
1865
- else if (key.ctrl && key.name === 'w') deleteWordLeft();
1866
- else if (
1867
- (key.meta || key.ctrl) &&
1868
- (key.name === 'backspace' || input === '\x7f')
1869
- )
1870
- deleteWordLeft();
1871
- else if ((key.meta || key.ctrl) && key.name === 'delete')
1872
- deleteWordRight();
1873
- else if (
1874
- key.name === 'backspace' ||
1875
- input === '\x7f' ||
1876
- (key.ctrl && key.name === 'h')
1877
- )
1878
- backspace();
1879
- else if (key.name === 'delete' || (key.ctrl && key.name === 'd')) del();
1880
- else if (input && !key.ctrl && !key.meta) {
1881
- insert(input, { paste: key.paste });
1882
- }
1883
- },
1884
- [newline, move, deleteWordLeft, deleteWordRight, backspace, del, insert],
1885
- );
1886
-
1887
- const renderedVisualLines = useMemo(
1888
- () => visualLines.slice(visualScrollRow, visualScrollRow + viewport.height),
1889
- [visualLines, visualScrollRow, viewport.height],
1890
- );
1891
-
1892
- const replaceRange = useCallback(
1893
- (
1894
- startRow: number,
1895
- startCol: number,
1896
- endRow: number,
1897
- endCol: number,
1898
- text: string,
1899
- ): void => {
1900
- dispatch({
1901
- type: 'replace_range',
1902
- payload: { startRow, startCol, endRow, endCol, text },
1903
- });
1904
- },
1905
- [],
1906
- );
1907
-
1908
- const replaceRangeByOffset = useCallback(
1909
- (startOffset: number, endOffset: number, replacementText: string): void => {
1910
- const [startRow, startCol] = offsetToLogicalPos(text, startOffset);
1911
- const [endRow, endCol] = offsetToLogicalPos(text, endOffset);
1912
- replaceRange(startRow, startCol, endRow, endCol, replacementText);
1913
- },
1914
- [text, replaceRange],
1915
- );
1916
-
1917
- const moveToOffset = useCallback((offset: number): void => {
1918
- dispatch({ type: 'move_to_offset', payload: { offset } });
1919
- }, []);
1920
-
1921
- const returnValue: TextBuffer = {
1922
- lines,
1923
- text,
1924
- cursor: [cursorRow, cursorCol],
1925
- preferredCol,
1926
- selectionAnchor,
1927
-
1928
- allVisualLines: visualLines,
1929
- viewportVisualLines: renderedVisualLines,
1930
- visualCursor,
1931
- visualScrollRow,
1932
-
1933
- setText,
1934
- insert,
1935
- newline,
1936
- backspace,
1937
- del,
1938
- move,
1939
- undo,
1940
- redo,
1941
- replaceRange,
1942
- replaceRangeByOffset,
1943
- moveToOffset,
1944
- deleteWordLeft,
1945
- deleteWordRight,
1946
- killLineRight,
1947
- killLineLeft,
1948
- handleInput,
1949
- openInExternalEditor,
1950
- // Vim-specific operations
1951
- vimDeleteWordForward,
1952
- vimDeleteWordBackward,
1953
- vimDeleteWordEnd,
1954
- vimChangeWordForward,
1955
- vimChangeWordBackward,
1956
- vimChangeWordEnd,
1957
- vimDeleteLine,
1958
- vimChangeLine,
1959
- vimDeleteToEndOfLine,
1960
- vimChangeToEndOfLine,
1961
- vimChangeMovement,
1962
- vimMoveLeft,
1963
- vimMoveRight,
1964
- vimMoveUp,
1965
- vimMoveDown,
1966
- vimMoveWordForward,
1967
- vimMoveWordBackward,
1968
- vimMoveWordEnd,
1969
- vimDeleteChar,
1970
- vimInsertAtCursor,
1971
- vimAppendAtCursor,
1972
- vimOpenLineBelow,
1973
- vimOpenLineAbove,
1974
- vimAppendAtLineEnd,
1975
- vimInsertAtLineStart,
1976
- vimMoveToLineStart,
1977
- vimMoveToLineEnd,
1978
- vimMoveToFirstNonWhitespace,
1979
- vimMoveToFirstLine,
1980
- vimMoveToLastLine,
1981
- vimMoveToLine,
1982
- vimEscapeInsertMode,
1983
- };
1984
- return returnValue;
1985
- }
1986
-
1987
- export interface TextBuffer {
1988
- // State
1989
- lines: string[]; // Logical lines
1990
- text: string;
1991
- cursor: [number, number]; // Logical cursor [row, col]
1992
- /**
1993
- * When the user moves the caret vertically we try to keep their original
1994
- * horizontal column even when passing through shorter lines. We remember
1995
- * that *preferred* column in this field while the user is still travelling
1996
- * vertically. Any explicit horizontal movement resets the preference.
1997
- */
1998
- preferredCol: number | null; // Preferred visual column
1999
- selectionAnchor: [number, number] | null; // Logical selection anchor
2000
-
2001
- // Visual state (handles wrapping)
2002
- allVisualLines: string[]; // All visual lines for the current text and viewport width.
2003
- viewportVisualLines: string[]; // The subset of visual lines to be rendered based on visualScrollRow and viewport.height
2004
- visualCursor: [number, number]; // Visual cursor [row, col] relative to the start of all visualLines
2005
- visualScrollRow: number; // Scroll position for visual lines (index of the first visible visual line)
2006
-
2007
- // Actions
2008
-
2009
- /**
2010
- * Replaces the entire buffer content with the provided text.
2011
- * The operation is undoable.
2012
- */
2013
- setText: (text: string) => void;
2014
- /**
2015
- * Insert a single character or string without newlines.
2016
- */
2017
- insert: (ch: string, opts?: { paste?: boolean }) => void;
2018
- newline: () => void;
2019
- backspace: () => void;
2020
- del: () => void;
2021
- move: (dir: Direction) => void;
2022
- undo: () => void;
2023
- redo: () => void;
2024
- /**
2025
- * Replaces the text within the specified range with new text.
2026
- * Handles both single-line and multi-line ranges.
2027
- *
2028
- * @param startRow The starting row index (inclusive).
2029
- * @param startCol The starting column index (inclusive, code-point based).
2030
- * @param endRow The ending row index (inclusive).
2031
- * @param endCol The ending column index (exclusive, code-point based).
2032
- * @param text The new text to insert.
2033
- * @returns True if the buffer was modified, false otherwise.
2034
- */
2035
- replaceRange: (
2036
- startRow: number,
2037
- startCol: number,
2038
- endRow: number,
2039
- endCol: number,
2040
- text: string,
2041
- ) => void;
2042
- /**
2043
- * Delete the word to the *left* of the caret, mirroring common
2044
- * Ctrl/Alt+Backspace behaviour in editors & terminals. Both the adjacent
2045
- * whitespace *and* the word characters immediately preceding the caret are
2046
- * removed. If the caret is already at column‑0 this becomes a no-op.
2047
- */
2048
- deleteWordLeft: () => void;
2049
- /**
2050
- * Delete the word to the *right* of the caret, akin to many editors'
2051
- * Ctrl/Alt+Delete shortcut. Removes any whitespace/punctuation that
2052
- * follows the caret and the next contiguous run of word characters.
2053
- */
2054
- deleteWordRight: () => void;
2055
- /**
2056
- * Deletes text from the cursor to the end of the current line.
2057
- */
2058
- killLineRight: () => void;
2059
- /**
2060
- * Deletes text from the start of the current line to the cursor.
2061
- */
2062
- killLineLeft: () => void;
2063
- /**
2064
- * High level "handleInput" – receives what Ink gives us.
2065
- */
2066
- handleInput: (key: {
2067
- name: string;
2068
- ctrl: boolean;
2069
- meta: boolean;
2070
- shift: boolean;
2071
- paste: boolean;
2072
- sequence: string;
2073
- }) => void;
2074
- /**
2075
- * Opens the current buffer contents in the user's preferred terminal text
2076
- * editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks
2077
- * until the editor exits, then reloads the file and replaces the in‑memory
2078
- * buffer with whatever the user saved.
2079
- *
2080
- * The operation is treated as a single undoable edit – we snapshot the
2081
- * previous state *once* before launching the editor so one `undo()` will
2082
- * revert the entire change set.
2083
- *
2084
- * Note: We purposefully rely on the *synchronous* spawn API so that the
2085
- * calling process genuinely waits for the editor to close before
2086
- * continuing. This mirrors Git's behaviour and simplifies downstream
2087
- * control‑flow (callers can simply `await` the Promise).
2088
- */
2089
- openInExternalEditor: (opts?: { editor?: string }) => Promise<void>;
2090
-
2091
- replaceRangeByOffset: (
2092
- startOffset: number,
2093
- endOffset: number,
2094
- replacementText: string,
2095
- ) => void;
2096
- moveToOffset(offset: number): void;
2097
-
2098
- // Vim-specific operations
2099
- /**
2100
- * Delete N words forward from cursor position (vim 'dw' command)
2101
- */
2102
- vimDeleteWordForward: (count: number) => void;
2103
- /**
2104
- * Delete N words backward from cursor position (vim 'db' command)
2105
- */
2106
- vimDeleteWordBackward: (count: number) => void;
2107
- /**
2108
- * Delete to end of N words from cursor position (vim 'de' command)
2109
- */
2110
- vimDeleteWordEnd: (count: number) => void;
2111
- /**
2112
- * Change N words forward from cursor position (vim 'cw' command)
2113
- */
2114
- vimChangeWordForward: (count: number) => void;
2115
- /**
2116
- * Change N words backward from cursor position (vim 'cb' command)
2117
- */
2118
- vimChangeWordBackward: (count: number) => void;
2119
- /**
2120
- * Change to end of N words from cursor position (vim 'ce' command)
2121
- */
2122
- vimChangeWordEnd: (count: number) => void;
2123
- /**
2124
- * Delete N lines from cursor position (vim 'dd' command)
2125
- */
2126
- vimDeleteLine: (count: number) => void;
2127
- /**
2128
- * Change N lines from cursor position (vim 'cc' command)
2129
- */
2130
- vimChangeLine: (count: number) => void;
2131
- /**
2132
- * Delete from cursor to end of line (vim 'D' command)
2133
- */
2134
- vimDeleteToEndOfLine: () => void;
2135
- /**
2136
- * Change from cursor to end of line (vim 'C' command)
2137
- */
2138
- vimChangeToEndOfLine: () => void;
2139
- /**
2140
- * Change movement operations (vim 'ch', 'cj', 'ck', 'cl' commands)
2141
- */
2142
- vimChangeMovement: (movement: 'h' | 'j' | 'k' | 'l', count: number) => void;
2143
- /**
2144
- * Move cursor left N times (vim 'h' command)
2145
- */
2146
- vimMoveLeft: (count: number) => void;
2147
- /**
2148
- * Move cursor right N times (vim 'l' command)
2149
- */
2150
- vimMoveRight: (count: number) => void;
2151
- /**
2152
- * Move cursor up N times (vim 'k' command)
2153
- */
2154
- vimMoveUp: (count: number) => void;
2155
- /**
2156
- * Move cursor down N times (vim 'j' command)
2157
- */
2158
- vimMoveDown: (count: number) => void;
2159
- /**
2160
- * Move cursor forward N words (vim 'w' command)
2161
- */
2162
- vimMoveWordForward: (count: number) => void;
2163
- /**
2164
- * Move cursor backward N words (vim 'b' command)
2165
- */
2166
- vimMoveWordBackward: (count: number) => void;
2167
- /**
2168
- * Move cursor to end of Nth word (vim 'e' command)
2169
- */
2170
- vimMoveWordEnd: (count: number) => void;
2171
- /**
2172
- * Delete N characters at cursor (vim 'x' command)
2173
- */
2174
- vimDeleteChar: (count: number) => void;
2175
- /**
2176
- * Enter insert mode at cursor (vim 'i' command)
2177
- */
2178
- vimInsertAtCursor: () => void;
2179
- /**
2180
- * Enter insert mode after cursor (vim 'a' command)
2181
- */
2182
- vimAppendAtCursor: () => void;
2183
- /**
2184
- * Open new line below and enter insert mode (vim 'o' command)
2185
- */
2186
- vimOpenLineBelow: () => void;
2187
- /**
2188
- * Open new line above and enter insert mode (vim 'O' command)
2189
- */
2190
- vimOpenLineAbove: () => void;
2191
- /**
2192
- * Move to end of line and enter insert mode (vim 'A' command)
2193
- */
2194
- vimAppendAtLineEnd: () => void;
2195
- /**
2196
- * Move to first non-whitespace and enter insert mode (vim 'I' command)
2197
- */
2198
- vimInsertAtLineStart: () => void;
2199
- /**
2200
- * Move cursor to beginning of line (vim '0' command)
2201
- */
2202
- vimMoveToLineStart: () => void;
2203
- /**
2204
- * Move cursor to end of line (vim '$' command)
2205
- */
2206
- vimMoveToLineEnd: () => void;
2207
- /**
2208
- * Move cursor to first non-whitespace character (vim '^' command)
2209
- */
2210
- vimMoveToFirstNonWhitespace: () => void;
2211
- /**
2212
- * Move cursor to first line (vim 'gg' command)
2213
- */
2214
- vimMoveToFirstLine: () => void;
2215
- /**
2216
- * Move cursor to last line (vim 'G' command)
2217
- */
2218
- vimMoveToLastLine: () => void;
2219
- /**
2220
- * Move cursor to specific line number (vim '[N]G' command)
2221
- */
2222
- vimMoveToLine: (lineNumber: number) => void;
2223
- /**
2224
- * Handle escape from insert mode (moves cursor left if not at line start)
2225
- */
2226
- vimEscapeInsertMode: () => void;
2227
- }