ink-prompt 0.1.8 → 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 +74 -6
- package/dist/components/MultilineInput/index.d.ts +12 -0
- package/dist/components/MultilineInput/index.js +4 -4
- package/dist/components/MultilineInput/useTextInput.d.ts +6 -1
- package/dist/components/MultilineInput/useTextInput.js +85 -16
- 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/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)
|
|
@@ -171,7 +239,7 @@ describe('useTextInput', () => {
|
|
|
171
239
|
});
|
|
172
240
|
describe('history limit', () => {
|
|
173
241
|
it('should use default history limit of 100', () => {
|
|
174
|
-
const { result } = renderHook(() => useTextInput());
|
|
242
|
+
const { result } = renderHook(() => useTextInput({ undoDebounceMs: 0 }));
|
|
175
243
|
// Insert 5 characters separately
|
|
176
244
|
for (let i = 0; i < 5; i++) {
|
|
177
245
|
act(() => {
|
|
@@ -195,7 +263,7 @@ describe('useTextInput', () => {
|
|
|
195
263
|
expect(result.current.value).toBe(valueBefore);
|
|
196
264
|
});
|
|
197
265
|
it('should respect custom history limit', () => {
|
|
198
|
-
const { result } = renderHook(() => useTextInput({ historyLimit: 3 }));
|
|
266
|
+
const { result } = renderHook(() => useTextInput({ historyLimit: 3, undoDebounceMs: 0 }));
|
|
199
267
|
// Insert 5 characters
|
|
200
268
|
for (let i = 0; i < 5; i++) {
|
|
201
269
|
act(() => {
|
|
@@ -219,7 +287,7 @@ describe('useTextInput', () => {
|
|
|
219
287
|
expect(result.current.value).toBe(valueBefore);
|
|
220
288
|
});
|
|
221
289
|
it('should trim oldest history when limit exceeded', () => {
|
|
222
|
-
const { result } = renderHook(() => useTextInput({ historyLimit: 3 }));
|
|
290
|
+
const { result } = renderHook(() => useTextInput({ historyLimit: 3, undoDebounceMs: 0 }));
|
|
223
291
|
// Insert 5 characters
|
|
224
292
|
for (let i = 0; i < 5; i++) {
|
|
225
293
|
act(() => {
|
|
@@ -243,7 +311,7 @@ describe('useTextInput', () => {
|
|
|
243
311
|
expect(result.current.value).toBe(valueBefore);
|
|
244
312
|
});
|
|
245
313
|
it('should clear redo stack when new edit happens', () => {
|
|
246
|
-
const { result } = renderHook(() => useTextInput({ historyLimit: 5 }));
|
|
314
|
+
const { result } = renderHook(() => useTextInput({ historyLimit: 5, undoDebounceMs: 0 }));
|
|
247
315
|
act(() => {
|
|
248
316
|
result.current.insert('a');
|
|
249
317
|
});
|
|
@@ -282,7 +350,7 @@ describe('useTextInput', () => {
|
|
|
282
350
|
expect(result.current.value).toBe(valueBefore);
|
|
283
351
|
});
|
|
284
352
|
it('should prevent excessive memory use with bounded history', () => {
|
|
285
|
-
const { result } = renderHook(() => useTextInput({ historyLimit: 10 }));
|
|
353
|
+
const { result } = renderHook(() => useTextInput({ historyLimit: 10, undoDebounceMs: 0 }));
|
|
286
354
|
// Insert 30 characters
|
|
287
355
|
for (let i = 0; i < 30; i++) {
|
|
288
356
|
act(() => {
|
|
@@ -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) {
|
|
@@ -5,6 +5,11 @@ export interface UseTextInputProps {
|
|
|
5
5
|
width?: number;
|
|
6
6
|
/** Maximum number of history entries to keep (default: 100) */
|
|
7
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;
|
|
8
13
|
}
|
|
9
14
|
export interface UseTextInputResult {
|
|
10
15
|
value: string;
|
|
@@ -21,4 +26,4 @@ export interface UseTextInputResult {
|
|
|
21
26
|
cursorOffset: number;
|
|
22
27
|
setCursorOffset: (offset: number) => void;
|
|
23
28
|
}
|
|
24
|
-
export declare function useTextInput({ initialValue, width, historyLimit }?: 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, historyLimit = 100 } = {}) {
|
|
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,46 +12,100 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100 } =
|
|
|
12
12
|
});
|
|
13
13
|
const [undoStack, setUndoStack] = useState([]);
|
|
14
14
|
const [redoStack, setRedoStack] = useState([]);
|
|
15
|
-
const
|
|
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) => {
|
|
16
23
|
setUndoStack((prev) => {
|
|
17
|
-
const newStack = [...prev,
|
|
18
|
-
// Trim stack if it exceeds history limit
|
|
24
|
+
const newStack = [...prev, state];
|
|
19
25
|
if (newStack.length > historyLimit) {
|
|
20
26
|
return newStack.slice(-historyLimit);
|
|
21
27
|
}
|
|
22
28
|
return newStack;
|
|
23
29
|
});
|
|
24
|
-
setRedoStack([]);
|
|
25
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]);
|
|
57
|
+
const pushToHistory = useCallback((currentBuffer, currentCursor) => {
|
|
58
|
+
appendUndoState({ buffer: currentBuffer, cursor: currentCursor });
|
|
59
|
+
setRedoStack([]);
|
|
60
|
+
}, [appendUndoState]);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
return () => {
|
|
63
|
+
clearPendingInsertTimer();
|
|
64
|
+
pendingInsertBatchRef.current.startState = undefined;
|
|
65
|
+
};
|
|
66
|
+
}, [clearPendingInsertTimer]);
|
|
26
67
|
const insert = useCallback((char) => {
|
|
27
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}`);
|
|
28
69
|
// Normalize line endings: \r\n → \n, \r → \n (handles Windows, Unix, and old Mac)
|
|
29
70
|
const normalized = char.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
30
|
-
|
|
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
|
+
}
|
|
31
81
|
// TextBuffer now handles multi-line insertion internally
|
|
32
82
|
const result = bufferInsertText(buffer, cursor, normalized);
|
|
33
83
|
setBuffer(result.buffer);
|
|
34
84
|
setCursor(result.cursor);
|
|
35
|
-
}, [buffer, cursor, pushToHistory]);
|
|
85
|
+
}, [beginOrRefreshInsertBatch, buffer, cursor, flushPendingInsertBatch, pushToHistory, undoDebounceMs]);
|
|
36
86
|
const deleteChar = useCallback(() => {
|
|
87
|
+
flushPendingInsertBatch();
|
|
37
88
|
pushToHistory(buffer, cursor);
|
|
38
89
|
const result = bufferDeleteChar(buffer, cursor);
|
|
39
90
|
setBuffer(result.buffer);
|
|
40
91
|
setCursor(result.cursor);
|
|
41
|
-
}, [buffer, cursor, pushToHistory]);
|
|
92
|
+
}, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
|
|
42
93
|
const deleteCharForward = useCallback(() => {
|
|
94
|
+
flushPendingInsertBatch();
|
|
43
95
|
pushToHistory(buffer, cursor);
|
|
44
96
|
const result = bufferDeleteCharForward(buffer, cursor);
|
|
45
97
|
setBuffer(result.buffer);
|
|
46
98
|
setCursor(result.cursor);
|
|
47
|
-
}, [buffer, cursor, pushToHistory]);
|
|
99
|
+
}, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
|
|
48
100
|
const newLine = useCallback(() => {
|
|
101
|
+
flushPendingInsertBatch();
|
|
49
102
|
pushToHistory(buffer, cursor);
|
|
50
103
|
const result = bufferInsertNewLine(buffer, cursor);
|
|
51
104
|
setBuffer(result.buffer);
|
|
52
105
|
setCursor(result.cursor);
|
|
53
|
-
}, [buffer, cursor, pushToHistory]);
|
|
106
|
+
}, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
|
|
54
107
|
const deleteAndNewLine = useCallback(() => {
|
|
108
|
+
flushPendingInsertBatch();
|
|
55
109
|
pushToHistory(buffer, cursor);
|
|
56
110
|
// First delete the character before cursor (the backslash)
|
|
57
111
|
const afterDelete = bufferDeleteChar(buffer, cursor);
|
|
@@ -59,12 +113,22 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100 } =
|
|
|
59
113
|
const afterNewLine = bufferInsertNewLine(afterDelete.buffer, afterDelete.cursor);
|
|
60
114
|
setBuffer(afterNewLine.buffer);
|
|
61
115
|
setCursor(afterNewLine.cursor);
|
|
62
|
-
}, [buffer, cursor, pushToHistory]);
|
|
116
|
+
}, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
|
|
63
117
|
const moveCursor = useCallback((direction) => {
|
|
118
|
+
flushPendingInsertBatch();
|
|
64
119
|
const newCursor = bufferMoveCursor(buffer, cursor, direction, width);
|
|
65
120
|
setCursor(newCursor);
|
|
66
|
-
}, [buffer, cursor, width]);
|
|
121
|
+
}, [buffer, cursor, flushPendingInsertBatch, width]);
|
|
67
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
|
+
}
|
|
68
132
|
if (undoStack.length === 0)
|
|
69
133
|
return;
|
|
70
134
|
const previousState = undoStack[undoStack.length - 1];
|
|
@@ -73,8 +137,11 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100 } =
|
|
|
73
137
|
setBuffer(previousState.buffer);
|
|
74
138
|
setCursor(previousState.cursor);
|
|
75
139
|
setUndoStack(newUndoStack);
|
|
76
|
-
}, [buffer, cursor, undoStack]);
|
|
140
|
+
}, [buffer, clearPendingInsertTimer, cursor, undoStack]);
|
|
77
141
|
const redo = useCallback(() => {
|
|
142
|
+
if (pendingInsertBatchRef.current.startState) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
78
145
|
if (redoStack.length === 0)
|
|
79
146
|
return;
|
|
80
147
|
const nextState = redoStack[redoStack.length - 1];
|
|
@@ -85,6 +152,7 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100 } =
|
|
|
85
152
|
setRedoStack(newRedoStack);
|
|
86
153
|
}, [buffer, cursor, redoStack]);
|
|
87
154
|
const setText = useCallback((text) => {
|
|
155
|
+
flushPendingInsertBatch();
|
|
88
156
|
pushToHistory(buffer, cursor);
|
|
89
157
|
const newBuffer = createBuffer(text);
|
|
90
158
|
setBuffer(newBuffer);
|
|
@@ -94,7 +162,7 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100 } =
|
|
|
94
162
|
line: lines.length - 1,
|
|
95
163
|
column: lines[lines.length - 1].length,
|
|
96
164
|
});
|
|
97
|
-
}, [buffer, cursor, pushToHistory]);
|
|
165
|
+
}, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
|
|
98
166
|
return {
|
|
99
167
|
value: getTextContent(buffer),
|
|
100
168
|
cursor,
|
|
@@ -109,7 +177,8 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100 } =
|
|
|
109
177
|
setText,
|
|
110
178
|
cursorOffset: getOffset(buffer, cursor),
|
|
111
179
|
setCursorOffset: useCallback((offset) => {
|
|
180
|
+
flushPendingInsertBatch();
|
|
112
181
|
setCursor(getCursor(buffer, offset));
|
|
113
|
-
}, [buffer]),
|
|
182
|
+
}, [buffer, flushPendingInsertBatch]),
|
|
114
183
|
};
|
|
115
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;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a new buffer from optional initial text
|
|
3
|
+
*/
|
|
4
|
+
export function createBuffer(text) {
|
|
5
|
+
if (!text) {
|
|
6
|
+
return { lines: [''] };
|
|
7
|
+
}
|
|
8
|
+
return { lines: text.split('\n') };
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Insert a character at the cursor position
|
|
12
|
+
*/
|
|
13
|
+
export function insertChar(buffer, cursor, char) {
|
|
14
|
+
const { line, column } = cursor;
|
|
15
|
+
const currentLine = buffer.lines[line];
|
|
16
|
+
const newLine = currentLine.slice(0, column) + char + currentLine.slice(column);
|
|
17
|
+
const newLines = [...buffer.lines];
|
|
18
|
+
newLines[line] = newLine;
|
|
19
|
+
return {
|
|
20
|
+
buffer: { lines: newLines },
|
|
21
|
+
cursor: { line, column: column + 1 },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Delete character before cursor (backspace)
|
|
26
|
+
*/
|
|
27
|
+
export function deleteChar(buffer, cursor) {
|
|
28
|
+
const { line, column } = cursor;
|
|
29
|
+
// At the very start of the buffer - nothing to delete
|
|
30
|
+
if (line === 0 && column === 0) {
|
|
31
|
+
return { buffer, cursor };
|
|
32
|
+
}
|
|
33
|
+
// At the start of a line - merge with previous line
|
|
34
|
+
if (column === 0) {
|
|
35
|
+
const previousLine = buffer.lines[line - 1];
|
|
36
|
+
const currentLine = buffer.lines[line];
|
|
37
|
+
const mergedLine = previousLine + currentLine;
|
|
38
|
+
const newLines = [...buffer.lines];
|
|
39
|
+
newLines[line - 1] = mergedLine;
|
|
40
|
+
newLines.splice(line, 1);
|
|
41
|
+
return {
|
|
42
|
+
buffer: { lines: newLines },
|
|
43
|
+
cursor: { line: line - 1, column: previousLine.length },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Delete character within the line
|
|
47
|
+
const currentLine = buffer.lines[line];
|
|
48
|
+
const newLine = currentLine.slice(0, column - 1) + currentLine.slice(column);
|
|
49
|
+
const newLines = [...buffer.lines];
|
|
50
|
+
newLines[line] = newLine;
|
|
51
|
+
return {
|
|
52
|
+
buffer: { lines: newLines },
|
|
53
|
+
cursor: { line, column: column - 1 },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Insert a new line at cursor position (splits current line)
|
|
58
|
+
*/
|
|
59
|
+
export function insertNewLine(buffer, cursor) {
|
|
60
|
+
const { line, column } = cursor;
|
|
61
|
+
const currentLine = buffer.lines[line];
|
|
62
|
+
const beforeCursor = currentLine.slice(0, column);
|
|
63
|
+
const afterCursor = currentLine.slice(column);
|
|
64
|
+
const newLines = [...buffer.lines];
|
|
65
|
+
newLines[line] = beforeCursor;
|
|
66
|
+
newLines.splice(line + 1, 0, afterCursor);
|
|
67
|
+
return {
|
|
68
|
+
buffer: { lines: newLines },
|
|
69
|
+
cursor: { line: line + 1, column: 0 },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Move cursor in specified direction with bounds checking
|
|
74
|
+
*/
|
|
75
|
+
export function moveCursor(buffer, cursor, direction) {
|
|
76
|
+
const { line, column } = cursor;
|
|
77
|
+
const currentLine = buffer.lines[line];
|
|
78
|
+
const lineCount = buffer.lines.length;
|
|
79
|
+
switch (direction) {
|
|
80
|
+
case 'left':
|
|
81
|
+
if (column > 0) {
|
|
82
|
+
return { line, column: column - 1 };
|
|
83
|
+
}
|
|
84
|
+
// Wrap to end of previous line
|
|
85
|
+
if (line > 0) {
|
|
86
|
+
return { line: line - 1, column: buffer.lines[line - 1].length };
|
|
87
|
+
}
|
|
88
|
+
return cursor;
|
|
89
|
+
case 'right':
|
|
90
|
+
if (column < currentLine.length) {
|
|
91
|
+
return { line, column: column + 1 };
|
|
92
|
+
}
|
|
93
|
+
// Wrap to start of next line
|
|
94
|
+
if (line < lineCount - 1) {
|
|
95
|
+
return { line: line + 1, column: 0 };
|
|
96
|
+
}
|
|
97
|
+
return cursor;
|
|
98
|
+
case 'up':
|
|
99
|
+
if (line > 0) {
|
|
100
|
+
const targetLine = buffer.lines[line - 1];
|
|
101
|
+
return { line: line - 1, column: Math.min(column, targetLine.length) };
|
|
102
|
+
}
|
|
103
|
+
return cursor;
|
|
104
|
+
case 'down':
|
|
105
|
+
if (line < lineCount - 1) {
|
|
106
|
+
const targetLine = buffer.lines[line + 1];
|
|
107
|
+
return { line: line + 1, column: Math.min(column, targetLine.length) };
|
|
108
|
+
}
|
|
109
|
+
return cursor;
|
|
110
|
+
case 'lineStart':
|
|
111
|
+
return { line, column: 0 };
|
|
112
|
+
case 'lineEnd':
|
|
113
|
+
return { line, column: currentLine.length };
|
|
114
|
+
default:
|
|
115
|
+
return cursor;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Get the full text content from buffer (lines joined with newlines)
|
|
120
|
+
*/
|
|
121
|
+
export function getTextContent(buffer) {
|
|
122
|
+
// Single empty line is considered empty buffer
|
|
123
|
+
if (buffer.lines.length === 1 && buffer.lines[0] === '') {
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
return buffer.lines.join('\n');
|
|
127
|
+
}
|