@google/gemini-cli 0.13.0-nightly.20251102.d7243fb8 → 0.13.0-preview.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/dist/google-gemini-cli-0.13.0-nightly.20251031.c89bc30d.tgz +0 -0
  2. package/dist/package.json +3 -3
  3. package/dist/src/commands/mcp/list.test.js +25 -21
  4. package/dist/src/commands/mcp/list.test.js.map +1 -1
  5. package/dist/src/config/config.js +11 -84
  6. package/dist/src/config/config.js.map +1 -1
  7. package/dist/src/config/config.test.js +13 -30
  8. package/dist/src/config/config.test.js.map +1 -1
  9. package/dist/src/config/extension-manager.d.ts +18 -6
  10. package/dist/src/config/extension-manager.js +25 -19
  11. package/dist/src/config/extension-manager.js.map +1 -1
  12. package/dist/src/config/extension.test.js +9 -9
  13. package/dist/src/config/extension.test.js.map +1 -1
  14. package/dist/src/config/extensions/extensionSettings.test.js +39 -43
  15. package/dist/src/config/extensions/extensionSettings.test.js.map +1 -1
  16. package/dist/src/config/extensions/github.test.js +133 -165
  17. package/dist/src/config/extensions/github.test.js.map +1 -1
  18. package/dist/src/config/keyBindings.d.ts +3 -0
  19. package/dist/src/config/keyBindings.js +29 -7
  20. package/dist/src/config/keyBindings.js.map +1 -1
  21. package/dist/src/config/keyBindings.test.js +17 -0
  22. package/dist/src/config/keyBindings.test.js.map +1 -1
  23. package/dist/src/config/policy.d.ts +0 -7
  24. package/dist/src/config/policy.js +10 -177
  25. package/dist/src/config/policy.js.map +1 -1
  26. package/dist/src/config/settings.js +1 -0
  27. package/dist/src/config/settings.js.map +1 -1
  28. package/dist/src/config/settingsSchema.d.ts +123 -22
  29. package/dist/src/config/settingsSchema.js +371 -21
  30. package/dist/src/config/settingsSchema.js.map +1 -1
  31. package/dist/src/config/settingsSchema.test.js +40 -1
  32. package/dist/src/config/settingsSchema.test.js.map +1 -1
  33. package/dist/src/gemini.js +15 -5
  34. package/dist/src/gemini.js.map +1 -1
  35. package/dist/src/gemini.test.js +2 -0
  36. package/dist/src/gemini.test.js.map +1 -1
  37. package/dist/src/generated/git-commit.d.ts +2 -2
  38. package/dist/src/generated/git-commit.js +2 -2
  39. package/dist/src/generated/git-commit.js.map +1 -1
  40. package/dist/src/nonInteractiveCli.js +68 -1
  41. package/dist/src/nonInteractiveCli.js.map +1 -1
  42. package/dist/src/services/BuiltinCommandLoader.js +4 -0
  43. package/dist/src/services/BuiltinCommandLoader.js.map +1 -1
  44. package/dist/src/services/BuiltinCommandLoader.test.js +22 -0
  45. package/dist/src/services/BuiltinCommandLoader.test.js.map +1 -1
  46. package/dist/src/services/McpPromptLoader.js +2 -2
  47. package/dist/src/services/McpPromptLoader.js.map +1 -1
  48. package/dist/src/services/McpPromptLoader.test.js +4 -2
  49. package/dist/src/services/McpPromptLoader.test.js.map +1 -1
  50. package/dist/src/test-utils/render.d.ts +2 -1
  51. package/dist/src/test-utils/render.js +3 -2
  52. package/dist/src/test-utils/render.js.map +1 -1
  53. package/dist/src/ui/AppContainer.js +32 -15
  54. package/dist/src/ui/AppContainer.js.map +1 -1
  55. package/dist/src/ui/AppContainer.test.js +160 -0
  56. package/dist/src/ui/AppContainer.test.js.map +1 -1
  57. package/dist/src/ui/commands/mcpCommand.js +14 -14
  58. package/dist/src/ui/commands/mcpCommand.js.map +1 -1
  59. package/dist/src/ui/commands/mcpCommand.test.js +4 -0
  60. package/dist/src/ui/commands/mcpCommand.test.js.map +1 -1
  61. package/dist/src/ui/commands/policiesCommand.d.ts +7 -0
  62. package/dist/src/ui/commands/policiesCommand.js +59 -0
  63. package/dist/src/ui/commands/policiesCommand.js.map +1 -0
  64. package/dist/src/ui/commands/policiesCommand.test.js +83 -0
  65. package/dist/src/ui/commands/policiesCommand.test.js.map +1 -0
  66. package/dist/src/ui/components/Composer.js +1 -1
  67. package/dist/src/ui/components/Composer.js.map +1 -1
  68. package/dist/src/ui/components/Composer.test.js +4 -1
  69. package/dist/src/ui/components/Composer.test.js.map +1 -1
  70. package/dist/src/ui/components/ConfigInitDisplay.js +4 -6
  71. package/dist/src/ui/components/ConfigInitDisplay.js.map +1 -1
  72. package/dist/src/ui/components/InputPrompt.js +22 -2
  73. package/dist/src/ui/components/InputPrompt.js.map +1 -1
  74. package/dist/src/ui/components/InputPrompt.test.js +70 -5
  75. package/dist/src/ui/components/InputPrompt.test.js.map +1 -1
  76. package/dist/src/ui/components/MainContent.js +15 -4
  77. package/dist/src/ui/components/MainContent.js.map +1 -1
  78. package/dist/src/ui/components/Notifications.js +38 -5
  79. package/dist/src/ui/components/Notifications.js.map +1 -1
  80. package/dist/src/ui/components/SettingsDialog.js +32 -25
  81. package/dist/src/ui/components/SettingsDialog.js.map +1 -1
  82. package/dist/src/ui/components/ShellConfirmationDialog.js +1 -1
  83. package/dist/src/ui/components/ShellConfirmationDialog.js.map +1 -1
  84. package/dist/src/ui/components/messages/InfoMessage.js +1 -1
  85. package/dist/src/ui/components/messages/InfoMessage.js.map +1 -1
  86. package/dist/src/ui/components/messages/ToolConfirmationMessage.js +1 -1
  87. package/dist/src/ui/components/messages/ToolConfirmationMessage.js.map +1 -1
  88. package/dist/src/ui/components/messages/WarningMessage.js +2 -2
  89. package/dist/src/ui/components/messages/WarningMessage.js.map +1 -1
  90. package/dist/src/ui/components/shared/text-buffer.d.ts +1 -0
  91. package/dist/src/ui/components/shared/text-buffer.js +23 -0
  92. package/dist/src/ui/components/shared/text-buffer.js.map +1 -1
  93. package/dist/src/ui/components/shared/text-buffer.test.js +246 -201
  94. package/dist/src/ui/components/shared/text-buffer.test.js.map +1 -1
  95. package/dist/src/ui/contexts/KeypressContext.js +182 -132
  96. package/dist/src/ui/contexts/KeypressContext.js.map +1 -1
  97. package/dist/src/ui/contexts/KeypressContext.test.js +144 -8
  98. package/dist/src/ui/contexts/KeypressContext.test.js.map +1 -1
  99. package/dist/src/ui/contexts/MouseContext.d.ts +21 -0
  100. package/dist/src/ui/contexts/MouseContext.js +89 -0
  101. package/dist/src/ui/contexts/MouseContext.js.map +1 -0
  102. package/dist/src/ui/contexts/MouseContext.test.js +164 -0
  103. package/dist/src/ui/contexts/MouseContext.test.js.map +1 -0
  104. package/dist/src/ui/hooks/slashCommandProcessor.test.js +70 -73
  105. package/dist/src/ui/hooks/slashCommandProcessor.test.js.map +1 -1
  106. package/dist/src/ui/hooks/useGeminiStream.test.js +135 -368
  107. package/dist/src/ui/hooks/useGeminiStream.test.js.map +1 -1
  108. package/dist/src/ui/hooks/useKeypress.test.js +17 -9
  109. package/dist/src/ui/hooks/useKeypress.test.js.map +1 -1
  110. package/dist/src/ui/hooks/useMouse.d.ts +17 -0
  111. package/dist/src/ui/hooks/useMouse.js +27 -0
  112. package/dist/src/ui/hooks/useMouse.js.map +1 -0
  113. package/dist/src/ui/hooks/useMouse.test.d.ts +6 -0
  114. package/dist/src/ui/hooks/useMouse.test.js +57 -0
  115. package/dist/src/ui/hooks/useMouse.test.js.map +1 -0
  116. package/dist/src/ui/hooks/useSelectionList.js +5 -4
  117. package/dist/src/ui/hooks/useSelectionList.js.map +1 -1
  118. package/dist/src/ui/hooks/useSelectionList.test.js +24 -3
  119. package/dist/src/ui/hooks/useSelectionList.test.js.map +1 -1
  120. package/dist/src/ui/hooks/useToolScheduler.test.js +109 -200
  121. package/dist/src/ui/hooks/useToolScheduler.test.js.map +1 -1
  122. package/dist/src/ui/keyMatchers.test.js +27 -0
  123. package/dist/src/ui/keyMatchers.test.js.map +1 -1
  124. package/dist/src/ui/themes/no-color.js +1 -0
  125. package/dist/src/ui/themes/no-color.js.map +1 -1
  126. package/dist/src/ui/themes/semantic-tokens.d.ts +1 -0
  127. package/dist/src/ui/themes/semantic-tokens.js +3 -0
  128. package/dist/src/ui/themes/semantic-tokens.js.map +1 -1
  129. package/dist/src/ui/themes/theme.d.ts +1 -0
  130. package/dist/src/ui/themes/theme.js +4 -0
  131. package/dist/src/ui/themes/theme.js.map +1 -1
  132. package/dist/src/ui/utils/InlineMarkdownRenderer.d.ts +1 -0
  133. package/dist/src/ui/utils/InlineMarkdownRenderer.js +11 -10
  134. package/dist/src/ui/utils/InlineMarkdownRenderer.js.map +1 -1
  135. package/dist/src/ui/utils/MarkdownDisplay.js +11 -9
  136. package/dist/src/ui/utils/MarkdownDisplay.js.map +1 -1
  137. package/dist/src/ui/utils/input.d.ts +17 -0
  138. package/dist/src/ui/utils/input.js +51 -0
  139. package/dist/src/ui/utils/input.js.map +1 -0
  140. package/dist/src/ui/utils/input.test.d.ts +6 -0
  141. package/dist/src/ui/utils/input.test.js +44 -0
  142. package/dist/src/ui/utils/input.test.js.map +1 -0
  143. package/dist/src/ui/utils/kittyProtocolDetector.js +13 -4
  144. package/dist/src/ui/utils/kittyProtocolDetector.js.map +1 -1
  145. package/dist/src/ui/utils/mouse.d.ts +31 -0
  146. package/dist/src/ui/utils/mouse.js +164 -0
  147. package/dist/src/ui/utils/mouse.js.map +1 -0
  148. package/dist/src/ui/utils/mouse.test.d.ts +6 -0
  149. package/dist/src/ui/utils/mouse.test.js +131 -0
  150. package/dist/src/ui/utils/mouse.test.js.map +1 -0
  151. package/dist/src/utils/events.d.ts +11 -2
  152. package/dist/src/utils/events.js +1 -0
  153. package/dist/src/utils/events.js.map +1 -1
  154. package/dist/src/utils/sandbox.js +16 -18
  155. package/dist/src/utils/sandbox.js.map +1 -1
  156. package/dist/tsconfig.tsbuildinfo +1 -1
  157. package/package.json +4 -4
  158. package/dist/src/config/policy-toml-loader.d.ts +0 -46
  159. package/dist/src/config/policy-toml-loader.js +0 -314
  160. package/dist/src/config/policy-toml-loader.js.map +0 -1
  161. package/dist/src/config/policy-toml-loader.test.js +0 -626
  162. package/dist/src/config/policy-toml-loader.test.js.map +0 -1
  163. package/dist/src/config/policy.test.js +0 -1058
  164. package/dist/src/config/policy.test.js.map +0 -1
  165. /package/dist/src/{config/policy-toml-loader.test.d.ts → ui/commands/policiesCommand.test.d.ts} +0 -0
  166. /package/dist/src/{config/policy.test.d.ts → ui/contexts/MouseContext.test.d.ts} +0 -0
