ink-prompt 0.1.7 → 0.1.9

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 (37) hide show
  1. package/dist/components/MultilineInput/__tests__/useTextInput.test.js +206 -1
  2. package/dist/components/MultilineInput/index.d.ts +12 -0
  3. package/dist/components/MultilineInput/index.js +4 -4
  4. package/dist/components/MultilineInput/useTextInput.d.ts +8 -1
  5. package/dist/components/MultilineInput/useTextInput.js +90 -14
  6. package/dist/examples/examples/basic.d.ts +1 -0
  7. package/dist/examples/examples/basic.js +9 -0
  8. package/dist/examples/src/components/MultilineInput/KeyHandler.d.ts +15 -0
  9. package/dist/examples/src/components/MultilineInput/KeyHandler.js +97 -0
  10. package/dist/examples/src/components/MultilineInput/TextBuffer.d.ts +34 -0
  11. package/dist/examples/src/components/MultilineInput/TextBuffer.js +127 -0
  12. package/dist/examples/src/components/MultilineInput/TextRenderer.d.ts +24 -0
  13. package/dist/examples/src/components/MultilineInput/TextRenderer.js +72 -0
  14. package/dist/examples/src/components/MultilineInput/__tests__/KeyHandler.test.d.ts +1 -0
  15. package/dist/examples/src/components/MultilineInput/__tests__/KeyHandler.test.js +115 -0
  16. package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.d.ts +1 -0
  17. package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.js +254 -0
  18. package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.d.ts +1 -0
  19. package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.js +176 -0
  20. package/dist/examples/src/components/MultilineInput/__tests__/integration.test.d.ts +1 -0
  21. package/dist/examples/src/components/MultilineInput/__tests__/integration.test.js +71 -0
  22. package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.d.ts +1 -0
  23. package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.js +65 -0
  24. package/dist/examples/src/components/MultilineInput/index.d.ts +39 -0
  25. package/dist/examples/src/components/MultilineInput/index.js +82 -0
  26. package/dist/examples/src/components/MultilineInput/types.d.ts +55 -0
  27. package/dist/examples/src/components/MultilineInput/types.js +1 -0
  28. package/dist/examples/src/components/MultilineInput/useTextInput.d.ts +16 -0
  29. package/dist/examples/src/components/MultilineInput/useTextInput.js +82 -0
  30. package/dist/examples/src/hello.test.d.ts +1 -0
  31. package/dist/examples/src/hello.test.js +13 -0
  32. package/dist/examples/src/index.d.ts +2 -0
  33. package/dist/examples/src/index.js +2 -0
  34. package/dist/hooks/__tests__/useTerminalWidth.test.js +69 -1
  35. package/dist/hooks/useTerminalWidth.d.ts +2 -1
  36. package/dist/hooks/useTerminalWidth.js +19 -4
  37. package/package.json +3 -3
@@ -1,5 +1,5 @@
1
1
  import { renderHook, act } from '@testing-library/react';
2
- import { describe, it, expect } from 'vitest';
2
+ import { describe, it, expect, vi } from 'vitest';
3
3
  import { useTextInput } from '../useTextInput.js';
