ink-prompt 0.2.5 → 0.3.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.
package/README.md CHANGED
@@ -54,6 +54,7 @@ render(<App />);
54
54
  | `maxImageSizeBytes` | `number` | `10485760` | Maximum image size in bytes (default 10 MiB) |
55
55
  | `maxImageCount` | `number` | `10` | Maximum number of pasted images |
56
56
  | `acceptedMimeTypes` | `string[]` | | Restricts accepted image MIME types |
57
+ | `maxHeight` | `number` | `Math.floor(terminalHeight * 0.8)` | Maximum visual rows to render before scrolling (defaults to terminal height minus a 20% buffer) |
57
58
 
58
59
  ### Keyboard Controls
59
60
 
@@ -201,6 +201,15 @@ export function handleKey(key, input, buffer, actions, cursor, rawInput, width)
201
201
  actions.moveCursor('lineEnd');
202
202
  return;
203
203
  }
204
+ // Alt+\ for buffer start, Alt+/ for buffer end (nano style)
205
+ if (key.meta && input === '\\') {
206
+ actions.moveCursor('bufferStart');
207
+ return;
208
+ }
209
+ if (key.meta && input === '/') {
210
+ actions.moveCursor('bufferEnd');
211
+ return;
212
+ }
204
213
  // Paste / History
