ink-prompt 0.1.7 → 0.1.8

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.
@@ -169,4 +169,141 @@ describe('useTextInput', () => {
169
169
  expect(result.current.cursor).toEqual({ line: 0, column: 5 });
170
170
  });
171
171
  });
172
+ describe('history limit', () => {
173
+ it('should use default history limit of 100', () => {
174
+ const { result } = renderHook(() => useTextInput());
175
+ // Insert 5 characters separately
176
+ for (let i = 0; i < 5; i++) {
177
+ act(() => {
178
+ result.current.insert('a');
179
+ });
180
+ }
181
+ expect(result.current.value).toBe('aaaaa');
182
+ // Undo 5 times
183
+ for (let i = 0; i < 5; i++) {
184
+ act(() => {
185
+ result.current.undo();
186
+ });
187
+ }
188
+ // Should be back to empty
189
+ expect(result.current.value).toBe('');
190
+ // Try to undo again - should have no effect
191
+ const valueBefore = result.current.value;
192
+ act(() => {
193
+ result.current.undo();
194
+ });
195
+ expect(result.current.value).toBe(valueBefore);
196
+ });
197
+ it('should respect custom history limit', () => {
198
+ const { result } = renderHook(() => useTextInput({ historyLimit: 3 }));
199
+ // Insert 5 characters
200
+ for (let i = 0; i < 5; i++) {
201
+ act(() => {
202
+ result.current.insert('a');
203
+ });
204
+ }
205
+ expect(result.current.value).toBe('aaaaa');
206
+ // Undo 4 times - only 3 should work due to limit
207
+ for (let i = 0; i < 4; i++) {
208
+ act(() => {
209
+ result.current.undo();
210
+ });
211
+ }
212
+ // Should be back to 2 characters (5 - 3 undos, since only 3 history entries)
213
+ expect(result.current.value).toBe('aa');
214
+ // Try to undo again - should have no effect
215
+ const valueBefore = result.current.value;
216
+ act(() => {
217
+ result.current.undo();
218
+ });
219
+ expect(result.current.value).toBe(valueBefore);
220
+ });
221
+ it('should trim oldest history when limit exceeded', () => {
222
+ const { result } = renderHook(() => useTextInput({ historyLimit: 3 }));
223
+ // Insert 5 characters
224
+ for (let i = 0; i < 5; i++) {
225
+ act(() => {
226
+ result.current.insert('a');
227
+ });
228
+ }
229
+ expect(result.current.value).toBe('aaaaa');
230
+ // Can undo 3 times (limited by history)
231
+ for (let i = 0; i < 3; i++) {
232
+ act(() => {
233
+ result.current.undo();
234
+ });
235
+ }
236
+ // Should be at 2 characters
237
+ expect(result.current.value).toBe('aa');
238
+ // Try to undo one more time - should have no effect (no more history)
239
+ const valueBefore = result.current.value;
240
+ act(() => {
241
+ result.current.undo();
242
+ });
243
+ expect(result.current.value).toBe(valueBefore);
244
+ });
245
+ it('should clear redo stack when new edit happens', () => {
246
+ const { result } = renderHook(() => useTextInput({ historyLimit: 5 }));
247
+ act(() => {
248
+ result.current.insert('a');
249
+ });
250
+ act(() => {
251
+ result.current.insert('b');
252
+ });
253
+ act(() => {
254
+ result.current.insert('c');
255
+ });
256
+ expect(result.current.value).toBe('abc');
257
+ // Undo once
258
+ act(() => {
259
+ result.current.undo();
260
+ });
261
+ expect(result.current.value).toBe('ab');
262
+ // Redo should work
263
+ act(() => {
264
+ result.current.redo();
265
+ });
266
+ expect(result.current.value).toBe('abc');
267
+ // Undo again
268
+ act(() => {
269
+ result.current.undo();
270
+ });
271
+ expect(result.current.value).toBe('ab');
272
+ // Insert new character - redo stack should be cleared
273
+ act(() => {
274
+ result.current.insert('d');
275
+ });
276
+ expect(result.current.value).toBe('abd');
277
+ // Redo should not work now (redo stack was cleared on insert)
278
+ const valueBefore = result.current.value;
279
+ act(() => {
280
+ result.current.redo();
281
+ });
282
+ expect(result.current.value).toBe(valueBefore);
283
+ });
284
+ it('should prevent excessive memory use with bounded history', () => {
285
+ const { result } = renderHook(() => useTextInput({ historyLimit: 10 }));
286
+ // Insert 30 characters
287
+ for (let i = 0; i < 30; i++) {
288
+ act(() => {
289
+ result.current.insert('x');
290
+ });
291
+ }
292
+ expect(result.current.value).toBe('x'.repeat(30));
293
+ // We should only be able to undo 10 times due to history limit
294
+ for (let i = 0; i < 10; i++) {
295
+ act(() => {
296
+ result.current.undo();
297
+ });
298
+ }
299
+ // Should be back to 20 characters (30 - 10 undos)
300
+ expect(result.current.value).toBe('x'.repeat(20));
301
+ // No more undos available
302
+ const valueBefore = result.current.value;
303
+ act(() => {
304
+ result.current.undo();
305
+ });
306
+ expect(result.current.value).toBe(valueBefore);
307
+ });
308
+ });
172
309
  });