@@ -188,41 +188,38 @@ describe('textBufferReducer', () => {
188
188
  });
189
189
  });
190
190
  describe('delete_word_left action', () => {
191
- it('should delete a simple word', () => {
192
- const stateWithText = {
193
- ...initialState,
194
- lines: ['hello world'],
195
- cursorRow: 0,
191
+ const createSingleLineState = (text, col) => ({
192
+ ...initialState,
193
+ lines: [text],
194
+ cursorRow: 0,
195
+ cursorCol: col,
196
+ });
197
+ it.each([
198
+ {
199
+ input: 'hello world',
196
200
  cursorCol: 11,
197
- };
198
- const action = { type: 'delete_word_left' };
199
- const state = textBufferReducer(stateWithText, action);
200
- expect(state.lines).toEqual(['hello ']);
201
- expect(state.cursorCol).toBe(6);
202
- });
203
- it('should delete a path segment', () => {
204
- const stateWithText = {
205
- ...initialState,
206
- lines: ['path/to/file'],
207
- cursorRow: 0,
201
+ expectedLines: ['hello '],
202
+ expectedCol: 6,
203
+ desc: 'simple word',
204
+ },
205
+ {
206
+ input: 'path/to/file',
208
207
  cursorCol: 12,
209
- };
210
- const action = { type: 'delete_word_left' };
211
- const state = textBufferReducer(stateWithText, action);
212
- expect(state.lines).toEqual(['path/to/']);
213
- expect(state.cursorCol).toBe(8);
214
- });
215
- it('should delete variable_name parts', () => {
216
- const stateWithText = {
217
- ...initialState,
218
- lines: ['variable_name'],
219
- cursorRow: 0,
208
+ expectedLines: ['path/to/'],
209
+ expectedCol: 8,
210
+ desc: 'path segment',
211
+ },
212
+ {
213
+ input: 'variable_name',
220
214
  cursorCol: 13,
221
- };
222
- const action = { type: 'delete_word_left' };
223
- const state = textBufferReducer(stateWithText, action);
224
- expect(state.lines).toEqual(['variable_']);
225
- expect(state.cursorCol).toBe(9);
215
+ expectedLines: ['variable_'],
216
+ expectedCol: 9,
217
+ desc: 'variable_name parts',
218
+ },
219
+ ])('should delete $desc', ({ input, cursorCol, expectedLines, expectedCol }) => {
220
+ const state = textBufferReducer(createSingleLineState(input, cursorCol), { type: 'delete_word_left' });
221
+ expect(state.lines).toEqual(expectedLines);
222
+ expect(state.cursorCol).toBe(expectedCol);
226
223
  });
227
224
  it('should act like backspace at the beginning of a line', () => {
228
225
  const stateWithText = {
@@ -231,51 +228,55 @@ describe('textBufferReducer', () => {
231
228
  cursorRow: 1,
232
229
  cursorCol: 0,
233
230
  };
234
- const action = { type: 'delete_word_left' };
235
- const state = textBufferReducer(stateWithText, action);
231
+ const state = textBufferReducer(stateWithText, {
232
+ type: 'delete_word_left',
233
+ });
236
234
  expect(state.lines).toEqual(['helloworld']);
237
235
  expect(state.cursorRow).toBe(0);
238
236
  expect(state.cursorCol).toBe(5);
239
237
  });
240
238
  });
241
239
  describe('delete_word_right action', () => {
242
- it('should delete a simple word', () => {
243
- const stateWithText = {
244
- ...initialState,
245
- lines: ['hello world'],
246
- cursorRow: 0,
240
+ const createSingleLineState = (text, col) => ({
241
+ ...initialState,
242
+ lines: [text],
243
+ cursorRow: 0,
244
+ cursorCol: col,
245
+ });
246
+ it.each([
247
+ {
248
+ input: 'hello world',
247
249
  cursorCol: 0,
248
- };
249
- const action = { type: 'delete_word_right' };
250
- const state = textBufferReducer(stateWithText, action);
251
- expect(state.lines).toEqual(['world']);
252
- expect(state.cursorCol).toBe(0);
253
- });
254
- it('should delete a path segment', () => {
250
+ expectedLines: ['world'],
251
+ expectedCol: 0,
252
+ desc: 'simple word',
253
+ },
254
+ {
255
+ input: 'variable_name',
256
+ cursorCol: 0,
257
+ expectedLines: ['_name'],
258
+ expectedCol: 0,
259
+ desc: 'variable_name parts',
260
+ },
261
+ ])('should delete $desc', ({ input, cursorCol, expectedLines, expectedCol }) => {
262
+ const state = textBufferReducer(createSingleLineState(input, cursorCol), { type: 'delete_word_right' });
263
+ expect(state.lines).toEqual(expectedLines);
264
+ expect(state.cursorCol).toBe(expectedCol);
265
+ });
266
+ it('should delete path segments progressively', () => {
255
267
  const stateWithText = {
256
268
  ...initialState,
257
269
  lines: ['path/to/file'],
258
270
  cursorRow: 0,
259
271
  cursorCol: 0,
260
272
  };
261
- const action = { type: 'delete_word_right' };
262
- let state = textBufferReducer(stateWithText, action);
273
+ let state = textBufferReducer(stateWithText, {
274
+ type: 'delete_word_right',
275
+ });
263
276
  expect(state.lines).toEqual(['/to/file']);
264
- state = textBufferReducer(state, action);
277
+ state = textBufferReducer(state, { type: 'delete_word_right' });
265
278
  expect(state.lines).toEqual(['to/file']);
266
279
  });
267
- it('should delete variable_name parts', () => {
268
- const stateWithText = {
269
- ...initialState,
270
- lines: ['variable_name'],
271
- cursorRow: 0,
272
- cursorCol: 0,
273
- };
274
- const action = { type: 'delete_word_right' };
275
- const state = textBufferReducer(stateWithText, action);
276
- expect(state.lines).toEqual(['_name']);
277
- expect(state.cursorCol).toBe(0);
278
- });
279
280
  it('should act like delete at the end of a line', () => {
280
281
  const stateWithText = {
281
282
  ...initialState,
@@ -283,15 +284,15 @@ describe('textBufferReducer', () => {
283
284
  cursorRow: 0,
284
285
  cursorCol: 5,
285
286
  };
286
- const action = { type: 'delete_word_right' };
287
- const state = textBufferReducer(stateWithText, action);
287
+ const state = textBufferReducer(stateWithText, {
288
+ type: 'delete_word_right',
289
+ });
288
290
  expect(state.lines).toEqual(['helloworld']);
289
291
  expect(state.cursorRow).toBe(0);
290
292
  expect(state.cursorCol).toBe(5);
291
293
  });
292
294
  });
293
295
  });
294
- // Helper to get the state from the hook
295
296
  const getBufferState = (result) => {
296
297
  expect(result.current).toHaveOnlyValidCharacters();
297
298
  return {
@@ -1129,71 +1130,46 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
1129
1130
  });
1130
1131
  });
1131
1132
  describe('Input Sanitization', () => {
1132
- it('should strip ANSI escape codes from input', () => {
1133
+ const createInput = (sequence) => ({
1134
+ name: '',
1135
+ ctrl: false,
1136
+ meta: false,
1137
+ shift: false,
1138
+ paste: false,
1139
+ sequence,
1140
+ });
1141
+ it.each([
1142
+ {
1143
+ input: '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m',
1144
+ expected: 'Hello World',
1145
+ desc: 'ANSI escape codes',
1146
+ },
1147
+ {
1148
+ input: 'H\x07e\x08l\x0Bl\x0Co',
1149
+ expected: 'Hello',
1150
+ desc: 'control characters',
1151
+ },
1152
+ {
1153
+ input: '\u001B[4mH\u001B[0mello',
1154
+ expected: 'Hello',
1155
+ desc: 'mixed ANSI and control characters',
1156
+ },
1157
+ {
1158
+ input: '\u001B[4mPasted\u001B[4m Text',
1159
+ expected: 'Pasted Text',
1160
+ desc: 'pasted text with ANSI',
1161
+ },
1162
+ ])('should strip $desc from input', ({ input, expected }) => {
1133
1163
  const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }));
1134
- const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m';
1135
- act(() => result.current.handleInput({
1136
- name: '',
1137
- ctrl: false,
1138
- meta: false,
1139
- shift: false,
1140
- paste: false,
1141
- sequence: textWithAnsi,
1142
- }));
1143
- expect(getBufferState(result).text).toBe('Hello World');
1144
- });
1145
- it('should strip control characters from input', () => {
1146
- const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }));
1147
- const textWithControlChars = 'H\x07e\x08l\x0Bl\x0Co'; // BELL, BACKSPACE, VT, FF
1148
- act(() => result.current.handleInput({
1149
- name: '',
1150
- ctrl: false,
1151
- meta: false,
1152
- shift: false,
1153
- paste: false,
1154
- sequence: textWithControlChars,
1155
- }));
1156
- expect(getBufferState(result).text).toBe('Hello');
1157
- });
1158
- it('should strip mixed ANSI and control characters from input', () => {
1159
- const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }));
1160
- const textWithMixed = '\u001B[4mH\u001B[0mello';
1161
- act(() => result.current.handleInput({
1162
- name: '',
1163
- ctrl: false,
1164
- meta: false,
1165
- shift: false,
1166
- paste: false,
1167
- sequence: textWithMixed,
1168
- }));
1169
- expect(getBufferState(result).text).toBe('Hello');
1164
+ act(() => result.current.handleInput(createInput(input)));
1165
+ expect(getBufferState(result).text).toBe(expected);
1170
1166
  });