4
4
  describe('useTextInput', () => {
5
5
  it('should initialize with empty buffer', () => {
@@ -62,6 +62,74 @@ describe('useTextInput', () => {
62
62
  });
63
63
  expect(result.current.value).toBe('a');
64
64
  });
65
+ it('should batch consecutive inserts into one undo step when undoDebounceMs is set', () => {
66
+ vi.useFakeTimers();
67
+ const { result } = renderHook(() => useTextInput({ undoDebounceMs: 200 }));
68
+ act(() => {
69
+ result.current.insert('a');
70
+ });
71
+ act(() => {
72
+ result.current.insert('b');
73
+ });
74
+ act(() => {
75
+ result.current.insert('c');
76
+ });
77
+ expect(result.current.value).toBe('abc');
78
+ act(() => {
79
+ vi.advanceTimersByTime(200);
80
+ });
81
+ act(() => {
82
+ result.current.undo();
83
+ });
84
+ expect(result.current.value).toBe('');
85
+ act(() => {
86
+ result.current.redo();
87
+ });
88
+ expect(result.current.value).toBe('abc');
89
+ vi.useRealTimers();
90
+ });
91
+ it('should undo a pending debounced batch without waiting for commit', () => {
92
+ vi.useFakeTimers();
93
+ const { result } = renderHook(() => useTextInput({ undoDebounceMs: 200 }));
94
+ act(() => {
95
+ result.current.insert('a');
96
+ });
97
+ act(() => {
98
+ result.current.insert('b');
99
+ });
100
+ expect(result.current.value).toBe('ab');
101
+ act(() => {
102
+ result.current.undo();
103
+ });
104
+ expect(result.current.value).toBe('');
105
+ vi.useRealTimers();
106
+ });
107
+ it('should create separate undo steps when there is an idle gap', () => {
108
+ vi.useFakeTimers();
109
+ const { result } = renderHook(() => useTextInput({ undoDebounceMs: 200 }));
110
+ act(() => {
111
+ result.current.insert('a');
112
+ });
113
+ act(() => {
114
+ vi.advanceTimersByTime(200);
115
+ });
116
+ act(() => {
117
+ result.current.insert('b');
118
+ });
119
+ act(() => {
120
+ vi.advanceTimersByTime(200);
121
+ });
122
+ expect(result.current.value).toBe('ab');
123
+ act(() => {
124
+ result.current.undo();
125
+ });
126
+ expect(result.current.value).toBe('a');
127
+ act(() => {
128
+ result.current.undo();
129
+ });
130
+ expect(result.current.value).toBe('');
131
+ vi.useRealTimers();
132
+ });
65
133
  it('should remove backslash when deleted at end of line', () => {
66
134
  const { result } = renderHook(() => useTextInput({ initialValue: 'hello\\' }));
67
135
  // Cursor should be at end: { line: 0, column: 6 } (after the backslash)
@@ -169,4 +237,141 @@ describe('useTextInput', () => {
169
237
  expect(result.current.cursor).toEqual({ line: 0, column: 5 });
170
238
  });
171
239
  });
240
+ describe('history limit', () => {
241
+ it('should use default history limit of 100', () => {
242
+ const { result } = renderHook(() => useTextInput({ undoDebounceMs: 0 }));
243
+ // Insert 5 characters separately
244
+ for (let i = 0; i < 5; i++) {
245
+ act(() => {
246
+ result.current.insert('a');
247
+ });
248
+ }
249
+ expect(result.current.value).toBe('aaaaa');
250
+ // Undo 5 times
251
+ for (let i = 0; i < 5; i++) {
252
+ act(() => {
253
+ result.current.undo();
254
+ });
255
+ }
256
+ // Should be back to empty
257
+ expect(result.current.value).toBe('');
258
+ // Try to undo again - should have no effect
259
+ const valueBefore = result.current.value;
260
+ act(() => {
261
+ result.current.undo();
262
+ });
263
+ expect(result.current.value).toBe(valueBefore);
264
+ });
265
+ it('should respect custom history limit', () => {
266
+ const { result } = renderHook(() => useTextInput({ historyLimit: 3, undoDebounceMs: 0 }));
267
+ // Insert 5 characters
268
+ for (let i = 0; i < 5; i++) {
269
+ act(() => {
270
+ result.current.insert('a');
271
+ });
272
+ }
273
+ expect(result.current.value).toBe('aaaaa');
274
+ // Undo 4 times - only 3 should work due to limit
275
+ for (let i = 0; i < 4; i++) {
276
+ act(() => {
277
+ result.current.undo();
278
+ });
279
+ }
280
+ // Should be back to 2 characters (5 - 3 undos, since only 3 history entries)
281
+ expect(result.current.value).toBe('aa');
282
+ // Try to undo again - should have no effect
283
+ const valueBefore = result.current.value;
284
+ act(() => {
285
+ result.current.undo();
286
+ });
287
+ expect(result.current.value).toBe(valueBefore);
288
+ });
289
+ it('should trim oldest history when limit exceeded', () => {
290
+ const { result } = renderHook(() => useTextInput({ historyLimit: 3, undoDebounceMs: 0 }));
291
+ // Insert 5 characters
292
+ for (let i = 0; i < 5; i++) {
293
+ act(() => {
294
+ result.current.insert('a');
295
+ });
296
+ }
297
+ expect(result.current.value).toBe('aaaaa');
298
+ // Can undo 3 times (limited by history)
299
+ for (let i = 0; i < 3; i++) {
300
+ act(() => {
301
+ result.current.undo();
302
+ });
303
+ }
304
+ // Should be at 2 characters
305
+ expect(result.current.value).toBe('aa');
306
+ // Try to undo one more time - should have no effect (no more history)
307
+ const valueBefore = result.current.value;
308
+ act(() => {
309
+ result.current.undo();
310
+ });
311
+ expect(result.current.value).toBe(valueBefore);
312
+ });
313
+ it('should clear redo stack when new edit happens', () => {
314
+ const { result } = renderHook(() => useTextInput({ historyLimit: 5, undoDebounceMs: 0 }));
315
+ act(() => {
316
+ result.current.insert('a');
317
+ });
318
+ act(() => {
319
+ result.current.insert('b');
320
+ });
321
+ act(() => {
322
+ result.current.insert('c');
323
+ });
324
+ expect(result.current.value).toBe('abc');
325
+ // Undo once
326
+ act(() => {
327
+ result.current.undo();
328
+ });
329
+ expect(result.current.value).toBe('ab');
330
+ // Redo should work
331
+ act(() => {
332
+ result.current.redo();
333
+ });
334
+ expect(result.current.value).toBe('abc');
335
+ // Undo again
336
+ act(() => {
337
+ result.current.undo();
338
+ });
339
+ expect(result.current.value).toBe('ab');
340
+ // Insert new character - redo stack should be cleared
341
+ act(() => {
342
+ result.current.insert('d');
343
+ });
344
+ expect(result.current.value).toBe('abd');
345
+ // Redo should not work now (redo stack was cleared on insert)
346
+ const valueBefore = result.current.value;
347
+ act(() => {
348
+ result.current.redo();
349
+ });
350
+ expect(result.current.value).toBe(valueBefore);
351
+ });
352
+ it('should prevent excessive memory use with bounded history', () => {
353
+ const { result } = renderHook(() => useTextInput({ historyLimit: 10, undoDebounceMs: 0 }));
354
+ // Insert 30 characters
355
+ for (let i = 0; i < 30; i++) {
356
+ act(() => {
357
+ result.current.insert('x');
358
+ });
359
+ }
360
+ expect(result.current.value).toBe('x'.repeat(30));
361
+ // We should only be able to undo 10 times due to history limit
362
+ for (let i = 0; i < 10; i++) {
363
+ act(() => {
364
+ result.current.undo();
365
+ });
366
+ }
367
+ // Should be back to 20 characters (30 - 10 undos)
368
+ expect(result.current.value).toBe('x'.repeat(20));
369
+ // No more undos available
370
+ const valueBefore = result.current.value;
371
+ act(() => {
372
+ result.current.undo();
373
+ });
374
+ expect(result.current.value).toBe(valueBefore);
375
+ });
376
+ });
172
377
  });