@@ -3,6 +3,8 @@ export interface UseTextInputProps {
3
3
  initialValue?: string;
4
4
  /** Terminal width for visual-aware cursor navigation (up/down arrows respect line wrapping) */
5
5
  width?: number;
6
+ /** Maximum number of history entries to keep (default: 100) */
7
+ historyLimit?: number;
6
8
  }
7
9
  export interface UseTextInputResult {
8
10
  value: string;
@@ -19,4 +21,4 @@ export interface UseTextInputResult {
19
21
  cursorOffset: number;
20
22
  setCursorOffset: (offset: number) => void;
21
23
  }
22
- export declare function useTextInput({ initialValue, width }?: UseTextInputProps): UseTextInputResult;
24
+ export declare function useTextInput({ initialValue, width, historyLimit }?: UseTextInputProps): UseTextInputResult;
@@ -1,7 +1,7 @@
1
1
  import { useState, useCallback } from 'react';
2
2
  import { createBuffer, insertText as bufferInsertText, deleteChar as bufferDeleteChar, deleteCharForward as bufferDeleteCharForward, insertNewLine as bufferInsertNewLine, moveCursor as bufferMoveCursor, getTextContent, getOffset, getCursor, } from './TextBuffer.js';
3
3
  import { log } from '../../utils/logger.js';
4
- export function useTextInput({ initialValue = '', width } = {}) {
4
+ export function useTextInput({ initialValue = '', width, historyLimit = 100 } = {}) {
5
5
  const [buffer, setBuffer] = useState(() => createBuffer(initialValue));
6
6
  const [cursor, setCursor] = useState(() => {
7
7
  const lines = initialValue.split('\n');
@@ -13,9 +13,16 @@ export function useTextInput({ initialValue = '', width } = {}) {
13
13
  const [undoStack, setUndoStack] = useState([]);
14
14
  const [redoStack, setRedoStack] = useState([]);
15
15
  const pushToHistory = useCallback((currentBuffer, currentCursor) => {
16
- setUndoStack((prev) => [...prev, { buffer: currentBuffer, cursor: currentCursor }]);
16
+ setUndoStack((prev) => {
17
+ const newStack = [...prev, { buffer: currentBuffer, cursor: currentCursor }];
18
+ // Trim stack if it exceeds history limit
19
+ if (newStack.length > historyLimit) {
20
+ return newStack.slice(-historyLimit);
21
+ }
22
+ return newStack;
23
+ });
17
24
  setRedoStack([]);
18
- }, []);
25
+ }, [historyLimit]);
19
26
  const insert = useCallback((char) => {
20
27
  log(`[INSERT] char="${char.replace(/[\x00-\x1F\x7F-\uFFFF]/g, c => `\\x${c.charCodeAt(0).toString(16)}`)}" len=${char.length} cursor={line:${cursor.line},col:${cursor.column}} linesBefore=${buffer.lines.length}`);
21
28
  // Normalize line endings: \r\n → \n, \r → \n (handles Windows, Unix, and old Mac)
@@ -30,7 +30,8 @@ describe('useTerminalWidth', () => {
30
30
  const { result } = renderHook(() => useTerminalWidth(100));
31
31
  expect(result.current).toBe(100);
32
32
  });
33
- it('updates width on resize', () => {
33
+ it('updates width on resize', async () => {
34
+ vi.useFakeTimers();
34
35
  const { result } = renderHook(() => useTerminalWidth());
35
36
  expect(result.current).toBe(80);
36
37
  // Simulate resize
@@ -38,7 +39,12 @@ describe('useTerminalWidth', () => {
38
39
  act(() => {
39
40
  mockStdout.emit('resize');
40
41
  });
42
+ // Wait for debounce to complete
43
+ await act(async () => {
44
+ vi.advanceTimersByTime(100);
45
+ });
41
46
  expect(result.current).toBe(120);
47
+ vi.useRealTimers();
42
48
  });
43
49
  it('does not update when prop width is provided', () => {
44
50
  const { result } = renderHook(() => useTerminalWidth(50));
@@ -57,4 +63,66 @@ describe('useTerminalWidth', () => {
57
63
  unmount();
58
64
  expect(mockStdout.listenerCount('resize')).toBe(0);
59
65
  });
66
+ it('debounces multiple rapid resize events', async () => {
67
+ vi.useFakeTimers();
68
+ const { result } = renderHook(() => useTerminalWidth());
69
+ expect(result.current).toBe(80);
70
+ // Simulate rapid resize events
71
+ mockStdout.columns = 100;
72
+ act(() => {
73
+ mockStdout.emit('resize');
74
+ });
75
+ mockStdout.columns = 120;
76
+ act(() => {
77
+ mockStdout.emit('resize');
78
+ });
79
+ mockStdout.columns = 140;
80
+ act(() => {
81
+ mockStdout.emit('resize');
82
+ });
83
+ // Still at initial width because debounce hasn't fired yet
84
+ expect(result.current).toBe(80);
85
+ // Wait for debounce to complete
86
+ await act(async () => {
87
+ vi.advanceTimersByTime(100);
88
+ });
89
+ // Now updated to the last value
90
+ expect(result.current).toBe(140);
91
+ vi.useRealTimers();
92
+ });
93
+ it('accepts custom debounce delay', async () => {
94
+ vi.useFakeTimers();
95
+ const { result } = renderHook(() => useTerminalWidth(undefined, 50));
96
+ expect(result.current).toBe(80);
97
+ // Simulate resize
98
+ mockStdout.columns = 100;
99
+ act(() => {
100
+ mockStdout.emit('resize');
101
+ });
102
+ expect(result.current).toBe(80);
103
+ // Wait for custom debounce to complete
104
+ await act(async () => {
105
+ vi.advanceTimersByTime(50);
106
+ });
107
+ expect(result.current).toBe(100);
108
+ vi.useRealTimers();
109
+ });
110
+ it('cancels pending debounce on unmount', async () => {
111
+ vi.useFakeTimers();
112
+ const { unmount } = renderHook(() => useTerminalWidth());
113
+ // Simulate resize
114
+ mockStdout.columns = 100;
115
+ act(() => {
116
+ mockStdout.emit('resize');
117
+ });
118
+ // Unmount before debounce fires
119
+ unmount();
120
+ // Advance time past debounce delay
121
+ await act(async () => {
122
+ vi.advanceTimersByTime(200);
123
+ });
124
+ // No errors should occur
125
+ expect(true).toBe(true);
126
+ vi.useRealTimers();
127
+ });
60
128
  });
@@ -2,6 +2,7 @@
2
2
  * Hook to get the current terminal width and listen for resize events.
3
3
  *
4
4
  * @param propWidth - Optional explicit width to use instead of terminal width
5
+ * @param debounceMs - Optional debounce delay in milliseconds (default: 100)
5
6
  * @returns The effective width (propWidth if provided, otherwise terminal width)
6
7
  */
7
- export declare function useTerminalWidth(propWidth?: number): number;
8
+ export declare function useTerminalWidth(propWidth?: number, debounceMs?: number): number;
@@ -1,24 +1,39 @@
1
- import { useState, useEffect } from 'react';
1
+ import { useState, useEffect, useRef } from 'react';
2
2
  import { useStdout } from 'ink';
3
3
  /**
4
4
  * Hook to get the current terminal width and listen for resize events.
5
5
  *
6
6
  * @param propWidth - Optional explicit width to use instead of terminal width
7
+ * @param debounceMs - Optional debounce delay in milliseconds (default: 100)
7
8
  * @returns The effective width (propWidth if provided, otherwise terminal width)
8
9
  */
9
- export function useTerminalWidth(propWidth) {
10
+ export function useTerminalWidth(propWidth, debounceMs = 100) {
10
11
  const { stdout } = useStdout();
11
12
  const [terminalWidth, setTerminalWidth] = useState(stdout?.columns ?? 80);
13
+ const debounceTimer = useRef(null);
12
14
  useEffect(() => {
13
15
  if (!stdout)
14
16
  return;
15
17
  const onResize = () => {
16
- setTerminalWidth(stdout.columns);
18
+ // Cancel any pending debounce timer
19
+ if (debounceTimer.current) {
20
+ clearTimeout(debounceTimer.current);
21
+ }
22
+ // Set a new debounce timer
23
+ debounceTimer.current = setTimeout(() => {
24
+ setTerminalWidth(stdout.columns);
25
+ debounceTimer.current = null;
26
+ }, debounceMs);
17
27
  };
18
28
  stdout.on('resize', onResize);
19
29
  return () => {
20
30
  stdout.off('resize', onResize);
31
+ // Clean up any pending timer on unmount
32
+ if (debounceTimer.current) {
33
+ clearTimeout(debounceTimer.current);
34
+ debounceTimer.current = null;
35
+ }
21
36
  };
22
- }, [stdout]);
37
+ }, [stdout, debounceMs]);
23
38
  return propWidth ?? terminalWidth;
24
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ink-prompt",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "A React Ink component for prompts",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",