205
214
  if (key.ctrl) {
206
215
  if (input === 'v' && actions.paste) {
@@ -354,6 +354,12 @@ export function moveCursor(buffer, cursor, direction, width, entries) {
354
354
  return { line, column: 0 };
355
355
  case 'lineEnd':
356
356
  return { line, column: currentLine.length };
357
+ case 'bufferStart':
358
+ return { line: 0, column: 0 };
359
+ case 'bufferEnd': {
360
+ const lastLineIdx = lineCount - 1;
361
+ return { line: lastLineIdx, column: buffer.lines[lastLineIdx].length };
362
+ }
357
363
  default:
358
364
  return cursor;
359
365
  }
@@ -8,6 +8,7 @@ export interface TextRendererProps {
8
8
  showCursor?: boolean;
9
9
  /** Block state for expanding markers into display text */
10
10
  blockState?: BlockState;
11
+ maxHeight?: number;
11
12
  }
12
13
  interface VisualSegment {
13
14
  text: string;
@@ -21,5 +22,5 @@ interface VisualRow {
21
22
  export declare function wrapLines(buffer: Buffer, cursor: Cursor, width: number, entries?: Map<string, import('./BlockTypes.js').BlockEntry>): WrapResult & {
22
23
  rows: VisualRow[];
23
24
  };
24
- export declare function TextRenderer({ buffer, cursor, width: propWidth, showCursor, blockState, }: TextRendererProps): React.ReactElement;
25
+ export declare function TextRenderer({ buffer, cursor, width: propWidth, showCursor, blockState, maxHeight, }: TextRendererProps): React.ReactElement;
25
26
  export {};
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
2
3
  import { Box, Text } from 'ink';
3
4
  import { useTerminalWidth } from '../../hooks/useTerminalWidth.js';
4
5
  import { getVisualRows } from './TextBuffer.js';
@@ -140,12 +141,44 @@ function renderVisualRow(row, isCursorRow, cursorCol, showCursor) {
140
141
  : [];
141
142
  return (_jsxs(_Fragment, { children: [renderSegments(before, 'b'), _jsx(Text, { inverse: true, dimColor: under.dim, children: under.ch }), renderSegments(after, 'a')] }));
142
143
  }
143
- export function TextRenderer({ buffer, cursor, width: propWidth, showCursor = true, blockState, }) {
144
+ export function TextRenderer({ buffer, cursor, width: propWidth, showCursor = true, blockState, maxHeight, }) {
144
145
  const width = useTerminalWidth(propWidth);
145
146
  const entries = blockState?.entries;
146
147
  const { rows, cursorVisualRow, cursorVisualCol } = wrapLines(buffer, cursor, width, entries);
147
- return (_jsx(Box, { flexDirection: "column", children: rows.map((row, index) => {
148
- const isCursorRow = index === cursorVisualRow;
149
- return (_jsx(Box, { children: renderVisualRow(row, isCursorRow, cursorVisualCol, showCursor) }, index));
148
+ // Scroll state
149
+ const [scrollTop, setScrollTop] = React.useState(0);
150
+ let currentScrollTop = scrollTop;
151
+ if (maxHeight !== undefined) {
152
+ if (cursorVisualRow < currentScrollTop) {
153
+ currentScrollTop = cursorVisualRow;
154
+ }
155
+ else if (cursorVisualRow >= currentScrollTop + maxHeight) {
156
+ currentScrollTop = cursorVisualRow - maxHeight + 1;
157
+ }
158
+ const maxScroll = Math.max(0, rows.length - maxHeight);
159
+ if (currentScrollTop > maxScroll) {
160
+ currentScrollTop = maxScroll;
161
+ }
162
+ if (currentScrollTop < 0) {
163
+ currentScrollTop = 0;
164
+ }
165
+ if (currentScrollTop !== scrollTop) {
166
+ // In React, setting state during render is a standard pattern for deriving state.
167
+ setScrollTop(currentScrollTop);
168
+ }
169
+ }
170
+ else {
171
+ if (scrollTop !== 0) {
172
+ setScrollTop(0);
173
+ }
174
+ currentScrollTop = 0;
175
+ }
176
+ const visibleRows = maxHeight !== undefined
177
+ ? rows.slice(currentScrollTop, currentScrollTop + maxHeight)
178
+ : rows;
179
+ return (_jsx(Box, { flexDirection: "column", children: visibleRows.map((row, index) => {
180
+ const originalIndex = currentScrollTop + index;
181
+ const isCursorRow = originalIndex === cursorVisualRow;
182
+ return (_jsx(Box, { children: renderVisualRow(row, isCursorRow, cursorVisualCol, showCursor) }, originalIndex));
150
183
  }) }));
151
184
  }
@@ -61,6 +61,14 @@ describe('KeyHandler', () => {
61
61
  handleKey({ ctrl: true }, 'e', buffer, actions);
62
62
  expect(actions.moveCursor).toHaveBeenCalledWith('lineEnd');
63
63
  });
64
+ it('handles Alt+\ as buffer start', () => {
65
+ handleKey({ meta: true }, '\\', buffer, actions);
66
+ expect(actions.moveCursor).toHaveBeenCalledWith('bufferStart');
67
+ });
68
+ it('handles Alt+/ as buffer end', () => {
69
+ handleKey({ meta: true }, '/', buffer, actions);
70
+ expect(actions.moveCursor).toHaveBeenCalledWith('bufferEnd');
71
+ });
64
72
  });
65
73
  describe('Boundary Arrow', () => {
66
74
  describe('Left boundary', () => {
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { render, act } from '@testing-library/react';
4
+ import { MultilineInput } from '../index.js';
5
+ import { EventEmitter } from 'events';
6
+ // Create event emitters for standard streams
7
+ const mockStdout = new EventEmitter();
8
+ mockStdout.columns = 80;
9
+ const mockStdin = new EventEmitter();
10
+ let capturedUseInputHandler = null;
11
+ vi.mock('ink', async () => {
12
+ const actual = await vi.importActual('ink');
13
+ return {
14
+ ...actual,
15
+ useStdout: () => ({
16
+ stdout: mockStdout,
17
+ }),
18
+ useStdin: () => ({
19
+ stdin: mockStdin,
20
+ isRawModeSupported: true,
21
+ }),
22
+ useInput: (handler) => {
23
+ capturedUseInputHandler = handler;
24
+ },
25
+ };
26
+ });
27
+ describe('MultilineInput Meta Key handling', () => {
28
+ beforeEach(() => {
29
+ capturedUseInputHandler = null;
30
+ mockStdin.removeAllListeners();
31
+ vi.clearAllMocks();
32
+ });
33
+ it('correctly maps Alt+\\ (buffer start) even when Ink fails to parse key.meta', () => {
34
+ const onCursorChange = vi.fn();
35
+ // Initial value is "hello", cursor is at offset 5 (end)
36
+ render(_jsx(MultilineInput, { value: "hello", onCursorChange: onCursorChange, isActive: true }));
37
+ expect(capturedUseInputHandler).not.toBeNull();
38
+ // 1. Simulate the raw stdin data event and useInput handler in act
39
+ act(() => {
40
+ mockStdin.emit('data', Buffer.from('\x1b\\'));
41
+ capturedUseInputHandler('\\', { meta: false });
42
+ });
43
+ // The cursor should have moved to buffer start (offset 0)
44
+ expect(onCursorChange).toHaveBeenLastCalledWith(0);
45
+ });
46
+ it('correctly maps Alt+/ (buffer end) even when Ink fails to parse key.meta', () => {
47
+ const onCursorChange = vi.fn();
48
+ // We render and override cursor to start (offset 0)
49
+ render(_jsx(MultilineInput, { value: "hello", onCursorChange: onCursorChange, cursorOverride: 0, isActive: true }));
50
+ expect(capturedUseInputHandler).not.toBeNull();
51
+ // Clear initial cursor change calls to avoid confusion
52
+ onCursorChange.mockClear();
53
+ // 1. Simulate the raw stdin data event and useInput handler in act
54
+ act(() => {
55
+ mockStdin.emit('data', Buffer.from('\x1b/'));
56
+ capturedUseInputHandler('/', { meta: false });
57
+ });
58
+ // The cursor should have moved to buffer end (offset 5)
59
+ expect(onCursorChange).toHaveBeenLastCalledWith(5);
60
+ });
61
+ it('does not touch key.meta if it is not a 2-char escape sequence', () => {
62
+ const onChange = vi.fn();
63
+ render(_jsx(MultilineInput, { value: "", onChange: onChange, isActive: true }));
64
+ expect(capturedUseInputHandler).not.toBeNull();
65
+ onChange.mockClear();
66
+ // Simulate typing a slash character (normal '/')
67
+ act(() => {
68
+ mockStdin.emit('data', Buffer.from('/'));
69
+ capturedUseInputHandler('/', { meta: false });
70
+ });
71
+ // It should be treated as a normal character insertion
72
+ expect(onChange).toHaveBeenLastCalledWith('/');
73
+ });
74
+ });
@@ -428,6 +428,46 @@ describe('TextBuffer', () => {
428
428
  expect(result).toEqual({ line: 0, column: 5 });
429
429
  });
430
430
  });
431
+ describe('bufferStart', () => {
432
+ it('moves to start of buffer on first line', () => {
433
+ const buffer = createBuffer('hello');
434
+ const cursor = { line: 0, column: 3 };
435
+ const result = moveCursor(buffer, cursor, 'bufferStart');
436
+ expect(result).toEqual({ line: 0, column: 0 });
437
+ });
438
+ it('moves to start of buffer from later line', () => {
439
+ const buffer = createBuffer('line1\nline2\nline3');
440
+ const cursor = { line: 2, column: 3 };
441
+ const result = moveCursor(buffer, cursor, 'bufferStart');
442
+ expect(result).toEqual({ line: 0, column: 0 });
443
+ });
444
+ it('stays at start if already there', () => {
445
+ const buffer = createBuffer('hello');
446
+ const cursor = { line: 0, column: 0 };
447
+ const result = moveCursor(buffer, cursor, 'bufferStart');
448
+ expect(result).toEqual({ line: 0, column: 0 });
449
+ });
450
+ });
451
+ describe('bufferEnd', () => {
452
+ it('moves to end of buffer on last line', () => {
453
+ const buffer = createBuffer('hello');
454
+ const cursor = { line: 0, column: 2 };
455
+ const result = moveCursor(buffer, cursor, 'bufferEnd');
456
+ expect(result).toEqual({ line: 0, column: 5 });
457
+ });
458
+ it('moves to end of buffer from earlier line', () => {
459
+ const buffer = createBuffer('line1\nline2\nline3');
460
+ const cursor = { line: 0, column: 3 };
461
+ const result = moveCursor(buffer, cursor, 'bufferEnd');
462
+ expect(result).toEqual({ line: 2, column: 5 });
463
+ });
464
+ it('stays at end if already there', () => {
465
+ const buffer = createBuffer('hello');
466
+ const cursor = { line: 0, column: 5 };
467
+ const result = moveCursor(buffer, cursor, 'bufferEnd');
468
+ expect(result).toEqual({ line: 0, column: 5 });
469
+ });
470
+ });
431
471
  });
432
472
  describe('getTextContent', () => {
433
473
  it('returns empty string for empty buffer', () => {
@@ -182,4 +182,47 @@ describe('TextRenderer', () => {
182
182
  expect(container.textContent).toContain('fghij');
183
183
  });
184
184
  });
185
+ describe('maxHeight and scrolling', () => {
186
+ it('only renders up to maxHeight lines when text is longer', () => {
187
+ const buffer = { lines: ['line1', 'line2', 'line3', 'line4', 'line5'] };
188
+ const cursor = { line: 0, column: 0 };
189
+ const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, maxHeight: 3 }));
190
+ expect(container.textContent).toContain('line1');
191
+ expect(container.textContent).toContain('line2');
192
+ expect(container.textContent).toContain('line3');
193
+ expect(container.textContent).not.toContain('line4');
194
+ expect(container.textContent).not.toContain('line5');
195
+ });
196
+ it('scrolls down to show the cursor when it moves past the viewport height', () => {
197
+ const buffer = { lines: ['line1', 'line2', 'line3', 'line4', 'line5'] };
198
+ const cursor = { line: 4, column: 0 };
199
+ const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, maxHeight: 3 }));
200
+ expect(container.textContent).not.toContain('line1');
201
+ expect(container.textContent).not.toContain('line2');
202
+ expect(container.textContent).toContain('line3');
203
+ expect(container.textContent).toContain('line4');
204
+ expect(container.textContent).toContain('line5');
205
+ });
206
+ it('scrolls up to show the cursor when it moves above the viewport top', () => {
207
+ const buffer = { lines: ['line1', 'line2', 'line3', 'line4', 'line5'] };
208
+ // Step 1: Render with cursor at bottom to establish scroll (scrollTop = 2)
209
+ const { container, rerender } = render(_jsx(TextRenderer, { buffer: buffer, cursor: { line: 4, column: 0 }, maxHeight: 3 }));
210
+ expect(container.textContent).not.toContain('line1');
211
+ expect(container.textContent).toContain('line5');
212
+ // Step 2: Move cursor back to line 1 (scrollTop should become 1)
213
+ rerender(_jsx(TextRenderer, { buffer: buffer, cursor: { line: 1, column: 0 }, maxHeight: 3 }));
214
+ expect(container.textContent).not.toContain('line1');
215
+ expect(container.textContent).toContain('line2');
216
+ expect(container.textContent).toContain('line3');
217
+ expect(container.textContent).toContain('line4');
218
+ expect(container.textContent).not.toContain('line5');
219
+ // Step 3: Move cursor back to line 0 (scrollTop should become 0)
220
+ rerender(_jsx(TextRenderer, { buffer: buffer, cursor: { line: 0, column: 0 }, maxHeight: 3 }));
221
+ expect(container.textContent).toContain('line1');
222
+ expect(container.textContent).toContain('line2');
223
+ expect(container.textContent).toContain('line3');
224
+ expect(container.textContent).not.toContain('line4');
225
+ expect(container.textContent).not.toContain('line5');
226
+ });
227
+ });
185
228
  });
@@ -31,6 +31,7 @@ export interface MultilineInputProps {
31
31
  maxImageSizeBytes?: number;
32
32
  maxImageCount?: number;
33
33
  acceptedMimeTypes?: string[];
34
+ maxHeight?: number;
34
35
  }
35
36
  export interface MultilineInputCoreProps {
36
37
  value?: string;
@@ -57,6 +58,7 @@ export interface MultilineInputCoreProps {
57
58
  formatPastePlaceholder?: (displayNumber: number) => string;
58
59
  images?: ImageRef[];
59
60
  onImagesChange?: (images: ImageRef[]) => void;
61
+ maxHeight?: number;
60
62
  }
61
63
  export declare const MultilineInputCore: React.FC<MultilineInputCoreProps>;
62
64
  export declare const MultilineInput: React.FC<MultilineInputProps>;
@@ -2,12 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useCallback, useRef } from 'react';
3
3
  import { useInput, useStdin, Box, Text } from 'ink';
4
4
  import { useTerminalWidth } from '../../hooks/useTerminalWidth.js';
5
+ import { useTerminalHeight } from '../../hooks/useTerminalHeight.js';
5
6
  import { useTextInput } from './useTextInput.js';
6
7
  import { handleKey } from './KeyHandler.js';
7
8
  import { TextRenderer } from './TextRenderer.js';
8
9
  import { useClipboardPaste } from './useClipboardPaste.js';
9
10
  import { log } from '../../utils/logger.js';
10
- export const MultilineInputCore = ({ value, onChange, placeholder, showCursor = true, width = 80, onCursorChange, cursorOverride, undoDebounceMs, pasteThreshold, formatPastePlaceholder, images, onImagesChange, }) => {
11
+ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor = true, width = 80, onCursorChange, cursorOverride, undoDebounceMs, pasteThreshold, formatPastePlaceholder, images, onImagesChange, maxHeight, }) => {
11
12
  const textInput = useTextInput({ initialValue: value ?? '', undoDebounceMs, pasteThreshold, formatPastePlaceholder });
12
13
  const isSyncingFromProps = useRef(false);
13
14
  useEffect(() => {
@@ -58,9 +59,12 @@ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor =
58
59
  if (showPlaceholder) {
59
60
  return _jsx("div", { style: { opacity: 0.5 }, children: placeholder });
60
61
  }
61
- return (_jsx(TextRenderer, { buffer: textInput.buffer, cursor: textInput.cursor, width: width, showCursor: showCursor, blockState: textInput.blockState }));
62
+ const terminalHeight = useTerminalHeight();
63
+ const defaultMaxHeight = Math.max(1, Math.floor(terminalHeight * 0.8));
64
+ const effectiveMaxHeight = maxHeight ?? defaultMaxHeight;
65
+ return (_jsx(TextRenderer, { buffer: textInput.buffer, cursor: textInput.cursor, width: width, showCursor: showCursor, blockState: textInput.blockState, maxHeight: effectiveMaxHeight }));
62
66
  };
63
- 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, }) => {
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, }) => {
64
68
  const terminalWidth = useTerminalWidth(width);
65
69
  const { stdin } = useStdin();
66
70
  const lastRawInput = useRef('');
@@ -211,13 +215,26 @@ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCur
211
215
  suppressNextInput.current = false;
212
216
  return;
213
217
  }
