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.
- package/dist/components/MultilineInput/__tests__/useTextInput.test.js +206 -1
- package/dist/components/MultilineInput/index.d.ts +12 -0
- package/dist/components/MultilineInput/index.js +4 -4
- package/dist/components/MultilineInput/useTextInput.d.ts +8 -1
- package/dist/components/MultilineInput/useTextInput.js +90 -14
- package/dist/examples/examples/basic.d.ts +1 -0
- package/dist/examples/examples/basic.js +9 -0
- package/dist/examples/src/components/MultilineInput/KeyHandler.d.ts +15 -0
- package/dist/examples/src/components/MultilineInput/KeyHandler.js +97 -0
- package/dist/examples/src/components/MultilineInput/TextBuffer.d.ts +34 -0
- package/dist/examples/src/components/MultilineInput/TextBuffer.js +127 -0
- package/dist/examples/src/components/MultilineInput/TextRenderer.d.ts +24 -0
- package/dist/examples/src/components/MultilineInput/TextRenderer.js +72 -0
- package/dist/examples/src/components/MultilineInput/__tests__/KeyHandler.test.d.ts +1 -0
- package/dist/examples/src/components/MultilineInput/__tests__/KeyHandler.test.js +115 -0
- package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.d.ts +1 -0
- package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.js +254 -0
- package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.d.ts +1 -0
- package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.js +176 -0
- package/dist/examples/src/components/MultilineInput/__tests__/integration.test.d.ts +1 -0
- package/dist/examples/src/components/MultilineInput/__tests__/integration.test.js +71 -0
- package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.d.ts +1 -0
- package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.js +65 -0
- package/dist/examples/src/components/MultilineInput/index.d.ts +39 -0
- package/dist/examples/src/components/MultilineInput/index.js +82 -0
- package/dist/examples/src/components/MultilineInput/types.d.ts +55 -0
- package/dist/examples/src/components/MultilineInput/types.js +1 -0
- package/dist/examples/src/components/MultilineInput/useTextInput.d.ts +16 -0
- package/dist/examples/src/components/MultilineInput/useTextInput.js +82 -0
- package/dist/examples/src/hello.test.d.ts +1 -0
- package/dist/examples/src/hello.test.js +13 -0
- package/dist/examples/src/index.d.ts +2 -0
- package/dist/examples/src/index.js +2 -0
- package/dist/hooks/__tests__/useTerminalWidth.test.js +69 -1
- package/dist/hooks/useTerminalWidth.d.ts +2 -1
- package/dist/hooks/useTerminalWidth.js +19 -4
- 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
|
-
|
|
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
|
-
|
|
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,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;
|