1171
1167
  it('should not strip standard characters or newlines', () => {
1172
1168
  const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }));
1173
1169
  const validText = 'Hello World\nThis is a test.';
1174
- act(() => result.current.handleInput({
1175
- name: '',
1176
- ctrl: false,
1177
- meta: false,
1178
- shift: false,
1179
- paste: false,
1180
- sequence: validText,
1181
- }));
1170
+ act(() => result.current.handleInput(createInput(validText)));
1182
1171
  expect(getBufferState(result).text).toBe(validText);
1183
1172
  });
1184
- it('should sanitize pasted text via handleInput', () => {
1185
- const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }));
1186
- const pastedText = '\u001B[4mPasted\u001B[4m Text';
1187
- act(() => result.current.handleInput({
1188
- name: '',
1189
- ctrl: false,
1190
- meta: false,
1191
- shift: false,
1192
- paste: false,
1193
- sequence: pastedText,
1194
- }));
1195
- expect(getBufferState(result).text).toBe('Pasted Text');
1196
- });
1197
1173
  it('should sanitize large text (>5000 chars) and strip unsafe characters', () => {
1198
1174
  const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }));
1199
1175
  const unsafeChars = '\x07\x08\x0B\x0C';
@@ -1409,86 +1385,156 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
1409
1385
  });