@@ -52,6 +52,12 @@ export interface MultilineInputProps {
52
52
  * - 'right': cursor is at end of text (after last character)
53
53
  */
54
54
  onBoundaryArrow?: (direction: 'up' | 'down' | 'left' | 'right') => void;
55
+ /**
56
+ * Batches consecutive single-character inserts into a single undo step.
57
+ * The batch is committed after this many milliseconds of inactivity (default: 200).
58
+ * Set to 0 to disable batching (undo will be per edit again).
59
+ */
60
+ undoDebounceMs?: number;
55
61
  }
56
62
  /**
57
63
  * Props for the core component (without Ink-specific hooks)
@@ -105,6 +111,12 @@ export interface MultilineInputCoreProps {
105
111
  * - 'right': cursor is at end of text (after last character)
106
112
  */
107
113
  onBoundaryArrow?: (direction: 'up' | 'down' | 'left' | 'right') => void;
114
+ /**
115
+ * Batches consecutive single-character inserts into a single undo step.
116
+ * The batch is committed after this many milliseconds of inactivity (default: 200).
117
+ * Set to 0 to disable batching (undo will be per edit again).
118
+ */
119
+ undoDebounceMs?: number;
108
120
  }
109
121
  /**
110
122
  * Core rendering component that can be tested without Ink runtime.
@@ -11,8 +11,8 @@ import { log } from '../../utils/logger.js';
11
11
  * Core rendering component that can be tested without Ink runtime.
12
12
  * Does not include useInput/useStdout hooks.
13
13
  */
