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
- if (key.backspace) {
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 after submit', () => {
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
- expect(onChange).toHaveBeenCalledWith('');
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
- it('calls onChange on initial render with value', () => {
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
- render(_jsx(MultilineInputCore, { value: "test", onChange: onChange }));
67
- // onChange is called with the initial value
68
- expect(onChange).toHaveBeenCalledWith('test');
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?.(textInput.value);
41
- }, [textInput.value, onChange]);
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?.(textInput.value);
99
- }, [textInput.value, onChange]);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ink-prompt",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A React Ink component for prompts",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",