ink-prompt 0.1.3 → 0.1.4
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.
|
@@ -17,6 +17,13 @@ const END_SEQUENCES = [
|
|
|
17
17
|
'\x1bOF', // SS3 F (xterm application mode)
|
|
18
18
|
'\x1b[8~', // CSI 8~ (rxvt)
|
|
19
19
|
];
|
|
20
|
+
/**
|
|
21
|
+
* Raw sequences that represent backspace. Some terminals send DEL (0x7f) while others send BS (0x08).
|
|
22
|
+
*/
|
|
23
|
+
const BACKSPACE_SEQUENCES = ['\u0008', '\u007f'];
|
|
24
|
+
function isBackspaceSequence(seq) {
|
|
25
|
+
return !!seq && BACKSPACE_SEQUENCES.includes(seq);
|
|
26
|
+
}
|
|
20
27
|
/**
|
|
21
28
|
* Handles keyboard input and maps it to text input actions.
|
|
22
29
|
*
|
|
@@ -83,7 +90,9 @@ export function handleKey(key, input, buffer, actions, cursor, rawInput) {
|
|
|
83
90
|
}
|
|
84
91
|
}
|
|
85
92
|
// Editing
|
|
86
|
-
|
|
93
|
+
const rawBackspace = isBackspaceSequence(rawInput);
|
|
94
|
+
const inputBackspace = isBackspaceSequence(input);
|
|
95
|
+
if (key.backspace || rawBackspace || inputBackspace || (key.delete && rawBackspace)) {
|
|
87
96
|
actions.delete();
|
|
88
97
|
return;
|
|
89
98
|
}
|
|
@@ -71,6 +71,11 @@ describe('KeyHandler', () => {
|
|
|
71
71
|
handleKey({ delete: true }, '', buffer, actions);
|
|
72
72
|
expect(actions.deleteForward).toHaveBeenCalled();
|
|
73
73
|
});
|
|
74
|
+
it('treats DEL (0x7f) raw input as backspace even if reported as delete', () => {
|
|
75
|
+
handleKey({ delete: true }, '', buffer, actions, undefined, '');
|
|
76
|
+
expect(actions.delete).toHaveBeenCalled();
|
|
77
|
+
expect(actions.deleteForward).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
74
79
|
it('handles Ctrl+J (NewLine)', () => {
|
|
75
80
|
handleKey({ ctrl: true }, 'j', buffer, actions);
|
|
76
81
|
expect(actions.newLine).toHaveBeenCalled();
|
|
@@ -14,7 +14,7 @@ import { MultilineInputCore } from '../index.js';
|
|
|
14
14
|
*/
|
|
15
15
|
describe('MultilineInputCore', () => {
|
|
16
16
|
describe('Submission', () => {
|
|
17
|
-
it('clears input
|
|
17
|
+
it('clears input when parent updates value prop to empty', () => {
|
|
18
18
|
const onSubmit = vi.fn();
|
|
19
19
|
const onChange = vi.fn();
|
|
20
20
|
// Simulate controlled usage: value is managed by parent
|
|
@@ -22,11 +22,14 @@ describe('MultilineInputCore', () => {
|
|
|
22
22
|
const { rerender, container } = render(_jsx(MultilineInputCore, { value: value, onSubmit: onSubmit, onChange: onChange }));
|
|
23
23
|
// Simulate submit: parent receives value, then clears
|
|
24
24
|
onSubmit(value);
|
|
25
|
+
onChange.mockClear(); // Clear previous calls
|
|
25
26
|
value = '';
|
|
26
27
|
rerender(_jsx(MultilineInputCore, { value: value, onSubmit: onSubmit, onChange: onChange }));
|
|
27
28
|
// After rerender, input should be empty
|
|
28
29
|
expect(container.textContent).toContain(' '); // Cursor in empty buffer
|
|
29
|
-
|
|
30
|
+
// onChange should NOT be called when parent updates value prop
|
|
31
|
+
// (this is a prop sync, not user input - controlled component pattern)
|
|
32
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
30
33
|
});
|
|
31
34
|
});
|
|
32
35
|
describe('Rendering', () => {
|
|
@@ -61,11 +64,44 @@ describe('MultilineInputCore', () => {
|
|
|
61
64
|
expect(container.textContent).toContain('abcde');
|
|
62
65
|
expect(container.textContent).toContain('fghij');
|
|
63
66
|
});
|
|
64
|
-
|
|
67
|
+
});
|
|
68
|
+
describe('Controlled component behavior', () => {
|
|
69
|
+
it('does NOT call onChange when value prop is updated by parent', () => {
|
|
70
|
+
// This tests the controlled component pattern:
|
|
71
|
+
// onChange should only fire for user-initiated changes, not prop updates
|
|
72
|
+
const onChange = vi.fn();
|
|
73
|
+
const { rerender, container } = render(_jsx(MultilineInputCore, { value: "initial", onChange: onChange }));
|
|
74
|
+
expect(container.textContent).toContain('initial');
|
|
75
|
+
// Clear any calls from initial render
|
|
76
|
+
onChange.mockClear();
|
|
77
|
+
// Parent updates the value prop (simulating parent state change)
|
|
78
|
+
rerender(_jsx(MultilineInputCore, { value: "updated by parent", onChange: onChange }));
|
|
79
|
+
// The component should sync the new value...
|
|
80
|
+
expect(container.textContent).toContain('updated by parent');
|
|
81
|
+
// ...but should NOT call onChange (this is a prop sync, not user input)
|
|
82
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
it('does NOT create feedback loop when parent updates value', () => {
|
|
85
|
+
// Regression test for feedback loop bug:
|
|
86
|
+
// 1. Parent calls onChange(newValue)
|
|
87
|
+
// 2. Parent updates value prop
|
|
88
|
+
// 3. Component syncs internal state to match prop
|
|
89
|
+
// 4. BUG: Component calls onChange again with the same value!
|
|
90
|
+
// 5. This creates an infinite loop
|
|
65
91
|
const onChange = vi.fn();
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
92
|
+
let externalValue = 'start';
|
|
93
|
+
const { rerender } = render(_jsx(MultilineInputCore, { value: externalValue, onChange: onChange }));
|
|
94
|
+
onChange.mockClear();
|
|
95
|
+
// Simulate what happens after user types and parent updates value prop
|
|
96
|
+
externalValue = 'user typed this';
|
|
97
|
+
rerender(_jsx(MultilineInputCore, { value: externalValue, onChange: onChange }));
|
|
98
|
+
// Should NOT call onChange - this would cause infinite loop
|
|
99
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
100
|
+
// Update again
|
|
101
|
+
externalValue = 'another update';
|
|
102
|
+
rerender(_jsx(MultilineInputCore, { value: externalValue, onChange: onChange }));
|
|
103
|
+
// Still should not call onChange
|
|
104
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
69
105
|
});
|
|
70
106
|
});
|
|
71
107
|
describe('Placeholder', () => {
|
|
@@ -12,6 +12,8 @@ import { log } from '../../utils/logger.js';
|
|
|
12
12
|
*/
|
|
13
13
|
export const MultilineInputCore = ({ value, onChange, placeholder, showCursor = true, width = 80, onCursorChange, cursorOverride, }) => {
|
|
14
14
|
const textInput = useTextInput({ initialValue: value ?? '' });
|
|
15
|
+
// Track whether a value change is from syncing props (not user input)
|
|
16
|
+
const isSyncingFromProps = useRef(false);
|
|
15
17
|
// Handle cursor override
|
|
16
18
|
useEffect(() => {
|
|
17
19
|
if (cursorOverride !== undefined) {
|
|
@@ -32,13 +34,23 @@ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor =
|
|
|
32
34
|
// Sync external value changes
|
|
33
35
|
useEffect(() => {
|
|
34
36
|
if (value !== undefined && value !== textInput.value) {
|
|
37
|
+
isSyncingFromProps.current = true;
|
|
35
38
|
textInput.setText(value);
|
|
36
39
|
}
|
|
37
40
|
}, [value]);
|
|
38
|
-
// Notify parent of changes
|
|
41
|
+
// Notify parent of changes - but only for user-initiated changes
|
|
42
|
+
const onChangeRef = useRef(onChange);
|
|
39
43
|
useEffect(() => {
|
|
40
|
-
onChange
|
|
41
|
-
}, [
|
|
44
|
+
onChangeRef.current = onChange;
|
|
45
|
+
}, [onChange]);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (isSyncingFromProps.current) {
|
|
48
|
+
// This change was from syncing props, not user input - don't call onChange
|
|
49
|
+
isSyncingFromProps.current = false;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
onChangeRef.current?.(textInput.value);
|
|
53
|
+
}, [textInput.value]);
|
|
42
54
|
// Create buffer for TextRenderer
|
|
43
55
|
const buffer = createBuffer(textInput.value);
|
|
44
56
|
// Show placeholder if empty and no cursor shown
|
|
@@ -87,16 +99,28 @@ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCur
|
|
|
87
99
|
useEffect(() => {
|
|
88
100
|
onCursorChangeRef.current?.(textInput.cursorOffset);
|
|
89
101
|
}, [textInput.cursorOffset]);
|
|
102
|
+
// Track whether a value change is from syncing props (not user input)
|
|
103
|
+
const isSyncingFromProps = useRef(false);
|
|
90
104
|
// Sync external value changes
|
|
91
105
|
useEffect(() => {
|
|
92
106
|
if (value !== undefined && value !== textInput.value) {
|
|
107
|
+
isSyncingFromProps.current = true;
|
|
93
108
|
textInput.setText(value);
|
|
94
109
|
}
|
|
95
110
|
}, [value]);
|
|
96
|
-
// Notify parent of changes
|
|
111
|
+
// Notify parent of changes - but only for user-initiated changes
|
|
112
|
+
const onChangeRef = useRef(onChange);
|
|
97
113
|
useEffect(() => {
|
|
98
|
-
onChange
|
|
99
|
-
}, [
|
|
114
|
+
onChangeRef.current = onChange;
|
|
115
|
+
}, [onChange]);
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (isSyncingFromProps.current) {
|
|
118
|
+
// This change was from syncing props, not user input - don't call onChange
|
|
119
|
+
isSyncingFromProps.current = false;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
onChangeRef.current?.(textInput.value);
|
|
123
|
+
}, [textInput.value]);
|
|
100
124
|
// Create buffer for TextRenderer and KeyHandler
|
|
101
125
|
const buffer = createBuffer(textInput.value);
|
|
102
126
|
// Create submit handler
|