1410
1386
  });
1411
1387
  describe('offsetToLogicalPos', () => {
1412
- it('should return [0,0] for offset 0', () => {
1413
- expect(offsetToLogicalPos('any text', 0)).toEqual([0, 0]);
1414
- });
1415
- it('should handle single line text', () => {
1416
- const text = 'hello';
1417
- expect(offsetToLogicalPos(text, 0)).toEqual([0, 0]); // Start
1418
- expect(offsetToLogicalPos(text, 2)).toEqual([0, 2]); // Middle 'l'
1419
- expect(offsetToLogicalPos(text, 5)).toEqual([0, 5]); // End
1420
- expect(offsetToLogicalPos(text, 10)).toEqual([0, 5]); // Beyond end
1388
+ it.each([
1389
+ { text: 'any text', offset: 0, expected: [0, 0], desc: 'offset 0' },
1390
+ { text: 'hello', offset: 0, expected: [0, 0], desc: 'single line start' },
1391
+ { text: 'hello', offset: 2, expected: [0, 2], desc: 'single line middle' },
1392
+ { text: 'hello', offset: 5, expected: [0, 5], desc: 'single line end' },
1393
+ { text: 'hello', offset: 10, expected: [0, 5], desc: 'beyond end clamps' },
1394
+ {
1395
+ text: 'a\n\nc',
1396
+ offset: 0,
1397
+ expected: [0, 0],
1398
+ desc: 'empty lines - first char',
1399
+ },
1400
+ {
1401
+ text: 'a\n\nc',
1402
+ offset: 1,
1403
+ expected: [0, 1],
1404
+ desc: 'empty lines - end of first',
1405
+ },
1406
+ {
1407
+ text: 'a\n\nc',
1408
+ offset: 2,
1409
+ expected: [1, 0],
1410
+ desc: 'empty lines - empty line',
1411
+ },
1412
+ {
1413
+ text: 'a\n\nc',
1414
+ offset: 3,
1415
+ expected: [2, 0],
1416
+ desc: 'empty lines - last line start',
1417
+ },
1418
+ {
1419
+ text: 'a\n\nc',
1420
+ offset: 4,
1421
+ expected: [2, 1],
1422
+ desc: 'empty lines - last line end',
1423
+ },
1424
+ {
1425
+ text: 'hello\n',
1426
+ offset: 5,
1427
+ expected: [0, 5],
1428
+ desc: 'newline end - before newline',
1429
+ },
1430
+ {
1431
+ text: 'hello\n',
1432
+ offset: 6,
1433
+ expected: [1, 0],
1434
+ desc: 'newline end - after newline',
1435
+ },
1436
+ {
1437
+ text: 'hello\n',
1438
+ offset: 7,
1439
+ expected: [1, 0],
1440
+ desc: 'newline end - beyond',
1441
+ },
1442
+ {
1443
+ text: '\nhello',
1444
+ offset: 0,
1445
+ expected: [0, 0],
1446
+ desc: 'newline start - first line',
1447
+ },
1448
+ {
1449
+ text: '\nhello',
1450
+ offset: 1,
1451
+ expected: [1, 0],
1452
+ desc: 'newline start - second line',
1453
+ },
1454
+ {
1455
+ text: '\nhello',
1456
+ offset: 3,
1457
+ expected: [1, 2],
1458
+ desc: 'newline start - middle of second',
1459
+ },
1460
+ { text: '', offset: 0, expected: [0, 0], desc: 'empty string at 0' },
1461
+ { text: '', offset: 5, expected: [0, 0], desc: 'empty string beyond' },
1462
+ {
1463
+ text: '你好\n世界',
1464
+ offset: 0,
1465
+ expected: [0, 0],
1466
+ desc: 'unicode - start',
1467
+ },
1468
+ {
1469
+ text: '你好\n世界',
1470
+ offset: 1,
1471
+ expected: [0, 1],
1472
+ desc: 'unicode - after first char',
1473
+ },
1474
+ {
1475
+ text: '你好\n世界',
1476
+ offset: 2,
1477
+ expected: [0, 2],
1478
+ desc: 'unicode - end first line',
1479
+ },
1480
+ {
1481
+ text: '你好\n世界',
1482
+ offset: 3,
1483
+ expected: [1, 0],
1484
+ desc: 'unicode - second line start',
1485
+ },
1486
+ {
1487
+ text: '你好\n世界',
1488
+ offset: 4,
1489
+ expected: [1, 1],
1490
+ desc: 'unicode - second line middle',
1491
+ },
1492
+ {
1493
+ text: '你好\n世界',
1494
+ offset: 5,
1495
+ expected: [1, 2],
1496
+ desc: 'unicode - second line end',
1497
+ },
1498
+ {
1499
+ text: '你好\n世界',
1500
+ offset: 6,
1501
+ expected: [1, 2],
1502
+ desc: 'unicode - beyond',
1503
+ },
1504
+ {
1505
+ text: 'abc\ndef',
1506
+ offset: 3,
1507
+ expected: [0, 3],
1508
+ desc: 'at newline - end of line',
1509
+ },
1510
+ {
1511
+ text: 'abc\ndef',
1512
+ offset: 4,
1513
+ expected: [1, 0],
1514
+ desc: 'at newline - after newline',
1515
+ },
1516
+ { text: '🐶🐱', offset: 0, expected: [0, 0], desc: 'emoji - start' },
1517
+ { text: '🐶🐱', offset: 1, expected: [0, 1], desc: 'emoji - middle' },
1518
+ { text: '🐶🐱', offset: 2, expected: [0, 2], desc: 'emoji - end' },
1519
+ ])('should handle $desc', ({ text, offset, expected }) => {
1520
+ expect(offsetToLogicalPos(text, offset)).toEqual(expected);
1421
1521
  });
1422
- it('should handle multi-line text', () => {
1522
+ describe('multi-line text', () => {
1423
1523
  const text = 'hello\nworld\n123';
1424
- // "hello" (5) + \n (1) + "world" (5) + \n (1) + "123" (3)
1425
- // h e l l o \n w o r l d \n 1 2 3
1426
- // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4
1427
- // Line 0: "hello" (length 5)
1428
- expect(offsetToLogicalPos(text, 0)).toEqual([0, 0]); // Start of 'hello'
1429
- expect(offsetToLogicalPos(text, 3)).toEqual([0, 3]); // 'l' in 'hello'
1430
- expect(offsetToLogicalPos(text, 5)).toEqual([0, 5]); // End of 'hello' (before \n)
1431
- // Line 1: "world" (length 5)
1432
- expect(offsetToLogicalPos(text, 6)).toEqual([1, 0]); // Start of 'world' (after \n)
1433
- expect(offsetToLogicalPos(text, 8)).toEqual([1, 2]); // 'r' in 'world'
1434
- expect(offsetToLogicalPos(text, 11)).toEqual([1, 5]); // End of 'world' (before \n)
1435
- // Line 2: "123" (length 3)
1436
- expect(offsetToLogicalPos(text, 12)).toEqual([2, 0]); // Start of '123' (after \n)
1437
- expect(offsetToLogicalPos(text, 13)).toEqual([2, 1]); // '2' in '123'
1438
- expect(offsetToLogicalPos(text, 15)).toEqual([2, 3]); // End of '123'
1439
- expect(offsetToLogicalPos(text, 20)).toEqual([2, 3]); // Beyond end of text
1440
- });
1441
- it('should handle empty lines', () => {
1442
- const text = 'a\n\nc'; // "a" (1) + \n (1) + "" (0) + \n (1) + "c" (1)
1443
- expect(offsetToLogicalPos(text, 0)).toEqual([0, 0]); // 'a'
1444
- expect(offsetToLogicalPos(text, 1)).toEqual([0, 1]); // End of 'a'
1445
- expect(offsetToLogicalPos(text, 2)).toEqual([1, 0]); // Start of empty line
1446
- expect(offsetToLogicalPos(text, 3)).toEqual([2, 0]); // Start of 'c'
1447
- expect(offsetToLogicalPos(text, 4)).toEqual([2, 1]); // End of 'c'
1448
- });
1449
- it('should handle text ending with a newline', () => {
1450
- const text = 'hello\n'; // "hello" (5) + \n (1)
1451
- expect(offsetToLogicalPos(text, 5)).toEqual([0, 5]); // End of 'hello'
1452
- expect(offsetToLogicalPos(text, 6)).toEqual([1, 0]); // Position on the new empty line after
1453
- expect(offsetToLogicalPos(text, 7)).toEqual([1, 0]); // Still on the new empty line
1454
- });
1455
- it('should handle text starting with a newline', () => {
1456
- const text = '\nhello'; // "" (0) + \n (1) + "hello" (5)
1457
- expect(offsetToLogicalPos(text, 0)).toEqual([0, 0]); // Start of first empty line
1458
- expect(offsetToLogicalPos(text, 1)).toEqual([1, 0]); // Start of 'hello'
1459
- expect(offsetToLogicalPos(text, 3)).toEqual([1, 2]); // 'l' in 'hello'
1460
- });
1461
- it('should handle empty string input', () => {
1462
- expect(offsetToLogicalPos('', 0)).toEqual([0, 0]);
1463
- expect(offsetToLogicalPos('', 5)).toEqual([0, 0]);
1464
- });
1465
- it('should handle multi-byte unicode characters correctly', () => {
1466
- const text = '你好\n世界'; // "你好" (2 chars) + \n (1) + "世界" (2 chars)
1467
- // Total "code points" for offset calculation: 2 + 1 + 2 = 5
1468
- expect(offsetToLogicalPos(text, 0)).toEqual([0, 0]); // Start of '你好'
1469
- expect(offsetToLogicalPos(text, 1)).toEqual([0, 1]); // After '你', before '好'
1470
- expect(offsetToLogicalPos(text, 2)).toEqual([0, 2]); // End of '你好'
1471
- expect(offsetToLogicalPos(text, 3)).toEqual([1, 0]); // Start of '世界'
1472
- expect(offsetToLogicalPos(text, 4)).toEqual([1, 1]); // After '世', before '界'
1473
- expect(offsetToLogicalPos(text, 5)).toEqual([1, 2]); // End of '世界'
1474
- expect(offsetToLogicalPos(text, 6)).toEqual([1, 2]); // Beyond end
1475
- });
1476
- it('should handle offset exactly at newline character', () => {
1477
- const text = 'abc\ndef';
1478
- // a b c \n d e f
1479
- // 0 1 2 3 4 5 6
1480
- expect(offsetToLogicalPos(text, 3)).toEqual([0, 3]); // End of 'abc'
1481
- // The next character is the newline, so an offset of 4 means the start of the next line.
1482
- expect(offsetToLogicalPos(text, 4)).toEqual([1, 0]); // Start of 'def'
1483
- });
1484
- it('should handle offset in the middle of a multi-byte character (should place at start of that char)', () => {
1485
- // This scenario is tricky as "offset" is usually character-based.
1486
- // Assuming cpLen and related logic handles this by treating multi-byte as one unit.
1487
- // The current implementation of offsetToLogicalPos uses cpLen, so it should be code-point aware.
1488
- const text = '🐶🐱'; // 2 code points
1489
- expect(offsetToLogicalPos(text, 0)).toEqual([0, 0]);
1490
- expect(offsetToLogicalPos(text, 1)).toEqual([0, 1]); // After 🐶
1491
- expect(offsetToLogicalPos(text, 2)).toEqual([0, 2]); // After 🐱
1524
+ it.each([
1525
+ { offset: 0, expected: [0, 0], desc: 'start of first line' },
1526
+ { offset: 3, expected: [0, 3], desc: 'middle of first line' },
1527
+ { offset: 5, expected: [0, 5], desc: 'end of first line' },
1528
+ { offset: 6, expected: [1, 0], desc: 'start of second line' },
1529
+ { offset: 8, expected: [1, 2], desc: 'middle of second line' },
1530
+ { offset: 11, expected: [1, 5], desc: 'end of second line' },
1531
+ { offset: 12, expected: [2, 0], desc: 'start of third line' },
1532
+ { offset: 13, expected: [2, 1], desc: 'middle of third line' },
1533
+ { offset: 15, expected: [2, 3], desc: 'end of third line' },
1534
+ { offset: 20, expected: [2, 3], desc: 'beyond end' },
1535
+ ])('should return $expected for $desc (offset $offset)', ({ offset, expected }) => {
1536
+ expect(offsetToLogicalPos(text, offset)).toEqual(expected);
1537
+ });
1492
1538
  });
1493
1539
  });
1494
1540
  describe('logicalPosToOffset', () => {
@@ -1538,7 +1584,6 @@ describe('logicalPosToOffset', () => {
1538
1584
  expect(logicalPosToOffset(lines, 5, 10)).toBe(5); // Clamps to end of last line
1539
1585
  });
1540
1586
  });
1541
- // Helper to create state for reducer tests
1542
1587
  const createTestState = (lines, cursorRow, cursorCol, viewportWidth = 80) => {
1543
1588
  const text = lines.join('\n');
1544
1589
  let state = textBufferReducer(initialState, {