ink-prompt 0.1.6 → 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.
- package/dist/components/MultilineInput/__tests__/useTextInput.test.js +137 -0
- package/dist/components/MultilineInput/index.d.ts +82 -9
- package/dist/components/MultilineInput/index.js +3 -2
- package/dist/components/MultilineInput/useTextInput.d.ts +3 -1
- package/dist/components/MultilineInput/useTextInput.js +10 -3
- package/dist/hooks/__tests__/useTerminalWidth.test.js +69 -1
- package/dist/hooks/useTerminalWidth.d.ts +2 -1
- package/dist/hooks/useTerminalWidth.js +19 -4
- package/package.json +1 -1
|
@@ -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
|
});
|
|
@@ -1,37 +1,110 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
export interface MultilineInputProps {
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Controlled text value. When provided, the component becomes controlled
|
|
5
|
+
* and the value is managed externally.
|
|
6
|
+
*/
|
|
4
7
|
value?: string;
|
|
5
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Called when the text content changes due to user input. Receives the
|
|
10
|
+
* new text value as a parameter.
|
|
11
|
+
*/
|
|
6
12
|
onChange?: (value: string) => void;
|
|
7
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Called when the user submits the input (typically by pressing Enter
|
|
15
|
+
* without a backslash at the end). Receives the final text value.
|
|
16
|
+
*/
|
|
8
17
|
onSubmit?: (value: string) => void;
|
|
9
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Placeholder text displayed when the input is empty and the cursor
|
|
20
|
+
* is not shown.
|
|
21
|
+
*/
|
|
10
22
|
placeholder?: string;
|
|
11
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Whether to display the cursor. Defaults to true.
|
|
25
|
+
*/
|
|
12
26
|
showCursor?: boolean;
|
|
13
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Terminal width for word wrapping. If not provided, uses the terminal's
|
|
29
|
+
* current width with resize support.
|
|
30
|
+
*/
|
|
14
31
|
width?: number;
|
|
15
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Whether the input is active and focused, allowing keyboard input.
|
|
34
|
+
* Defaults to true.
|
|
35
|
+
*/
|
|
16
36
|
isActive?: boolean;
|
|
17
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Called whenever the cursor position changes. Receives the flat
|
|
39
|
+
* character offset as a parameter.
|
|
40
|
+
*/
|
|
18
41
|
onCursorChange?: (offset: number) => void;
|
|
19
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Optional external cursor position override. When set, forces the
|
|
44
|
+
* cursor to the specified flat character offset.
|
|
45
|
+
*/
|
|
20
46
|
cursorOverride?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Called when an arrow key is pressed but cursor is at a boundary.
|
|
49
|
+
* - 'up': cursor is on the first/topmost line
|
|
50
|
+
* - 'down': cursor is on the last/bottommost line
|
|
51
|
+
* - 'left': cursor is at position 0 (start of text)
|
|
52
|
+
* - 'right': cursor is at end of text (after last character)
|
|
53
|
+
*/
|
|
54
|
+
onBoundaryArrow?: (direction: 'up' | 'down' | 'left' | 'right') => void;
|
|
21
55
|
}
|
|
22
56
|
/**
|
|
23
57
|
* Props for the core component (without Ink-specific hooks)
|
|
24
58
|
* This allows testing the rendering logic separately.
|
|
25
59
|
*/
|
|
26
60
|
export interface MultilineInputCoreProps {
|
|
61
|
+
/**
|
|
62
|
+
* Controlled text value. When provided, the component becomes controlled
|
|
63
|
+
* and the text is managed externally.
|
|
64
|
+
*/
|
|
27
65
|
value?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Called when the text content changes. Receives the new text value
|
|
68
|
+
* as a parameter.
|
|
69
|
+
*/
|
|
28
70
|
onChange?: (value: string) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Called when the user submits the input (typically by pressing Enter
|
|
73
|
+
* without a backslash at the end).
|
|
74
|
+
*/
|
|
29
75
|
onSubmit?: (value: string) => void;
|
|
76
|
+
/**
|
|
77
|
+
* Placeholder text displayed when the input is empty and the cursor
|
|
78
|
+
* is not shown.
|
|
79
|
+
*/
|
|
30
80
|
placeholder?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Whether to display the cursor. Defaults to true.
|
|
83
|
+
*/
|
|
31
84
|
showCursor?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Terminal width for word wrapping. If not provided, uses the terminal's
|
|
87
|
+
* current width.
|
|
88
|
+
*/
|
|
32
89
|
width?: number;
|
|
90
|
+
/**
|
|
91
|
+
* Called whenever the cursor position changes. Receives the flat
|
|
92
|
+
* character offset as a parameter.
|
|
93
|
+
*/
|
|
33
94
|
onCursorChange?: (offset: number) => void;
|
|
95
|
+
/**
|
|
96
|
+
* Optional external cursor position override. When set, forces the
|
|
97
|
+
* cursor to the specified flat character offset.
|
|
98
|
+
*/
|
|
34
99
|
cursorOverride?: number;
|
|
100
|
+
/**
|
|
101
|
+
* Called when an arrow key is pressed but cursor is at a boundary.
|
|
102
|
+
* - 'up': cursor is on the first/topmost line
|
|
103
|
+
* - 'down': cursor is on the last/bottommost line
|
|
104
|
+
* - 'left': cursor is at position 0 (start of text)
|
|
105
|
+
* - 'right': cursor is at end of text (after last character)
|
|
106
|
+
*/
|
|
107
|
+
onBoundaryArrow?: (direction: 'up' | 'down' | 'left' | 'right') => void;
|
|
35
108
|
}
|
|
36
109
|
/**
|
|
37
110
|
* Core rendering component that can be tested without Ink runtime.
|
|
@@ -66,7 +66,7 @@ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor =
|
|
|
66
66
|
* Full MultilineInput with Ink keyboard handling.
|
|
67
67
|
* This component uses Ink-specific hooks and must be rendered in an Ink context.
|
|
68
68
|
*/
|
|
69
|
-
export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCursor = true, width, isActive = true, onCursorChange, cursorOverride, }) => {
|
|
69
|
+
export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCursor = true, width, isActive = true, onCursorChange, cursorOverride, onBoundaryArrow, }) => {
|
|
70
70
|
// Get terminal width from Ink (with resize support) if not provided
|
|
71
71
|
const terminalWidth = useTerminalWidth(width);
|
|
72
72
|
// Track raw input for detecting Home/End keys
|
|
@@ -140,11 +140,12 @@ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCur
|
|
|
140
140
|
redo: textInput.redo,
|
|
141
141
|
setText: textInput.setText,
|
|
142
142
|
submit: handleSubmit,
|
|
143
|
+
onBoundaryArrow,
|
|
143
144
|
};
|
|
144
145
|
// Handle keyboard input
|
|
145
146
|
useInput((input, key) => {
|
|
146
147
|
log(`[USEINPUT] input="${input.replace(/[\x00-\x1F\x7F-\uFFFF]/g, c => `\\x${c.charCodeAt(0).toString(16)}`)}" key=${JSON.stringify(key)} rawLen=${lastRawInput.current?.length || 0}`);
|
|
147
|
-
handleKey(key, input, buffer, actions, textInput.cursor, lastRawInput.current);
|
|
148
|
+
handleKey(key, input, buffer, actions, textInput.cursor, lastRawInput.current, terminalWidth);
|
|
148
149
|
}, { isActive });
|
|
149
150
|
// Show placeholder if empty and no cursor shown
|
|
150
151
|
const isEmpty = textInput.value === '';
|
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
}
|