14
- export const MultilineInputCore = ({ value, onChange, placeholder, showCursor = true, width = 80, onCursorChange, cursorOverride, }) => {
15
- const textInput = useTextInput({ initialValue: value ?? '' });
14
+ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor = true, width = 80, onCursorChange, cursorOverride, undoDebounceMs, }) => {
15
+ const textInput = useTextInput({ initialValue: value ?? '', undoDebounceMs });
16
16
  // Track whether a value change is from syncing props (not user input)
17
17
  const isSyncingFromProps = useRef(false);
18
18
  // Handle cursor override
@@ -66,7 +66,7 @@ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor =
66
66
  * Full MultilineInput with Ink keyboard handling.
67
67
  * This component uses Ink-specific hooks and must be rendered in an Ink context.
68
68
  */
69
- export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCursor = true, width, isActive = true, onCursorChange, cursorOverride, onBoundaryArrow, }) => {
69
+ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCursor = true, width, isActive = true, onCursorChange, cursorOverride, onBoundaryArrow, undoDebounceMs, }) => {
70
70
  // Get terminal width from Ink (with resize support) if not provided
71
71
  const terminalWidth = useTerminalWidth(width);
72
72
  // Track raw input for detecting Home/End keys
@@ -84,7 +84,7 @@ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCur
84
84
  stdin.off('data', handleData);
85
85
  };
86
86
  }, [stdin, isActive]);
87
- const textInput = useTextInput({ initialValue: value ?? '', width: terminalWidth });
87
+ const textInput = useTextInput({ initialValue: value ?? '', width: terminalWidth, undoDebounceMs });
88
88
  // Handle cursor override
