@abacus-ai/cli 1.106.25007 → 2.0.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/.oxlintrc.json +8 -0
  2. package/dist/index.mjs +12603 -0
  3. package/package.json +7 -39
  4. package/resources/abacus.ico +0 -0
  5. package/resources/entitlements.plist +9 -0
  6. package/src/__e2e__/README.md +196 -0
  7. package/src/__e2e__/agent-interactions.e2e.test.tsx +61 -0
  8. package/src/__e2e__/cli-commands.e2e.test.tsx +77 -0
  9. package/src/__e2e__/conversation-throttle.e2e.test.ts +453 -0
  10. package/src/__e2e__/conversation.e2e.test.tsx +56 -0
  11. package/src/__e2e__/diff-preview.e2e.test.tsx +3399 -0
  12. package/src/__e2e__/file-creation.e2e.test.tsx +149 -0
  13. package/src/__e2e__/helpers/test-helpers.ts +450 -0
  14. package/src/__e2e__/keyboard-navigation.e2e.test.tsx +34 -0
  15. package/src/__e2e__/llm-models.e2e.test.ts +402 -0
  16. package/src/__e2e__/mcp/mcp-callback-flow.e2e.test.tsx +71 -0
  17. package/src/__e2e__/mcp/mcp-full-app-ui.e2e.test.tsx +167 -0
  18. package/src/__e2e__/mcp/mcp-ui-rendering.e2e.test.tsx +185 -0
  19. package/src/__e2e__/repl.e2e.test.tsx +78 -0
  20. package/src/__e2e__/shell-compatibility.e2e.test.tsx +76 -0
  21. package/src/__e2e__/theme-mcp.e2e.test.tsx +98 -0
  22. package/src/__e2e__/tool-permissions.e2e.test.tsx +66 -0
  23. package/src/args.ts +22 -0
  24. package/src/components/__tests__/react-compiler.test.tsx +78 -0
  25. package/src/components/__tests__/status-indicator.test.tsx +403 -0
  26. package/src/components/composer/__tests__/bash-runner.test.tsx +263 -0
  27. package/src/components/composer/agent-mode-indicator.tsx +63 -0
  28. package/src/components/composer/bash-runner.tsx +54 -0
  29. package/src/components/composer/commands/default-commands.tsx +615 -0
  30. package/src/components/composer/commands/handler.tsx +59 -0
  31. package/src/components/composer/commands/picker.tsx +273 -0
  32. package/src/components/composer/commands/registry.ts +233 -0
  33. package/src/components/composer/commands/types.ts +33 -0
  34. package/src/components/composer/context.tsx +88 -0
  35. package/src/components/composer/file-mention-picker.tsx +83 -0
  36. package/src/components/composer/help.tsx +44 -0
  37. package/src/components/composer/index.tsx +1006 -0
  38. package/src/components/composer/mentions.ts +57 -0
  39. package/src/components/composer/message-queue.tsx +70 -0
  40. package/src/components/composer/mode-panel.tsx +35 -0
  41. package/src/components/composer/modes/__tests__/bash-handler.test.tsx +755 -0
  42. package/src/components/composer/modes/__tests__/bash-renderer.test.tsx +1108 -0
  43. package/src/components/composer/modes/bash-handler.tsx +132 -0
  44. package/src/components/composer/modes/bash-renderer.tsx +175 -0
  45. package/src/components/composer/modes/default-handlers.tsx +33 -0
  46. package/src/components/composer/modes/index.ts +41 -0
  47. package/src/components/composer/modes/types.ts +21 -0
  48. package/src/components/composer/persistent-shell.ts +283 -0
  49. package/src/components/composer/process.ts +65 -0
  50. package/src/components/composer/types.ts +9 -0
  51. package/src/components/composer/use-mention-search.ts +68 -0
  52. package/src/components/error-boundry.tsx +60 -0
  53. package/src/components/exit-message.tsx +29 -0
  54. package/src/components/expanded-view.tsx +74 -0
  55. package/src/components/file-completion.tsx +127 -0
  56. package/src/components/header.tsx +47 -0
  57. package/src/components/logo.tsx +37 -0
  58. package/src/components/segments.tsx +356 -0
  59. package/src/components/status-indicator.tsx +306 -0
  60. package/src/components/tool-group-summary.tsx +263 -0
  61. package/src/components/tool-permissions/ask-user-question-permission-ui.tsx +312 -0
  62. package/src/components/tool-permissions/diff-preview.tsx +355 -0
  63. package/src/components/tool-permissions/index.ts +5 -0
  64. package/src/components/tool-permissions/permission-options.tsx +375 -0
  65. package/src/components/tool-permissions/permission-preview-header.tsx +57 -0
  66. package/src/components/tool-permissions/tool-permission-ui.tsx +398 -0
  67. package/src/components/tools/agent/ask-user-question.tsx +101 -0
  68. package/src/components/tools/agent/enter-plan-mode.tsx +49 -0
  69. package/src/components/tools/agent/exit-plan-mode.tsx +75 -0
  70. package/src/components/tools/agent/handoff-to-main.tsx +27 -0
  71. package/src/components/tools/agent/subagent.tsx +37 -0
  72. package/src/components/tools/agent/todo-write.tsx +104 -0
  73. package/src/components/tools/browser/close-tab.tsx +58 -0
  74. package/src/components/tools/browser/computer.tsx +70 -0
  75. package/src/components/tools/browser/get-interactive-elements.tsx +54 -0
  76. package/src/components/tools/browser/get-tab-content.tsx +51 -0
  77. package/src/components/tools/browser/navigate-to.tsx +59 -0
  78. package/src/components/tools/browser/new-tab.tsx +60 -0
  79. package/src/components/tools/browser/perform-action.tsx +63 -0
  80. package/src/components/tools/browser/refresh-tab.tsx +43 -0
  81. package/src/components/tools/browser/switch-tab.tsx +58 -0
  82. package/src/components/tools/filesystem/delete-file.tsx +104 -0
  83. package/src/components/tools/filesystem/edit.tsx +220 -0
  84. package/src/components/tools/filesystem/list-dir.tsx +78 -0
  85. package/src/components/tools/filesystem/read-file.tsx +180 -0
  86. package/src/components/tools/filesystem/upload-image.tsx +76 -0
  87. package/src/components/tools/ide/ide-diagnostics.tsx +62 -0
  88. package/src/components/tools/index.ts +91 -0
  89. package/src/components/tools/mcp/mcp-tool.tsx +158 -0
  90. package/src/components/tools/search/fetch-url.tsx +73 -0
  91. package/src/components/tools/search/file-search.tsx +78 -0
  92. package/src/components/tools/search/grep.tsx +90 -0
  93. package/src/components/tools/search/semantic-search.tsx +66 -0
  94. package/src/components/tools/search/web-search.tsx +71 -0
  95. package/src/components/tools/shared/index.tsx +48 -0
  96. package/src/components/tools/shared/zod-coercion.ts +35 -0
  97. package/src/components/tools/terminal/bash-tool-output.tsx +174 -0
  98. package/src/components/tools/terminal/get-terminal-output.tsx +85 -0
  99. package/src/components/tools/terminal/run-in-terminal.tsx +106 -0
  100. package/src/components/tools/types.ts +16 -0
  101. package/src/components/tools.tsx +66 -0
  102. package/src/components/ui/__tests__/divider.test.tsx +61 -0
  103. package/src/components/ui/__tests__/gradient.test.tsx +125 -0
  104. package/src/components/ui/__tests__/input.test.tsx +166 -0
  105. package/src/components/ui/__tests__/select.test.tsx +273 -0
  106. package/src/components/ui/__tests__/shimmer.test.tsx +99 -0
  107. package/src/components/ui/blinking-indicator.tsx +25 -0
  108. package/src/components/ui/divider.tsx +162 -0
  109. package/src/components/ui/gradient.tsx +56 -0
  110. package/src/components/ui/input.tsx +228 -0
  111. package/src/components/ui/select.tsx +151 -0
  112. package/src/components/ui/shimmer.tsx +84 -0
  113. package/src/context/agent-mode.tsx +95 -0
  114. package/src/context/extension-file.tsx +136 -0
  115. package/src/context/network-activity.tsx +45 -0
  116. package/src/context/notification.tsx +62 -0
  117. package/src/context/shell-size.tsx +49 -0
  118. package/src/context/shell-title.tsx +38 -0
  119. package/src/entrypoints/print-mode.ts +312 -0
  120. package/src/entrypoints/repl.tsx +401 -0
  121. package/src/hooks/use-agent.ts +15 -0
  122. package/src/hooks/use-api-client.ts +1 -0
  123. package/src/hooks/use-available-height.ts +8 -0
  124. package/src/hooks/use-cleanup.ts +29 -0
  125. package/src/hooks/use-interrupt-manager.ts +242 -0
  126. package/src/hooks/use-models.ts +22 -0
  127. package/src/index.ts +217 -0
  128. package/src/lib/__tests__/ansi.test.ts +255 -0
  129. package/src/lib/__tests__/cli.test.ts +122 -0
  130. package/src/lib/__tests__/commands.test.ts +325 -0
  131. package/src/lib/__tests__/constants.test.ts +15 -0
  132. package/src/lib/__tests__/focusables.test.ts +25 -0
  133. package/src/lib/__tests__/fs.test.ts +231 -0
  134. package/src/lib/__tests__/markdown.test.tsx +348 -0
  135. package/src/lib/__tests__/mcpCommandHandler.test.ts +173 -0
  136. package/src/lib/__tests__/mcpManagement.test.ts +38 -0
  137. package/src/lib/__tests__/path-paste.test.ts +144 -0
  138. package/src/lib/__tests__/path.test.ts +300 -0
  139. package/src/lib/__tests__/queries.test.ts +39 -0
  140. package/src/lib/__tests__/standaloneMcpService.test.ts +71 -0
  141. package/src/lib/__tests__/text-buffer.test.ts +328 -0
  142. package/src/lib/__tests__/text-utils.test.ts +32 -0
  143. package/src/lib/__tests__/timing.test.ts +78 -0
  144. package/src/lib/__tests__/utils.test.ts +238 -0
  145. package/src/lib/__tests__/vim-buffer-actions.test.ts +154 -0
  146. package/src/lib/ansi.ts +150 -0
  147. package/src/lib/cli-push-server.ts +112 -0
  148. package/src/lib/cli.ts +44 -0
  149. package/src/lib/clipboard.ts +226 -0
  150. package/src/lib/command-utils.ts +93 -0
  151. package/src/lib/commands.ts +270 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/extension-connection.ts +181 -0
  154. package/src/lib/focusables.ts +7 -0
  155. package/src/lib/fs.ts +533 -0
  156. package/src/lib/markdown/code-block.tsx +63 -0
  157. package/src/lib/markdown/index.ts +4 -0
  158. package/src/lib/markdown/link.tsx +19 -0
  159. package/src/lib/markdown/markdown.tsx +372 -0
  160. package/src/lib/markdown/types.ts +15 -0
  161. package/src/lib/mcpCommandHandler.ts +121 -0
  162. package/src/lib/mcpManagement.ts +44 -0
  163. package/src/lib/path-paste.ts +185 -0
  164. package/src/lib/path.ts +179 -0
  165. package/src/lib/queries.ts +15 -0
  166. package/src/lib/standaloneMcpService.ts +688 -0
  167. package/src/lib/status-utils.ts +237 -0
  168. package/src/lib/test-utils.tsx +72 -0
  169. package/src/lib/text-buffer.ts +2415 -0
  170. package/src/lib/text-utils.ts +272 -0
  171. package/src/lib/timing.ts +63 -0
  172. package/src/lib/types.ts +295 -0
  173. package/src/lib/utils.ts +182 -0
  174. package/src/lib/vim-buffer-actions.ts +732 -0
  175. package/src/providers/agent.tsx +1075 -0
  176. package/src/providers/api-client.tsx +43 -0
  177. package/src/services/logger.ts +85 -0
  178. package/src/terminal/detection.ts +187 -0
  179. package/src/terminal/exit.ts +279 -0
  180. package/src/terminal/notification.ts +83 -0
  181. package/src/terminal/progress.ts +201 -0
  182. package/src/terminal/setup.ts +797 -0
  183. package/src/terminal/suspend.ts +58 -0
  184. package/src/terminal/types.ts +51 -0
  185. package/src/theme/context.tsx +57 -0
  186. package/src/theme/index.ts +4 -0
  187. package/src/theme/themed.tsx +35 -0
  188. package/src/theme/themes.json +546 -0
  189. package/src/theme/types.ts +110 -0
  190. package/src/tools/types.ts +59 -0
  191. package/src/tools/utils/__tests__/zod-coercion.test.ts +33 -0
  192. package/src/tools/utils/tool-ui-components.tsx +631 -0
  193. package/src/tools/utils/zod-coercion.ts +35 -0
  194. package/tsconfig.json +11 -0
  195. package/tsconfig.node.json +29 -0
  196. package/tsconfig.test.json +27 -0
  197. package/tsdown.config.ts +17 -0
  198. package/vitest.config.ts +76 -0
  199. package/README.md +0 -28
  200. package/dist/index.js +0 -26