214
- 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}`);
215
- handleKey(key, input, textInput.buffer, actions, textInput.cursor, lastRawInput.current, terminalWidth);
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}`);
219
+ // Detect if this is an Alt keypress for symbol keys (like Alt+\ or Alt+/)
220
+ // Standard Alt keypresses send ESC (\x1b) followed by the character.
221
+ // We check if it is a 2-character sequence starting with ESC, excluding CSI/SS3 prefixes ('[' or 'O')
222
+ 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');
228
+ const updatedKey = isMeta ? { ...key, meta: true } : key;
229
+ handleKey(updatedKey, input, textInput.buffer, actions, textInput.cursor, lastRawInput.current, terminalWidth);
216
230
  }, { isActive });
217
231
  const isEmpty = textInput.value === '';
218
232
  const showPlaceholder = isEmpty && placeholder && !showCursor;
219
233
  if (showPlaceholder && !isPasting) {
220
234
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: placeholder }) }));
221
235
  }
222
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TextRenderer, { buffer: textInput.buffer, cursor: textInput.cursor, width: terminalWidth, showCursor: showCursor, blockState: textInput.blockState }), isPasting && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Reading clipboard..." }) }))] }));
236
+ const terminalHeight = useTerminalHeight();
237
+ const defaultMaxHeight = Math.max(1, Math.floor(terminalHeight * 0.8));
238
+ const effectiveMaxHeight = maxHeight ?? defaultMaxHeight;
239
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TextRenderer, { buffer: textInput.buffer, cursor: textInput.cursor, width: terminalWidth, showCursor: showCursor, blockState: textInput.blockState, maxHeight: effectiveMaxHeight }), isPasting && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Reading clipboard..." }) }))] }));
223
240
  };