89
89
  useEffect(() => {
90
90
  if (cursorOverride !== undefined) {
@@ -3,6 +3,13 @@ export interface UseTextInputProps {
3
3
  initialValue?: string;
4
4
  /** Terminal width for visual-aware cursor navigation (up/down arrows respect line wrapping) */
5
5
  width?: number;
6
+ /** Maximum number of history entries to keep (default: 100) */
7
+ historyLimit?: number;
8
+ /**
9
+ * When > 0, consecutive single-character inserts are batched into a single undo step.
10
+ * A batch is committed after this many milliseconds of inactivity (default: 200).
11
+ */
12
+ undoDebounceMs?: number;
6
13
  }
7
14
  export interface UseTextInputResult {
8
15
  value: string;
@@ -19,4 +26,4 @@ export interface UseTextInputResult {
19
26
  cursorOffset: number;
20
27
  setCursorOffset: (offset: number) => void;
21
28
  }
22
- export declare function useTextInput({ initialValue, width }?: UseTextInputProps): UseTextInputResult;
29
+ export declare function useTextInput({ initialValue, width, historyLimit, undoDebounceMs, }?: UseTextInputProps): UseTextInputResult;
@@ -1,7 +1,7 @@
1
- import { useState, useCallback } from 'react';
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
2
  import { createBuffer, insertText as bufferInsertText, deleteChar as bufferDeleteChar, deleteCharForward as bufferDeleteCharForward, insertNewLine as bufferInsertNewLine, moveCursor as bufferMoveCursor, getTextContent, getOffset, getCursor, } from './TextBuffer.js';
3
3
  import { log } from '../../utils/logger.js';
4
- export function useTextInput({ initialValue = '', width } = {}) {
4
+ export function useTextInput({ initialValue = '', width, historyLimit = 100, undoDebounceMs = 200, } = {}) {
5
5
  const [buffer, setBuffer] = useState(() => createBuffer(initialValue));
6
6
  const [cursor, setCursor] = useState(() => {
7
7
  const lines = initialValue.split('\n');
@@ -12,39 +12,100 @@ export function useTextInput({ initialValue = '', width } = {}) {
12
12
  });
13
13
  const [undoStack, setUndoStack] = useState([]);
14
14
  const [redoStack, setRedoStack] = useState([]);
15
+ const pendingInsertBatchRef = useRef({});
16
+ const clearPendingInsertTimer = useCallback(() => {
17
+ if (pendingInsertBatchRef.current.timerId) {
18
+ clearTimeout(pendingInsertBatchRef.current.timerId);
19
+ pendingInsertBatchRef.current.timerId = undefined;
20
+ }
21
+ }, []);
22
+ const appendUndoState = useCallback((state) => {
23
+ setUndoStack((prev) => {
24
+ const newStack = [...prev, state];
25
+ if (newStack.length > historyLimit) {
26
+ return newStack.slice(-historyLimit);
27
+ }
28
+ return newStack;
29
+ });
30
+ }, [historyLimit]);
31
+ const commitPendingInsertBatch = useCallback(() => {
32
+ const startState = pendingInsertBatchRef.current.startState;
33
+ if (!startState)
34
+ return;
35
+ clearPendingInsertTimer();
36
+ pendingInsertBatchRef.current.startState = undefined;
37
+ appendUndoState(startState);
38
+ }, [appendUndoState, clearPendingInsertTimer]);
39
+ const schedulePendingInsertCommit = useCallback(() => {
40
+ if (undoDebounceMs <= 0)
41
+ return;
42
+ clearPendingInsertTimer();
43
+ pendingInsertBatchRef.current.timerId = setTimeout(() => {
44
+ commitPendingInsertBatch();
45
+ }, undoDebounceMs);
46
+ }, [clearPendingInsertTimer, commitPendingInsertBatch, undoDebounceMs]);
47
+ const beginOrRefreshInsertBatch = useCallback((currentBuffer, currentCursor) => {
48
+ if (!pendingInsertBatchRef.current.startState) {
49
+ pendingInsertBatchRef.current.startState = { buffer: currentBuffer, cursor: currentCursor };
50
+ setRedoStack([]);
51
+ }
52
+ schedulePendingInsertCommit();
53
+ }, [schedulePendingInsertCommit]);
54
+ const flushPendingInsertBatch = useCallback(() => {
55
+ commitPendingInsertBatch();
56
+ }, [commitPendingInsertBatch]);
15
57
  const pushToHistory = useCallback((currentBuffer, currentCursor) => {
16
- setUndoStack((prev) => [...prev, { buffer: currentBuffer, cursor: currentCursor }]);
58
+ appendUndoState({ buffer: currentBuffer, cursor: currentCursor });
17
59
  setRedoStack([]);
18
- }, []);
60
+ }, [appendUndoState]);
61
+ useEffect(() => {
62
+ return () => {
63
+ clearPendingInsertTimer();
64
+ pendingInsertBatchRef.current.startState = undefined;
65
+ };
66
+ }, [clearPendingInsertTimer]);
19
67
  const insert = useCallback((char) => {
20
68
  log(`[INSERT] char="${char.replace(/[\x00-\x1F\x7F-\uFFFF]/g, c => `\\x${c.charCodeAt(0).toString(16)}`)}" len=${char.length} cursor={line:${cursor.line},col:${cursor.column}} linesBefore=${buffer.lines.length}`);
21
69
  // Normalize line endings: \r\n → \n, \r → \n (handles Windows, Unix, and old Mac)
22
70
  const normalized = char.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
23
- pushToHistory(buffer, cursor);
71
+ const canBatchInsert = undoDebounceMs > 0 &&
72
+ normalized.length === 1 &&
73
+ normalized !== '\n';
74
+ if (canBatchInsert) {
75
+ beginOrRefreshInsertBatch(buffer, cursor);
76
+ }
77
+ else {
78
+ flushPendingInsertBatch();
79
+ pushToHistory(buffer, cursor);
80
+ }
24
81
  // TextBuffer now handles multi-line insertion internally
25
82
  const result = bufferInsertText(buffer, cursor, normalized);
26
83
  setBuffer(result.buffer);
27
84
  setCursor(result.cursor);
28
- }, [buffer, cursor, pushToHistory]);
85
+ }, [beginOrRefreshInsertBatch, buffer, cursor, flushPendingInsertBatch, pushToHistory, undoDebounceMs]);
29
86
  const deleteChar = useCallback(() => {
87
+ flushPendingInsertBatch();
30
88
  pushToHistory(buffer, cursor);
31
89
  const result = bufferDeleteChar(buffer, cursor);
32
90
  setBuffer(result.buffer);
33
91
  setCursor(result.cursor);
34
- }, [buffer, cursor, pushToHistory]);
92
+ }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
35
93
  const deleteCharForward = useCallback(() => {
94
+ flushPendingInsertBatch();
36
95
  pushToHistory(buffer, cursor);
37
96
  const result = bufferDeleteCharForward(buffer, cursor);
38
97
  setBuffer(result.buffer);
39
98
  setCursor(result.cursor);
40
- }, [buffer, cursor, pushToHistory]);
99
+ }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
41
100
  const newLine = useCallback(() => {
101
+ flushPendingInsertBatch();
42
102
  pushToHistory(buffer, cursor);
43
103
  const result = bufferInsertNewLine(buffer, cursor);
44
104
  setBuffer(result.buffer);
45
105
  setCursor(result.cursor);
46
- }, [buffer, cursor, pushToHistory]);
106
+ }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
47
107
  const deleteAndNewLine = useCallback(() => {
108
+ flushPendingInsertBatch();
48
109
  pushToHistory(buffer, cursor);
49
110
  // First delete the character before cursor (the backslash)
50
111
  const afterDelete = bufferDeleteChar(buffer, cursor);
@@ -52,12 +113,22 @@ export function useTextInput({ initialValue = '', width } = {}) {
52
113
  const afterNewLine = bufferInsertNewLine(afterDelete.buffer, afterDelete.cursor);
53
114
  setBuffer(afterNewLine.buffer);
54
115
  setCursor(afterNewLine.cursor);
55
- }, [buffer, cursor, pushToHistory]);
116
+ }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
56
117
  const moveCursor = useCallback((direction) => {
118
+ flushPendingInsertBatch();
57
119
  const newCursor = bufferMoveCursor(buffer, cursor, direction, width);
58
120
  setCursor(newCursor);
59
- }, [buffer, cursor, width]);
121
+ }, [buffer, cursor, flushPendingInsertBatch, width]);
60
122
  const undo = useCallback(() => {
123
+ const pendingStartState = pendingInsertBatchRef.current.startState;
124
+ if (pendingStartState) {
125
+ clearPendingInsertTimer();
126
+ pendingInsertBatchRef.current.startState = undefined;
127
+ setRedoStack((prev) => [...prev, { buffer, cursor }]);
128
+ setBuffer(pendingStartState.buffer);
129
+ setCursor(pendingStartState.cursor);
130
+ return;
131
+ }
61
132
  if (undoStack.length === 0)
62
133
  return;
63
134
  const previousState = undoStack[undoStack.length - 1];
@@ -66,8 +137,11 @@ export function useTextInput({ initialValue = '', width } = {}) {
66
137
  setBuffer(previousState.buffer);
67
138
  setCursor(previousState.cursor);
68
139
  setUndoStack(newUndoStack);
69
- }, [buffer, cursor, undoStack]);
140
+ }, [buffer, clearPendingInsertTimer, cursor, undoStack]);
70
141
  const redo = useCallback(() => {
142
+ if (pendingInsertBatchRef.current.startState) {
143
+ return;
144
+ }
71
145
  if (redoStack.length === 0)
72
146
  return;
73
147
  const nextState = redoStack[redoStack.length - 1];
@@ -78,6 +152,7 @@ export function useTextInput({ initialValue = '', width } = {}) {
78
152
  setRedoStack(newRedoStack);
79
153
  }, [buffer, cursor, redoStack]);
80
154
  const setText = useCallback((text) => {
155
+ flushPendingInsertBatch();
81
156
  pushToHistory(buffer, cursor);
82
157
  const newBuffer = createBuffer(text);
83
158
  setBuffer(newBuffer);
@@ -87,7 +162,7 @@ export function useTextInput({ initialValue = '', width } = {}) {
87
162
  line: lines.length - 1,
88
163
  column: lines[lines.length - 1].length,
89
164
  });
90
- }, [buffer, cursor, pushToHistory]);
165
+ }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
91
166
  return {
92
167
  value: getTextContent(buffer),
93
168
  cursor,
@@ -102,7 +177,8 @@ export function useTextInput({ initialValue = '', width } = {}) {
102
177
  setText,
103
178
  cursorOffset: getOffset(buffer, cursor),
104
179
  setCursorOffset: useCallback((offset) => {
180
+ flushPendingInsertBatch();
105
181
  setCursor(getCursor(buffer, offset));
106
- }, [buffer]),
182
+ }, [buffer, flushPendingInsertBatch]),
107
183
  };
