ink-prompt 0.3.1 → 0.4.0

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
@@ -16,11 +16,6 @@ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor =
16
16
  formatPastePlaceholder,
17
17
  });
18
18
  const isSyncingFromProps = useRef(false);
19
- useEffect(() => {
20
- if (cursorOverride !== undefined) {
21
- textInput.setCursorOffset(cursorOverride);
22
- }
23
- }, [cursorOverride]);
24
19
  const onCursorChangeRef = useRef(onCursorChange);
25
20
  useEffect(() => {
26
21
  onCursorChangeRef.current = onCursorChange;
@@ -33,9 +28,9 @@ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor =
33
28
  useEffect(() => {
34
29
  if (value !== undefined && value !== textInput.value) {
35
30
  isSyncingFromProps.current = true;
36
- textInput.setText(value);
37
31
  }
38
- }, [value]);
32
+ textInput.syncExternalState({ value, cursorOffset: cursorOverride });
33
+ }, [value, cursorOverride]);
39
34
  const onChangeRef = useRef(onChange);
40
35
  useEffect(() => {
41
36
  onChangeRef.current = onChange;
@@ -145,11 +140,6 @@ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCur
145
140
  existingImages: textInput.images,
146
141
  onPasteError,
147
142
  });
148
- useEffect(() => {
149
- if (cursorOverride !== undefined) {
150
- textInput.setCursorOffset(cursorOverride);
151
- }
152
- }, [cursorOverride]);
153
143
  const onCursorChangeRef = useRef(onCursorChange);
154
144
  useEffect(() => {
155
145
  onCursorChangeRef.current = onCursorChange;
@@ -161,9 +151,9 @@ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCur
161
151
  useEffect(() => {
162
152
  if (value !== undefined && value !== textInput.value) {
163
153
  isSyncingFromProps.current = true;
164
- textInput.setText(value);
165
154
  }
166
- }, [value]);
155
+ textInput.syncExternalState({ value, cursorOffset: cursorOverride });
156
+ }, [value, cursorOverride]);
167
157
  const onChangeRef = useRef(onChange);
168
158
  useEffect(() => {
169
159
  onChangeRef.current = onChange;
@@ -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.1",
3
+ "version": "0.4.0",
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"
@@ -34,6 +34,9 @@
34
34
  ],
35
35
  "author": "Duc Nguyen",
36
36
  "license": "MIT",
37
+ "engines": {
38
+ "node": ">=20 <=24"
39
+ },
37
40
  "repository": "github:qduc/ink-prompt",
38
41
  "publishConfig": {
39
42
  "access": "public"
@@ -46,12 +49,12 @@
46
49
  "@testing-library/react": "^16.3.0",
47
50
  "@types/node": "^20.19.41",
48
51
  "@types/react": "^19.0.0",
49
- "@vitest/ui": "^4.0.15",
52
+ "@vitest/ui": "^4.1.9",
50
53
  "happy-dom": "^20.0.11",
51
54
  "ink": "^7.0.0",
52
55
  "react": "^19.2.1",
53
56
  "react-dom": "^19.2.1",
54
57
  "typescript": "^5.0.0",
55
- "vitest": "^4.0.15"
58
+ "vitest": "^4.1.9"
56
59
  }
57
60
  }