ink-prompt 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/MultilineInput/KeyHandler.d.ts +1 -1
- package/dist/components/MultilineInput/__tests__/integration.test.js +10 -1
- package/dist/components/MultilineInput/index.d.ts +1 -0
- package/dist/components/MultilineInput/index.js +28 -23
- package/dist/components/MultilineInput/useTextInput.d.ts +4 -0
- package/dist/components/MultilineInput/useTextInput.js +28 -2
- package/package.json +5 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Key, type Buffer, type Cursor } from './types.js';
|
|
2
2
|
import { type UseTextInputResult } from './useTextInput.js';
|
|
3
|
-
export interface KeyHandlerActions extends Omit<UseTextInputResult, 'value' | 'cursor' | 'cursorOffset' | 'setCursorOffset' | 'buffer' | 'blockState' | 'insertImage' | 'images' | 'getImages' | 'setImages'> {
|
|
3
|
+
export interface KeyHandlerActions extends Omit<UseTextInputResult, 'value' | 'cursor' | 'cursorOffset' | 'setCursorOffset' | 'buffer' | 'blockState' | 'insertImage' | 'images' | 'getImages' | 'setImages' | 'syncExternalState'> {
|
|
4
4
|
submit: () => void;
|
|
5
5
|
onBoundaryArrow?: (direction: 'up' | 'down' | 'left' | 'right') => void;
|
|
6
6
|
paste?: () => void;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
-
import { render } from '@testing-library/react';
|
|
3
|
+
import { render, waitFor } from '@testing-library/react';
|
|
4
4
|
import { MultilineInputCore } from '../index.js';
|
|
5
5
|
/**
|
|
6
6
|
* Integration tests for MultilineInputCore.
|
|
@@ -66,6 +66,15 @@ describe('MultilineInputCore', () => {
|
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
68
|
describe('Controlled component behavior', () => {
|
|
69
|
+
it('applies simultaneous value and cursorOverride updates against the new value', async () => {
|
|
70
|
+
const onCursorChange = vi.fn();
|
|
71
|
+
const { rerender } = render(_jsx(MultilineInputCore, { value: "x", cursorOverride: 1, onCursorChange: onCursorChange }));
|
|
72
|
+
onCursorChange.mockClear();
|
|
73
|
+
rerender(_jsx(MultilineInputCore, { value: "abcde", cursorOverride: 4, onCursorChange: onCursorChange }));
|
|
74
|
+
await waitFor(() => {
|
|
75
|
+
expect(onCursorChange).toHaveBeenLastCalledWith(4);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
69
78
|
it('does NOT call onChange when value prop is updated by parent', () => {
|
|
70
79
|
// This tests the controlled component pattern:
|
|
71
80
|
// onChange should only fire for user-initiated changes, not prop updates
|
|
@@ -9,13 +9,13 @@ import { TextRenderer } from './TextRenderer.js';
|
|
|
9
9
|
import { useClipboardPaste } from './useClipboardPaste.js';
|
|
10
10
|
import { log } from '../../utils/logger.js';
|
|
11
11
|
export const MultilineInputCore = ({ value, onChange, placeholder, showCursor = true, width = 80, onCursorChange, cursorOverride, undoDebounceMs, pasteThreshold, formatPastePlaceholder, images, onImagesChange, maxHeight, }) => {
|
|
12
|
-
const textInput = useTextInput({
|
|
12
|
+
const textInput = useTextInput({
|
|
13
|
+
initialValue: value ?? '',
|
|
14
|
+
undoDebounceMs,
|
|
15
|
+
pasteThreshold,
|
|
16
|
+
formatPastePlaceholder,
|
|
17
|
+
});
|
|
13
18
|
const isSyncingFromProps = useRef(false);
|
|
14
|
-
useEffect(() => {
|
|
15
|
-
if (cursorOverride !== undefined) {
|
|
16
|
-
textInput.setCursorOffset(cursorOverride);
|
|
17
|
-
}
|
|
18
|
-
}, [cursorOverride]);
|
|
19
19
|
const onCursorChangeRef = useRef(onCursorChange);
|
|
20
20
|
useEffect(() => {
|
|
21
21
|
onCursorChangeRef.current = onCursorChange;
|
|
@@ -28,9 +28,9 @@ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor =
|
|
|
28
28
|
useEffect(() => {
|
|
29
29
|
if (value !== undefined && value !== textInput.value) {
|
|
30
30
|
isSyncingFromProps.current = true;
|
|
31
|
-
textInput.setText(value);
|
|
32
31
|
}
|
|
33
|
-
|
|
32
|
+
textInput.syncExternalState({ value, cursorOffset: cursorOverride });
|
|
33
|
+
}, [value, cursorOverride]);
|
|
34
34
|
const onChangeRef = useRef(onChange);
|
|
35
35
|
useEffect(() => {
|
|
36
36
|
onChangeRef.current = onChange;
|
|
@@ -64,14 +64,20 @@ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor =
|
|
|
64
64
|
const effectiveMaxHeight = maxHeight ?? defaultMaxHeight;
|
|
65
65
|
return (_jsx(TextRenderer, { buffer: textInput.buffer, cursor: textInput.cursor, width: width, showCursor: showCursor, blockState: textInput.blockState, maxHeight: effectiveMaxHeight }));
|
|
66
66
|
};
|
|
67
|
-
export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCursor = true, width, isActive = true, onCursorChange, cursorOverride, onBoundaryArrow, undoDebounceMs, pasteThreshold, formatPastePlaceholder, images, onImagesChange, onPasteError, enableImagePaste = false, maxImageSizeBytes, maxImageCount, acceptedMimeTypes, maxHeight, }) => {
|
|
67
|
+
export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCursor = true, width, isActive = true, onCursorChange, cursorOverride, onBoundaryArrow, undoDebounceMs, pasteThreshold, formatPastePlaceholder, images, onImagesChange, onPasteError, enableImagePaste = false, maxImageSizeBytes, maxImageCount, acceptedMimeTypes, maxHeight, ignoreInput, }) => {
|
|
68
68
|
const terminalWidth = useTerminalWidth(width);
|
|
69
69
|
const { stdin } = useStdin();
|
|
70
70
|
const lastRawInput = useRef('');
|
|
71
71
|
const pasteActive = useRef(false);
|
|
72
72
|
const pasteBuffer = useRef('');
|
|
73
73
|
const suppressNextInput = useRef(false);
|
|
74
|
-
const textInput = useTextInput({
|
|
74
|
+
const textInput = useTextInput({
|
|
75
|
+
initialValue: value ?? '',
|
|
76
|
+
width: terminalWidth,
|
|
77
|
+
undoDebounceMs,
|
|
78
|
+
pasteThreshold,
|
|
79
|
+
formatPastePlaceholder,
|
|
80
|
+
});
|
|
75
81
|
const textInputRef = useRef(textInput);
|
|
76
82
|
useEffect(() => {
|
|
77
83
|
textInputRef.current = textInput;
|
|
@@ -134,11 +140,6 @@ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCur
|
|
|
134
140
|
existingImages: textInput.images,
|
|
135
141
|
onPasteError,
|
|
136
142
|
});
|
|
137
|
-
useEffect(() => {
|
|
138
|
-
if (cursorOverride !== undefined) {
|
|
139
|
-
textInput.setCursorOffset(cursorOverride);
|
|
140
|
-
}
|
|
141
|
-
}, [cursorOverride]);
|
|
142
143
|
const onCursorChangeRef = useRef(onCursorChange);
|
|
143
144
|
useEffect(() => {
|
|
144
145
|
onCursorChangeRef.current = onCursorChange;
|
|
@@ -150,9 +151,9 @@ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCur
|
|
|
150
151
|
useEffect(() => {
|
|
151
152
|
if (value !== undefined && value !== textInput.value) {
|
|
152
153
|
isSyncingFromProps.current = true;
|
|
153
|
-
textInput.setText(value);
|
|
154
154
|
}
|
|
155
|
-
|
|
155
|
+
textInput.syncExternalState({ value, cursorOffset: cursorOverride });
|
|
156
|
+
}, [value, cursorOverride]);
|
|
156
157
|
const onChangeRef = useRef(onChange);
|
|
157
158
|
useEffect(() => {
|
|
158
159
|
onChangeRef.current = onChange;
|
|
@@ -209,22 +210,26 @@ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCur
|
|
|
209
210
|
paste: handlePaste,
|
|
210
211
|
};
|
|
211
212
|
useInput((input, key) => {
|
|
213
|
+
if (ignoreInput?.(input, key)) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
212
216
|
if (suppressNextInput.current) {
|
|
213
217
|
// This stdin chunk is part of a bracketed paste — already handled by the
|
|
214
218
|
// raw 'data' listener. Don't dispatch as keystrokes.
|
|
215
219
|
suppressNextInput.current = false;
|
|
216
220
|
return;
|
|
217
221
|
}
|
|
218
|
-
log(`[USEINPUT] input=
|
|
222
|
+
log(`[USEINPUT] input='${input.replace(/[\x00-\x1F\x7F-]/g, (c) => `\\x${c.charCodeAt(0).toString(16)}`)}' key=${JSON.stringify(key)} rawLen=${lastRawInput.current?.length || 0}`);
|
|
219
223
|
// Detect if this is an Alt keypress for symbol keys (like Alt+\ or Alt+/)
|
|
220
224
|
// Standard Alt keypresses send ESC (\x1b) followed by the character.
|
|
221
225
|
// We check if it is a 2-character sequence starting with ESC, excluding CSI/SS3 prefixes ('[' or 'O')
|
|
222
226
|
const raw = lastRawInput.current;
|
|
223
|
-
const isMeta = key.meta ||
|
|
224
|
-
raw
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
227
|
+
const isMeta = key.meta ||
|
|
228
|
+
(raw &&
|
|
229
|
+
raw.length === 2 &&
|
|
230
|
+
raw.startsWith('\x1b') &&
|
|
231
|
+
raw[1] !== '[' &&
|
|
232
|
+
raw[1] !== 'O');
|
|
228
233
|
const updatedKey = isMeta ? { ...key, meta: true } : key;
|
|
229
234
|
handleKey(updatedKey, input, textInput.buffer, actions, textInput.cursor, lastRawInput.current, terminalWidth);
|
|
230
235
|
}, { isActive });
|
|
@@ -22,6 +22,10 @@ export interface UseTextInputResult {
|
|
|
22
22
|
undo: () => void;
|
|
23
23
|
redo: () => void;
|
|
24
24
|
setText: (text: string) => void;
|
|
25
|
+
syncExternalState: (state: {
|
|
26
|
+
value?: string;
|
|
27
|
+
cursorOffset?: number;
|
|
28
|
+
}) => void;
|
|
25
29
|
cursorOffset: number;
|
|
26
30
|
setCursorOffset: (offset: number) => void;
|
|
27
31
|
blockState: BlockState;
|
|
@@ -4,6 +4,13 @@ import { createBlockState, createPasteBlockEntry, createImageBlockEntry, removeB
|
|
|
4
4
|
import { findAtomicBlockBefore, findAtomicBlockAfter } from './AtomicBlocks.js';
|
|
5
5
|
import { log } from '../../utils/logger.js';
|
|
6
6
|
const defaultFormatPlaceholder = (displayNumber) => `[Paste text #${displayNumber}]`;
|
|
7
|
+
function getEndCursor(text) {
|
|
8
|
+
const lines = text.split('\n');
|
|
9
|
+
return {
|
|
10
|
+
line: lines.length - 1,
|
|
11
|
+
column: lines[lines.length - 1].length,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
7
14
|
export function useTextInput({ initialValue = '', width, historyLimit = 100, undoDebounceMs = 200, pasteThreshold, formatPastePlaceholder = defaultFormatPlaceholder, } = {}) {
|
|
8
15
|
const [buffer, setBuffer] = useState(() => createBuffer(initialValue));
|
|
9
16
|
const [cursor, setCursor] = useState(() => {
|
|
@@ -188,8 +195,7 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
|
|
|
188
195
|
applyEdit(() => {
|
|
189
196
|
setBlockState(createBlockState());
|
|
190
197
|
const newBuffer = createBuffer(text);
|
|
191
|
-
const
|
|
192
|
-
const newCursor = { line: lines.length - 1, column: lines[lines.length - 1].length };
|
|
198
|
+
const newCursor = getEndCursor(text);
|
|
193
199
|
return { buffer: newBuffer, cursor: newCursor };
|
|
194
200
|
});
|
|
195
201
|
}, [applyEdit]);
|
|
@@ -202,6 +208,25 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
|
|
|
202
208
|
}, [applyEdit, buffer, cursor, blockState]);
|
|
203
209
|
const value = useMemo(() => getValue(buffer.lines, blockState.entries), [buffer.lines, blockState.entries]);
|
|
204
210
|
const cursorOffset = useMemo(() => getValueCursorOffset(buffer.lines, cursor, blockState.entries), [buffer.lines, cursor, blockState.entries]);
|
|
211
|
+
const syncExternalState = useCallback(({ value: externalValue, cursorOffset: externalCursorOffset }) => {
|
|
212
|
+
const shouldSyncValue = externalValue !== undefined && externalValue !== value;
|
|
213
|
+
const shouldSyncCursor = externalCursorOffset !== undefined && externalCursorOffset !== cursorOffset;
|
|
214
|
+
if (!shouldSyncValue && !shouldSyncCursor)
|
|
215
|
+
return;
|
|
216
|
+
flushPendingInsertBatch();
|
|
217
|
+
const nextBlockState = shouldSyncValue ? createBlockState() : blockState;
|
|
218
|
+
const nextBuffer = shouldSyncValue ? createBuffer(externalValue) : buffer;
|
|
219
|
+
const nextCursor = externalCursorOffset !== undefined
|
|
220
|
+
? getCursorFromValueOffset(nextBuffer.lines, externalCursorOffset, nextBlockState.entries)
|
|
221
|
+
: shouldSyncValue
|
|
222
|
+
? getEndCursor(externalValue)
|
|
223
|
+
: cursor;
|
|
224
|
+
if (shouldSyncValue) {
|
|
225
|
+
setBlockState(nextBlockState);
|
|
226
|
+
setBuffer(nextBuffer);
|
|
227
|
+
}
|
|
228
|
+
setCursor(nextCursor);
|
|
229
|
+
}, [blockState, buffer, cursor, cursorOffset, flushPendingInsertBatch, value]);
|
|
205
230
|
const imagesList = useMemo(() => {
|
|
206
231
|
const result = [];
|
|
207
232
|
for (const entry of blockState.entries.values()) {
|
|
@@ -256,6 +281,7 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
|
|
|
256
281
|
undo,
|
|
257
282
|
redo,
|
|
258
283
|
setText,
|
|
284
|
+
syncExternalState,
|
|
259
285
|
cursorOffset,
|
|
260
286
|
setCursorOffset: useCallback((offset) => {
|
|
261
287
|
flushPendingInsertBatch();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ink-prompt",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "A React Ink component for prompts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"dev": "tsc --watch",
|
|
22
22
|
"type-check": "tsc --noEmit",
|
|
23
23
|
"clean": "rm -rf dist",
|
|
24
|
-
"test": "vitest run",
|
|
24
|
+
"test": "vitest run --reporter=minimal",
|
|
25
25
|
"test:ui": "vitest --ui",
|
|
26
26
|
"test:watch": "vitest",
|
|
27
27
|
"release": "bash scripts/release.sh"
|
|
@@ -44,14 +44,14 @@
|
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@testing-library/react": "^16.3.0",
|
|
47
|
-
"@types/node": "^20.
|
|
47
|
+
"@types/node": "^20.19.41",
|
|
48
48
|
"@types/react": "^19.0.0",
|
|
49
|
-
"@vitest/ui": "^4.
|
|
49
|
+
"@vitest/ui": "^4.1.9",
|
|
50
50
|
"happy-dom": "^20.0.11",
|
|
51
51
|
"ink": "^7.0.0",
|
|
52
52
|
"react": "^19.2.1",
|
|
53
53
|
"react-dom": "^19.2.1",
|
|
54
54
|
"typescript": "^5.0.0",
|
|
55
|
-
"vitest": "^4.
|
|
55
|
+
"vitest": "^4.1.9"
|
|
56
56
|
}
|
|
57
57
|
}
|