@@ -0,0 +1,732 @@
1
+ import {
2
+ findNextWordAcrossLines,
3
+ findPrevWordAcrossLines,
4
+ findWordEndInLine,
5
+ getLineRangeOffsets,
6
+ getPositionFromOffsets,
7
+ isCombiningMark,
8
+ isWordCharStrict,
9
+ isWordCharWithCombining,
10
+ pushUndo,
11
+ replaceRangeInternal,
12
+ TextBufferAction,
13
+ TextBufferState,
14
+ } from "./text-buffer.js";
15
+ import { cpLen, toCodePoints } from "./text-utils.js";
16
+
17
+ /* Fail to compile on unexpected values. */
18
+ export function assumeExhaustive(_value: never): void {}
19
+
20
+ // Check if we're at the end of a base word (on the last base character)
21
+ // Returns true if current position has a base character followed only by combining marks until non-word
22
+ function isAtEndOfBaseWord(lineCodePoints: string[], col: number): boolean {
23
+ if (!isWordCharStrict(lineCodePoints[col])) {
24
+ return false;
25
+ }
26
+
27
+ // Look ahead to see if we have only combining marks followed by non-word
28
+ let i = col + 1;
29
+
30
+ // Skip any combining marks
31
+ while (i < lineCodePoints.length && isCombiningMark(lineCodePoints[i])) {
32
+ i++;
33
+ }
34
+
35
+ // If we hit end of line or non-word character, we were at end of base word
36
+ return i >= lineCodePoints.length || !isWordCharStrict(lineCodePoints[i]);
37
+ }
38
+
39
+ export type VimAction = Extract<
40
+ TextBufferAction,
41
+ | { type: "vim_delete_word_forward" }
42
+ | { type: "vim_delete_word_backward" }
43
+ | { type: "vim_delete_word_end" }
44
+ | { type: "vim_change_word_forward" }
45
+ | { type: "vim_change_word_backward" }
46
+ | { type: "vim_change_word_end" }
47
+ | { type: "vim_delete_line" }
48
+ | { type: "vim_change_line" }
49
+ | { type: "vim_delete_to_end_of_line" }
50
+ | { type: "vim_change_to_end_of_line" }
51
+ | { type: "vim_change_movement" }
52
+ | { type: "vim_move_left" }
53
+ | { type: "vim_move_right" }
54
+ | { type: "vim_move_up" }
55
+ | { type: "vim_move_down" }
56
+ | { type: "vim_move_word_forward" }
57
+ | { type: "vim_move_word_backward" }
58
+ | { type: "vim_move_word_end" }
59
+ | { type: "vim_delete_char" }
60
+ | { type: "vim_insert_at_cursor" }
61
+ | { type: "vim_append_at_cursor" }
62
+ | { type: "vim_open_line_below" }
63
+ | { type: "vim_open_line_above" }
64
+ | { type: "vim_append_at_line_end" }
65
+ | { type: "vim_insert_at_line_start" }
66
+ | { type: "vim_move_to_line_start" }
67
+ | { type: "vim_move_to_line_end" }
68
+ | { type: "vim_move_to_first_nonwhitespace" }
69
+ | { type: "vim_move_to_first_line" }
70
+ | { type: "vim_move_to_last_line" }
71
+ | { type: "vim_move_to_line" }
72
+ | { type: "vim_escape_insert_mode" }
73
+ >;
74
+
75
+ export function handleVimAction(state: TextBufferState, action: VimAction): TextBufferState {
76
+ const { lines, cursorRow, cursorCol } = state;
77
+
78
+ switch (action.type) {
79
+ case "vim_delete_word_forward":
80
+ case "vim_change_word_forward": {
81
+ const { count } = action.payload;
82
+ let endRow = cursorRow;
83
+ let endCol = cursorCol;
84
+
85
+ for (let i = 0; i < count; i++) {
86
+ const nextWord = findNextWordAcrossLines(lines, endRow, endCol, true);
87
+ if (nextWord) {
88
+ endRow = nextWord.row;
89
+ endCol = nextWord.col;
90
+ } else {
91
+ // No more words, delete/change to end of current word or line
92
+ const currentLine = lines[endRow] || "";
93
+ const wordEnd = findWordEndInLine(currentLine, endCol);
94
+ if (wordEnd !== null) {
95
+ endCol = wordEnd + 1; // Include the character at word end
96
+ } else {
97
+ endCol = cpLen(currentLine);
98
+ }
99
+ break;
100
+ }
101
+ }
102
+
103
+ if (endRow !== cursorRow || endCol !== cursorCol) {
104
+ const nextState = pushUndo(state);
105
+ return replaceRangeInternal(nextState, cursorRow, cursorCol, endRow, endCol, "");
106
+ }
107
+ return state;
108
+ }
109
+
110
+ case "vim_delete_word_backward":
111
+ case "vim_change_word_backward": {
112
+ const { count } = action.payload;
113
+ let startRow = cursorRow;
114
+ let startCol = cursorCol;
115
+
116
+ for (let i = 0; i < count; i++) {
117
+ const prevWord = findPrevWordAcrossLines(lines, startRow, startCol);
118
+ if (prevWord) {
119
+ startRow = prevWord.row;
120
+ startCol = prevWord.col;
121
+ } else {
122
+ break;
123
+ }
124
+ }
125
+
126
+ if (startRow !== cursorRow || startCol !== cursorCol) {
127
+ const nextState = pushUndo(state);
128
+ return replaceRangeInternal(nextState, startRow, startCol, cursorRow, cursorCol, "");
129
+ }
130
+ return state;
131
+ }
132
+
133
+ case "vim_delete_word_end":
134
+ case "vim_change_word_end": {
135
+ const { count } = action.payload;
136
+ let row = cursorRow;
137
+ let col = cursorCol;
138
+ let endRow = cursorRow;
139
+ let endCol = cursorCol;
140
+
141
+ for (let i = 0; i < count; i++) {
142
+ const wordEnd = findNextWordAcrossLines(lines, row, col, false);
143
+ if (wordEnd) {
144
+ endRow = wordEnd.row;
145
+ endCol = wordEnd.col + 1; // Include the character at word end
146
+ // For next iteration, move to start of next word
147
+ if (i < count - 1) {
148
+ const nextWord = findNextWordAcrossLines(lines, wordEnd.row, wordEnd.col + 1, true);
149
+ if (nextWord) {
150
+ row = nextWord.row;
151
+ col = nextWord.col;
152
+ } else {
153
+ break; // No more words
154
+ }
155
+ }
156
+ } else {
157
+ break;
158
+ }
159
+ }
160
+
161
+ // Ensure we don't go past the end of the last line
162
+ if (endRow < lines.length) {
163
+ const lineLen = cpLen(lines[endRow] || "");
164
+ endCol = Math.min(endCol, lineLen);
165
+ }
166
+
167
+ if (endRow !== cursorRow || endCol !== cursorCol) {
168
+ const nextState = pushUndo(state);
169
+ return replaceRangeInternal(nextState, cursorRow, cursorCol, endRow, endCol, "");
170
+ }
171
+ return state;
172
+ }
173
+
174
+ case "vim_delete_line": {
175
+ const { count } = action.payload;
176
+ if (lines.length === 0) {
177
+ return state;
178
+ }
179
+
180
+ const linesToDelete = Math.min(count, lines.length - cursorRow);
181
+ const totalLines = lines.length;
182
+
183
+ if (totalLines === 1 || linesToDelete >= totalLines) {
184
+ // If there's only one line, or we're deleting all remaining lines,
185
+ // clear the content but keep one empty line (text editors should never be completely empty)
186
+ const nextState = pushUndo(state);
187
+ return {
188
+ ...nextState,
189
+ lines: [""],
190
+ cursorRow: 0,
191
+ cursorCol: 0,
192
+ preferredCol: null,
193
+ };
194
+ }
195
+
196
+ const nextState = pushUndo(state);
197
+ const newLines = [...nextState.lines];
198
+ newLines.splice(cursorRow, linesToDelete);
199
+
200
+ // Adjust cursor position
201
+ const newCursorRow = Math.min(cursorRow, newLines.length - 1);
202
+ const newCursorCol = 0; // Vim places cursor at beginning of line after dd
203
+
204
+ return {
205
+ ...nextState,
206
+ lines: newLines,
207
+ cursorRow: newCursorRow,
208
+ cursorCol: newCursorCol,
209
+ preferredCol: null,
210
+ };
211
+ }
212
+
213
+ case "vim_change_line": {
214
+ const { count } = action.payload;
215
+ if (lines.length === 0) {
216
+ return state;
217
+ }
218
+
219
+ const linesToChange = Math.min(count, lines.length - cursorRow);
220
+ const nextState = pushUndo(state);
221
+
222
+ const { startOffset, endOffset } = getLineRangeOffsets(
223
+ cursorRow,
224
+ linesToChange,
225
+ nextState.lines,
226
+ );
227
+ const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
228
+ startOffset,
229
+ endOffset,
230
+ nextState.lines,
231
+ );
232
+ return replaceRangeInternal(nextState, startRow, startCol, endRow, endCol, "");
233
+ }
234
+
235
+ case "vim_delete_to_end_of_line":
236
+ case "vim_change_to_end_of_line": {
237
+ const currentLine = lines[cursorRow] || "";
238
+ if (cursorCol < cpLen(currentLine)) {
239
+ const nextState = pushUndo(state);
240
+ return replaceRangeInternal(
241
+ nextState,
242
+ cursorRow,
243
+ cursorCol,
244
+ cursorRow,
245
+ cpLen(currentLine),
246
+ "",
247
+ );
248
+ }
249
+ return state;
250
+ }
251
+
252
+ case "vim_change_movement": {
253
+ const { movement, count } = action.payload;
254
+ const totalLines = lines.length;
255
+
256
+ switch (movement) {
257
+ case "h": {
258
+ // Left
259
+ // Change N characters to the left
260
+ const startCol = Math.max(0, cursorCol - count);
261
+ return replaceRangeInternal(
262
+ pushUndo(state),
263
+ cursorRow,
264
+ startCol,
265
+ cursorRow,
266
+ cursorCol,
267
+ "",
268
+ );
269
+ }
270
+
271
+ case "j": {
272
+ // Down
273
+ const linesToChange = Math.min(count, totalLines - cursorRow);
274
+ if (linesToChange > 0) {
275
+ if (totalLines === 1) {
276
+ const currentLine = state.lines[0] || "";
277
+ return replaceRangeInternal(pushUndo(state), 0, 0, 0, cpLen(currentLine), "");
278
+ } else {
279
+ const nextState = pushUndo(state);
280
+ const { startOffset, endOffset } = getLineRangeOffsets(
281
+ cursorRow,
282
+ linesToChange,
283
+ nextState.lines,
284
+ );
285
+ const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
286
+ startOffset,
287
+ endOffset,
288
+ nextState.lines,
289
+ );
290
+ return replaceRangeInternal(nextState, startRow, startCol, endRow, endCol, "");
291
+ }
292
+ }
293
+ return state;
294
+ }
295
+
296
+ case "k": {
297
+ // Up
298
+ const upLines = Math.min(count, cursorRow + 1);
299
+ if (upLines > 0) {
300
+ if (state.lines.length === 1) {
301
+ const currentLine = state.lines[0] || "";
302
+ return replaceRangeInternal(pushUndo(state), 0, 0, 0, cpLen(currentLine), "");
303
+ } else {
304
+ const startRow = Math.max(0, cursorRow - count + 1);
305
+ const linesToChange = cursorRow - startRow + 1;
306
+ const nextState = pushUndo(state);
307
+ const { startOffset, endOffset } = getLineRangeOffsets(
308
+ startRow,
309
+ linesToChange,
310
+ nextState.lines,
311
+ );
312
+ const {
313
+ startRow: newStartRow,
314
+ startCol,
315
+ endRow,
316
+ endCol,
317
+ } = getPositionFromOffsets(startOffset, endOffset, nextState.lines);
318
+ const resultState = replaceRangeInternal(
319
+ nextState,
320
+ newStartRow,
321
+ startCol,
322
+ endRow,
323
+ endCol,
324
+ "",
325
+ );
326
+ return {
327
+ ...resultState,
328
+ cursorRow: startRow,
329
+ cursorCol: 0,
330
+ };
331
+ }
332
+ }
333
+ return state;
334
+ }
335
+
336
+ case "l": {
337
+ // Right
338
+ // Change N characters to the right
339
+ return replaceRangeInternal(
340
+ pushUndo(state),
341
+ cursorRow,
342
+ cursorCol,
343
+ cursorRow,
344
+ Math.min(cpLen(lines[cursorRow] || ""), cursorCol + count),
345
+ "",
346
+ );
347
+ }
348
+
349
+ default:
350
+ return state;
351
+ }
352
+ }
353
+
354
+ case "vim_move_left": {
355
+ const { count } = action.payload;
356
+ const { cursorRow, cursorCol, lines } = state;
357
+ let newRow = cursorRow;
358
+ let newCol = cursorCol;
359
+
360
+ for (let i = 0; i < count; i++) {
361
+ if (newCol > 0) {
362
+ newCol--;
363
+ } else if (newRow > 0) {
364
+ // Move to end of previous line
365
+ newRow--;
366
+ const prevLine = lines[newRow] || "";
367
+ const prevLineLength = cpLen(prevLine);
368
+ // Position on last character, or column 0 for empty lines
369
+ newCol = prevLineLength === 0 ? 0 : prevLineLength - 1;
370
+ }
371
+ }
372
+
373
+ return {
374
+ ...state,
375
+ cursorRow: newRow,
376
+ cursorCol: newCol,
377
+ preferredCol: null,
378
+ };
379
+ }
380
+
381
+ case "vim_move_right": {
382
+ const { count } = action.payload;
383
+ const { cursorRow, cursorCol, lines } = state;
384
+ let newRow = cursorRow;
385
+ let newCol = cursorCol;
386
+
387
+ for (let i = 0; i < count; i++) {
388
+ const currentLine = lines[newRow] || "";
389
+ const lineLength = cpLen(currentLine);
390
+ // Don't move past the last character of the line
391
+ // For empty lines, stay at column 0; for non-empty lines, don't go past last character
392
+ if (lineLength === 0) {
393
+ // Empty line - try to move to next line
394
+ if (newRow < lines.length - 1) {
395
+ newRow++;
396
+ newCol = 0;
397
+ }
398
+ } else if (newCol < lineLength - 1) {
399
+ newCol++;
400
+
401
+ // Skip over combining marks - don't let cursor land on them
402
+ const currentLinePoints = toCodePoints(currentLine);
403
+ while (
404
+ newCol < currentLinePoints.length &&
405
+ isCombiningMark(currentLinePoints[newCol]) &&
406
+ newCol < lineLength - 1
407
+ ) {
408
+ newCol++;
409
+ }
410
+ } else if (newRow < lines.length - 1) {
411
+ // At end of line - move to beginning of next line
412
+ newRow++;
413
+ newCol = 0;
414
+ }
415
+ }
416
+
417
+ return {
418
+ ...state,
419
+ cursorRow: newRow,
420
+ cursorCol: newCol,
421
+ preferredCol: null,
422
+ };
423
+ }
424
+
425
+ case "vim_move_up": {
426
+ const { count } = action.payload;
427
+ const { cursorRow, cursorCol, lines } = state;
428
+ const newRow = Math.max(0, cursorRow - count);
429
+ const targetLine = lines[newRow] || "";
430
+ const targetLineLength = cpLen(targetLine);
431
+ const newCol = Math.min(cursorCol, targetLineLength > 0 ? targetLineLength - 1 : 0);
432
+
433
+ return {
434
+ ...state,
435
+ cursorRow: newRow,
436
+ cursorCol: newCol,
437
+ preferredCol: null,
438
+ };
439
+ }
440
+
441
+ case "vim_move_down": {
442
+ const { count } = action.payload;
443
+ const { cursorRow, cursorCol, lines } = state;
444
+ const newRow = Math.min(lines.length - 1, cursorRow + count);
445
+ const targetLine = lines[newRow] || "";
446
+ const targetLineLength = cpLen(targetLine);
447
+ const newCol = Math.min(cursorCol, targetLineLength > 0 ? targetLineLength - 1 : 0);
448
+
449
+ return {
450
+ ...state,
451
+ cursorRow: newRow,
452
+ cursorCol: newCol,
453
+ preferredCol: null,
454
+ };
455
+ }
456
+
457
+ case "vim_move_word_forward": {
458
+ const { count } = action.payload;
459
+ let row = cursorRow;
460
+ let col = cursorCol;
461
+
462
+ for (let i = 0; i < count; i++) {
463
+ const nextWord = findNextWordAcrossLines(lines, row, col, true);
464
+ if (nextWord) {
465
+ row = nextWord.row;
466
+ col = nextWord.col;
467
+ } else {
468
+ // No more words to move to
469
+ break;
470
+ }
471
+ }
472
+
473
+ return {
474
+ ...state,
475
+ cursorRow: row,
476
+ cursorCol: col,
477
+ preferredCol: null,
478
+ };
479
+ }
480
+
481
+ case "vim_move_word_backward": {
482
+ const { count } = action.payload;
483
+ let row = cursorRow;
484
+ let col = cursorCol;
485
+
486
+ for (let i = 0; i < count; i++) {
487
+ const prevWord = findPrevWordAcrossLines(lines, row, col);
488
+ if (prevWord) {
489
+ row = prevWord.row;
490
+ col = prevWord.col;
491
+ } else {
492
+ break;
493
+ }
494
+ }
495
+
496
+ return {
497
+ ...state,
498
+ cursorRow: row,
499
+ cursorCol: col,
500
+ preferredCol: null,
501
+ };
502
+ }
503
+
504
+ case "vim_move_word_end": {
505
+ const { count } = action.payload;
506
+ let row = cursorRow;
507
+ let col = cursorCol;
508
+
509
+ for (let i = 0; i < count; i++) {
510
+ // Special handling for the first iteration when we're at end of word
511
+ if (i === 0) {
512
+ const currentLine = lines[row] || "";
513
+ const lineCodePoints = toCodePoints(currentLine);
514
+
515
+ // Check if we're at the end of a word (on the last base character)
516
+ const atEndOfWord =
517
+ col < lineCodePoints.length &&
518
+ isWordCharStrict(lineCodePoints[col]) &&
519
+ (col + 1 >= lineCodePoints.length ||
520
+ !isWordCharWithCombining(lineCodePoints[col + 1]) ||
521
+ // Or if we're on a base char followed only by combining marks until non-word
522
+ (isWordCharStrict(lineCodePoints[col]) && isAtEndOfBaseWord(lineCodePoints, col)));
523
+
524
+ if (atEndOfWord) {
525
+ // We're already at end of word, find next word end
526
+ const nextWord = findNextWordAcrossLines(lines, row, col + 1, false);
527
+ if (nextWord) {
528
+ row = nextWord.row;
529
+ col = nextWord.col;
530
+ continue;
531
+ }
532
+ }
533
+ }
534
+
535
+ const wordEnd = findNextWordAcrossLines(lines, row, col, false);
536
+ if (wordEnd) {
537
+ row = wordEnd.row;
538
+ col = wordEnd.col;
539
+ } else {
540
+ break;
541
+ }
542
+ }
543
+
544
+ return {
545
+ ...state,
546
+ cursorRow: row,
547
+ cursorCol: col,
548
+ preferredCol: null,
549
+ };
550
+ }
551
+
552
+ case "vim_delete_char": {
553
+ const { count } = action.payload;
554
+ const { cursorRow, cursorCol, lines } = state;
555
+ const currentLine = lines[cursorRow] || "";
556
+ const lineLength = cpLen(currentLine);
557
+
558
+ if (cursorCol < lineLength) {
559
+ const deleteCount = Math.min(count, lineLength - cursorCol);
560
+ const nextState = pushUndo(state);
561
+ return replaceRangeInternal(
562
+ nextState,
563
+ cursorRow,
564
+ cursorCol,
565
+ cursorRow,
566
+ cursorCol + deleteCount,
567
+ "",
568
+ );
569
+ }
570
+ return state;
571
+ }
572
+
573
+ case "vim_insert_at_cursor": {
574
+ // Just return state - mode change is handled elsewhere
575
+ return state;
576
+ }
577
+
578
+ case "vim_append_at_cursor": {
579
+ const { cursorRow, cursorCol, lines } = state;
580
+ const currentLine = lines[cursorRow] || "";
581
+ const newCol = cursorCol < cpLen(currentLine) ? cursorCol + 1 : cursorCol;
582
+
583
+ return {
584
+ ...state,
585
+ cursorCol: newCol,
586
+ preferredCol: null,
587
+ };
588
+ }
589
+
590
+ case "vim_open_line_below": {
591
+ const { cursorRow, lines } = state;
592
+ const nextState = pushUndo(state);
593
+
594
+ // Insert newline at end of current line
595
+ const endOfLine = cpLen(lines[cursorRow] || "");
596
+ return replaceRangeInternal(nextState, cursorRow, endOfLine, cursorRow, endOfLine, "\n");
597
+ }
598
+
599
+ case "vim_open_line_above": {
600
+ const { cursorRow } = state;
601
+ const nextState = pushUndo(state);
602
+
603
+ // Insert newline at beginning of current line
604
+ const resultState = replaceRangeInternal(nextState, cursorRow, 0, cursorRow, 0, "\n");
605
+
606
+ // Move cursor to the new line above
607
+ return {
608
+ ...resultState,
609
+ cursorRow,
610
+ cursorCol: 0,
611
+ };
612
+ }
613
+
614
+ case "vim_append_at_line_end": {
615
+ const { cursorRow, lines } = state;
616
+ const lineLength = cpLen(lines[cursorRow] || "");
617
+
618
+ return {
619
+ ...state,
620
+ cursorCol: lineLength,
621
+ preferredCol: null,
622
+ };
623
+ }
624
+
625
+ case "vim_insert_at_line_start": {
626
+ const { cursorRow, lines } = state;
627
+ const currentLine = lines[cursorRow] || "";
628
+ let col = 0;
629
+
630
+ // Find first non-whitespace character using proper Unicode handling
631
+ const lineCodePoints = toCodePoints(currentLine);
632
+ while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
633
+ col++;
634
+ }
635
+
636
+ return {
637
+ ...state,
638
+ cursorCol: col,
639
+ preferredCol: null,
640
+ };
641
+ }
642
+
643
+ case "vim_move_to_line_start": {
644
+ return {
645
+ ...state,
646
+ cursorCol: 0,
647
+ preferredCol: null,
648
+ };
649
+ }
650
+
651
+ case "vim_move_to_line_end": {
652
+ const { cursorRow, lines } = state;
653
+ const lineLength = cpLen(lines[cursorRow] || "");
654
+
655
+ return {
656
+ ...state,
657
+ cursorCol: lineLength > 0 ? lineLength - 1 : 0,
658
+ preferredCol: null,
659
+ };
660
+ }
661
+
662
+ case "vim_move_to_first_nonwhitespace": {
663
+ const { cursorRow, lines } = state;
664
+ const currentLine = lines[cursorRow] || "";
665
+ let col = 0;
666
+
667
+ // Find first non-whitespace character using proper Unicode handling
668
+ const lineCodePoints = toCodePoints(currentLine);
669
+ while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
670
+ col++;
671
+ }
672
+
673
+ return {
674
+ ...state,
675
+ cursorCol: col,
676
+ preferredCol: null,
677
+ };
678
+ }
679
+
680
+ case "vim_move_to_first_line": {
681
+ return {
682
+ ...state,
683
+ cursorRow: 0,
684
+ cursorCol: 0,
685
+ preferredCol: null,
686
+ };
687
+ }
688
+
689
+ case "vim_move_to_last_line": {
690
+ const { lines } = state;
691
+ const lastRow = lines.length - 1;
692
+
693
+ return {
694
+ ...state,
695
+ cursorRow: lastRow,
696
+ cursorCol: 0,
697
+ preferredCol: null,
698
+ };
699
+ }
700
+
701
+ case "vim_move_to_line": {
702
+ const { lineNumber } = action.payload;
703
+ const { lines } = state;
704
+ const targetRow = Math.min(Math.max(0, lineNumber - 1), lines.length - 1);
705
+
706
+ return {
707
+ ...state,
708
+ cursorRow: targetRow,
709
+ cursorCol: 0,
710
+ preferredCol: null,
711
+ };
712
+ }
713
+
714
+ case "vim_escape_insert_mode": {
715
+ // Move cursor left if not at beginning of line (vim behavior when exiting insert mode)
716
+ const { cursorCol } = state;
717
+ const newCol = cursorCol > 0 ? cursorCol - 1 : 0;
718
+
719
+ return {
720
+ ...state,
721
+ cursorCol: newCol,
722
+ preferredCol: null,
723
+ };
724
+ }
725
+
726
+ default: {
727
+ // This should never happen if TypeScript is working correctly
728
+ assumeExhaustive(action);
729
+ return state;
730
+ }
731
+ }
732
+ }