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.
@@ -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
@@ -32,6 +32,7 @@ export interface MultilineInputProps {
32
32
  maxImageCount?: number;
33
33
  acceptedMimeTypes?: string[];
34
34
  maxHeight?: number;
35
+ ignoreInput?: (input: string, key: any) => boolean;
35
36
  }
36
37
  export interface MultilineInputCoreProps {
37
38
  value?: string;
@@ -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({ initialValue: value ?? '', undoDebounceMs, pasteThreshold, formatPastePlaceholder });
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
- }, [value]);
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({ initialValue: value ?? '', width: terminalWidth, undoDebounceMs, pasteThreshold, formatPastePlaceholder });
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
- }, [value]);
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="${input.replace(/[\x00-\x1F\x7F-]/g, c => `\\x${c.charCodeAt(0).toString(16)}`)}" key=${JSON.stringify(key)} rawLen=${lastRawInput.current?.length || 0}`);
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 || (raw &&
224
- raw.length === 2 &&
225
- raw.startsWith('\x1b') &&
226
- raw[1] !== '[' &&
227
- raw[1] !== 'O');
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 lines = text.split('\n');
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.0",
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.0.0",
47
+ "@types/node": "^20.19.41",
48
48
  "@types/react": "^19.0.0",
49
- "@vitest/ui": "^4.0.15",
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.0.15"
55
+ "vitest": "^4.1.9"
56
56
  }
57
57
  }