108
184
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import { render } from 'ink';
3
+ import { MultilineInput } from '../src/index.js';
4
+ const App = () => {
5
+ return (React.createElement(MultilineInput
6
+ // Add props here
7
+ , null));
8
+ };
9
+ render(React.createElement(App, null));
@@ -0,0 +1,15 @@
1
+ import { type Key, type Buffer, type Cursor } from './types';
2
+ import { type UseTextInputResult } from './useTextInput';
3
+ export interface KeyHandlerActions extends Omit<UseTextInputResult, 'value' | 'cursor'> {
4
+ submit: () => void;
5
+ }
6
+ /**
7
+ * Handles keyboard input and maps it to text input actions.
8
+ *
9
+ * @param key - The Ink key object
10
+ * @param input - The input string (if any)
11
+ * @param buffer - The current text buffer
12
+ * @param actions - The actions available to modify the state
13
+ * @param cursor - The current cursor position (optional, but required for some logic like backslash check)
14
+ */
15
+ export declare function handleKey(key: Partial<Key>, input: string, buffer: Buffer, actions: KeyHandlerActions, cursor?: Cursor): void;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Handles keyboard input and maps it to text input actions.
3
+ *
4
+ * @param key - The Ink key object
5
+ * @param input - The input string (if any)
6
+ * @param buffer - The current text buffer
7
+ * @param actions - The actions available to modify the state
8
+ * @param cursor - The current cursor position (optional, but required for some logic like backslash check)
9
+ */
10
+ export function handleKey(key, input, buffer, actions, cursor) {
11
+ // Navigation
12
+ if (key.upArrow) {
13
+ actions.moveCursor('up');
14
+ return;
15
+ }
16
+ if (key.downArrow) {
17
+ actions.moveCursor('down');
18
+ return;
19
+ }
20
+ if (key.leftArrow) {
21
+ actions.moveCursor('left');
22
+ return;
23
+ }
24
+ if (key.rightArrow) {
25
+ actions.moveCursor('right');
26
+ return;
27
+ }
28
+ // Home/End (Ink might not provide these directly in all environments, but if it does)
29
+ // We check for 'home' and 'end' properties if they exist on the key object,
30
+ // or specific sequences if we were parsing raw input, but here we assume Ink's Key object.
31
+ // Note: Ink's Key interface might not have home/end in all versions, but we'll assume it does or we extend it.
32
+ // If not, we might need to check specific input sequences, but for now let's trust the test/types.
33
+ if (key.home) {
34
+ actions.moveCursor('lineStart');
35
+ return;
36
+ }
37
+ if (key.end) {
38
+ actions.moveCursor('lineEnd');
39
+ return;
40
+ }
41
+ // History
42
+ if (key.ctrl) {
43
+ if (input === 'z') {
44
+ actions.undo();
45
+ return;
46
+ }
47
+ if (input === 'y') {
48
+ actions.redo();
49
+ return;
50
+ }
51
+ if (input === 'j') {
52
+ actions.newLine();
53
+ return;
54
+ }
55
+ }
56
+ // Editing
57
+ if (key.backspace) {
58
+ actions.delete();
59
+ return;
60
+ }
61
+ if (key.delete) {
62
+ // Currently mapped to delete (backspace behavior) as per requirements/tests,
63
+ // but usually delete is forward.
64
+ // The plan said "Delete (delete at cursor)", which usually means forward.
65
+ // But our useTextInput only has `delete` (which is backspace).
66
+ // For now, we map it to `delete` as per the test "handles Delete".
67
+ actions.delete();
68
+ return;
69
+ }
70
+ // Submission / New Line
71
+ if (key.return) {
72
+ if (cursor) {
73
+ const currentLine = buffer.lines[cursor.line];
74
+ // Check if line ends with backslash AND cursor is at the end (or we just check the line content?)
75
+ // Requirement: "Line ending with \ + Enter continues to next line"
76
+ // Usually this implies the user typed '\' then Enter.
77
+ // We should probably check if the character *before* the cursor is '\' if we want to be precise,
78
+ // or just if the line ends with '\'.
79
+ // Let's assume "line ends with \" means the last char of the line is '\'.
80
+ if (currentLine.endsWith('\\')) {
81
+ actions.delete(); // Remove the backslash
82
+ actions.newLine(); // Insert newline
83
+ return;
84
+ }
85
+ }
86
+ actions.submit();
87
+ return;
88
+ }
89
+ // Text Insertion
90
+ // Ignore control keys if they don't have a specific handler above
91
+ if (key.ctrl || key.meta) {
92
+ return;
93
+ }
94
+ if (input) {
95
+ actions.insert(input);
96
+ }
97
+ }
@@ -0,0 +1,34 @@
1
+ import type { Buffer, Cursor, Direction } from './types';
2
+ /**
3
+ * Create a new buffer from optional initial text
4
+ */
5
+ export declare function createBuffer(text?: string): Buffer;
6
+ /**
7
+ * Insert a character at the cursor position
8
+ */
9
+ export declare function insertChar(buffer: Buffer, cursor: Cursor, char: string): {
10
+ buffer: Buffer;
11
+ cursor: Cursor;
12
+ };
13
+ /**
14
+ * Delete character before cursor (backspace)
15
+ */
16
+ export declare function deleteChar(buffer: Buffer, cursor: Cursor): {
17
+ buffer: Buffer;
18
+ cursor: Cursor;
19
+ };
20
+ /**
21
+ * Insert a new line at cursor position (splits current line)
22
+ */
23
+ export declare function insertNewLine(buffer: Buffer, cursor: Cursor): {
24
+ buffer: Buffer;
25
+ cursor: Cursor;
26
+ };
27
+ /**
28
+ * Move cursor in specified direction with bounds checking
29
+ */
30
+ export declare function moveCursor(buffer: Buffer, cursor: Cursor, direction: Direction): Cursor;
31
+ /**
32
+ * Get the full text content from buffer (lines joined with newlines)
33
+ */
34
+ export declare function getTextContent(buffer: Buffer): string;