@abacus-ai/cli 2.0.0-canary.1 → 2.0.0-canary.2

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