@@ -17,7 +17,7 @@ export interface Buffer {
17
17
  /**
18
18
  * Cursor movement directions
19
19
  */
20
- export type Direction = 'up' | 'down' | 'left' | 'right' | 'lineStart' | 'lineEnd';
20
+ export type Direction = 'up' | 'down' | 'left' | 'right' | 'lineStart' | 'lineEnd' | 'bufferStart' | 'bufferEnd';
21
21
  /**
22
22
  * Boundary arrow directions (subset of Direction used for boundary detection)
23
23
  */
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import { useTerminalHeight } from '../useTerminalHeight.js';
4
+ import { EventEmitter } from 'events';
5
+ // Mock stdout
6
+ const mockStdout = new EventEmitter();
7
+ mockStdout.rows = 24;
8
+ vi.mock('ink', async () => {
9
+ const actual = await vi.importActual('ink');
10
+ return {
11
+ ...actual,
12
+ useStdout: () => ({
13
+ stdout: mockStdout,
14
+ }),
15
+ };
16
+ });
17
+ describe('useTerminalHeight', () => {
18
+ beforeEach(() => {
19
+ mockStdout.rows = 24;
20
+ mockStdout.removeAllListeners();
21
+ });
22
+ afterEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+ it('returns default terminal height', () => {
26
+ const { result } = renderHook(() => useTerminalHeight());
27
+ expect(result.current).toBe(24);
28
+ });
29
+ it('returns prop height when provided', () => {
30
+ const { result } = renderHook(() => useTerminalHeight(10));
31
+ expect(result.current).toBe(10);
32
+ });
33
+ it('updates height on resize', async () => {
34
+ vi.useFakeTimers();
35
+ const { result } = renderHook(() => useTerminalHeight());
36
+ expect(result.current).toBe(24);
37
+ // Simulate resize
38
+ mockStdout.rows = 40;
39
+ act(() => {
40
+ mockStdout.emit('resize');
41
+ });
42
+ // Wait for debounce to complete
43
+ await act(async () => {
44
+ vi.advanceTimersByTime(100);
45
+ });
46
+ expect(result.current).toBe(40);
47
+ vi.useRealTimers();
48
+ });
49
+ it('does not update when prop height is provided', () => {
50
+ const { result } = renderHook(() => useTerminalHeight(15));
51
+ expect(result.current).toBe(15);
52
+ // Simulate resize - should still use prop height
53
+ mockStdout.rows = 40;
54
+ act(() => {
55
+ mockStdout.emit('resize');
56
+ });
57
+ // Still returns prop height
58
+ expect(result.current).toBe(15);
59
+ });
60
+ it('cleans up resize listener on unmount', () => {
61
+ const { unmount } = renderHook(() => useTerminalHeight());
62
+ expect(mockStdout.listenerCount('resize')).toBe(1);
63
+ unmount();
64
+ expect(mockStdout.listenerCount('resize')).toBe(0);
65
+ });
66
+ it('debounces multiple rapid resize events', async () => {
67
+ vi.useFakeTimers();
68
+ const { result } = renderHook(() => useTerminalHeight());
69
+ expect(result.current).toBe(24);
70
+ // Simulate rapid resize events
71
+ mockStdout.rows = 30;
72
+ act(() => {
73
+ mockStdout.emit('resize');
74
+ });
75
+ mockStdout.rows = 35;
76
+ act(() => {
77
+ mockStdout.emit('resize');
78
+ });
79
+ mockStdout.rows = 45;
80
+ act(() => {
81
+ mockStdout.emit('resize');
82
+ });
83
+ // Still at initial height because debounce hasn't fired yet
84
+ expect(result.current).toBe(24);
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(45);
91
+ vi.useRealTimers();
92
+ });
93
+ it('accepts custom debounce delay', async () => {
94
+ vi.useFakeTimers();
95
+ const { result } = renderHook(() => useTerminalHeight(undefined, 50));
96
+ expect(result.current).toBe(24);
97
+ // Simulate resize
98
+ mockStdout.rows = 30;
99
+ act(() => {
100
+ mockStdout.emit('resize');
101
+ });
102
+ expect(result.current).toBe(24);
103
+ // Wait for custom debounce to complete
104
+ await act(async () => {
105
+ vi.advanceTimersByTime(50);
106
+ });
107
+ expect(result.current).toBe(30);
108
+ vi.useRealTimers();
109
+ });
110
+ it('cancels pending debounce on unmount', async () => {
111
+ vi.useFakeTimers();
112
+ const { unmount } = renderHook(() => useTerminalHeight());
113
+ // Simulate resize
114
+ mockStdout.rows = 30;
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
+ });
128
+ });
@@ -1 +1,2 @@
1
1
  export { useTerminalWidth } from './useTerminalWidth.js';
2
+ export { useTerminalHeight } from './useTerminalHeight.js';
@@ -1 +1,2 @@
1
1
  export { useTerminalWidth } from './useTerminalWidth.js';
2
+ export { useTerminalHeight } from './useTerminalHeight.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Hook to get the current terminal height and listen for resize events.
3
+ *
4
+ * @param propHeight - Optional explicit height to use instead of terminal height
5
+ * @param debounceMs - Optional debounce delay in milliseconds (default: 100)
6
+ * @returns The effective height (propHeight if provided, otherwise terminal height)
7
+ */
8
+ export declare function useTerminalHeight(propHeight?: number, debounceMs?: number): number;
@@ -0,0 +1,39 @@
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { useStdout } from 'ink';
3
+ /**
4
+ * Hook to get the current terminal height and listen for resize events.
5
+ *
6
+ * @param propHeight - Optional explicit height to use instead of terminal height
7
+ * @param debounceMs - Optional debounce delay in milliseconds (default: 100)
8
+ * @returns The effective height (propHeight if provided, otherwise terminal height)
9
+ */
10
+ export function useTerminalHeight(propHeight, debounceMs = 100) {
11
+ const { stdout } = useStdout();
12
+ const [terminalHeight, setTerminalHeight] = useState(stdout?.rows ?? 24);
13
+ const debounceTimer = useRef(null);
14
+ useEffect(() => {
15
+ if (!stdout)
16
+ return;
17
+ const onResize = () => {
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
+ setTerminalHeight(stdout.rows);
25
+ debounceTimer.current = null;
26
+ }, debounceMs);
27
+ };
28
+ stdout.on('resize', onResize);
29
+ return () => {
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
+ }
36
+ };
37
+ }, [stdout, debounceMs]);
38
+ return propHeight ?? terminalHeight;
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ink-prompt",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "A React Ink component for prompts",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",