ink-prompt 0.2.4 → 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,13 +54,14 @@ 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
 
60
61
  `MultilineInput` supports typical editing controls:
61
62
 
62
63
  - **Arrow keys** for navigation
63
- - `Ctrl+J` or typing `\` before **Enter** to add a newline
64
+ - **Shift+Enter**, `Ctrl+J`, or typing `\` before **Enter** to add a newline (Shift+Enter requires a terminal that distinguishes it — most emit `ESC + CR` or the kitty `CSI 13;2u` sequence)
64
65
  - `Ctrl+Z` / `Ctrl+Y` for undo/redo
65
66
  - `Ctrl+A` / `Ctrl+E` for jump to line start/end
66
67
  - **Home** / **End** keys for line start/end
@@ -125,6 +125,15 @@ const BACKSPACE_SEQUENCES = ['\u0008', '\u007f'];
125
125
  function isBackspaceSequence(seq) {
126
126
  return !!seq && BACKSPACE_SEQUENCES.includes(seq);
127
127
  }
128
+ /**
129
+ * Raw sequences that represent Shift+Enter across terminal emulators.
130
+ * - `\x1b\r` / `\x1b\n`: ESC + CR/LF emitted by terminals like iTerm2 / WezTerm when configured.
131
+ * - `\x1b[13;2u`: kitty keyboard protocol encoding for Shift+Enter.
132
+ */
133
+ const SHIFT_ENTER_SEQUENCES = ['\x1b\r', '\x1b\n', '\x1b[13;2u'];
134
+ function isShiftEnterSequence(seq) {
135
+ return !!seq && SHIFT_ENTER_SEQUENCES.includes(seq);
136
+ }
128
137
  /**
129
138
  * Handles keyboard input and maps it to text input actions.
130
139
  *
@@ -192,6 +201,15 @@ export function handleKey(key, input, buffer, actions, cursor, rawInput, width)
192
201
  actions.moveCursor('lineEnd');
193
202
  return;
194
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
+ }
195
213
  // Paste / History
196
214
  if (key.ctrl) {
197
215
  if (input === 'v' && actions.paste) {
@@ -223,6 +241,15 @@ export function handleKey(key, input, buffer, actions, cursor, rawInput, width)
223
241
  actions.deleteForward();
224
242
  return;
225
243
  }
244
+ // Shift+Enter inserts a newline regardless of buffer state.
245
+ // Detected via Ink's shift+return flags or raw escape sequences emitted by
246
+ // terminals that distinguish Shift+Enter from Enter.
247
+ if ((key.shift && key.return) ||
248
+ (key.meta && key.return) ||
249
+ isShiftEnterSequence(rawInput)) {
250
+ actions.newLine();
251
+ return;
252
+ }
226
253
  // Submission / New Line
227
254
  if (key.return) {
228
255
  log(`[KEYHANDLER] return key, cursor=${JSON.stringify(cursor)}, currentLine="${(cursor ? buffer.lines[cursor.line || 0] : 'no cursor').replace(/[\x00-\x1F\x7F-\uFFFF]/g, c => `\\x${c.charCodeAt(0).toString(16)}`)}" endsWithBackslash=${cursor ? buffer.lines[cursor.line || 0].endsWith('\\') : false}`);
@@ -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', () => {
@@ -289,6 +297,24 @@ describe('KeyHandler', () => {
289
297
  expect(actions.newLine).not.toHaveBeenCalled();
290
298
  expect(actions.submit).not.toHaveBeenCalled();
291
299
  });
300
+ it('handles Shift+Enter as newline', () => {
301
+ buffer = { lines: ['hello'] };
302
+ handleKey({ shift: true, return: true }, '', buffer, actions);
303
+ expect(actions.newLine).toHaveBeenCalledTimes(1);
304
+ expect(actions.submit).not.toHaveBeenCalled();
305
+ });
306
+ it('handles ESC+CR raw sequence (Shift+Enter) as newline', () => {
307
+ buffer = { lines: ['hello'] };
308
+ handleKey({ meta: true, return: true }, '', buffer, actions, undefined, '\x1b\r');
309
+ expect(actions.newLine).toHaveBeenCalledTimes(1);
310
+ expect(actions.submit).not.toHaveBeenCalled();
311
+ });
312
+ it('handles kitty Shift+Enter CSI u sequence as newline', () => {
313
+ buffer = { lines: ['hello'] };
314
+ handleKey({}, '', buffer, actions, undefined, '\x1b[13;2u');
315
+ expect(actions.newLine).toHaveBeenCalledTimes(1);
316
+ expect(actions.submit).not.toHaveBeenCalled();
317
+ });
292
318
  it('handles Enter as newline if line ends with backslash (multiple lines)', () => {
293
319
  const cursor = { line: 1, column: 7 };
294
320
  buffer = { lines: ['first', 'second\\'] };
@@ -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.4",
3
+ "version": "0.3.0",
4
4
  "description": "A React Ink component for prompts",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,15 +0,0 @@
1
- export declare function generateImageId(): string;
2
- export declare function createSentinel(id: string, displayNumber: number): string;
3
- export interface SentinelInfo {
4
- id: string;
5
- displayNumber: number;
6
- start: number;
7
- end: number;
8
- }
9
- export declare function parseSentinels(text: string): SentinelInfo[];
10
- export declare function findSentinelAt(text: string, offset: number): SentinelInfo | null;
11
- export declare function isInsideSentinel(text: string, offset: number): boolean;
12
- export declare function removeSentinel(text: string, offset: number): string;
13
- export declare function getPlaceholderText(displayNumber: number): string;
14
- export declare function getPlaceholderVisualWidth(displayNumber: number): number;
15
- export declare function getSentinelVisualWidthFromText(text: string, offset: number): number | null;
@@ -1,62 +0,0 @@
1
- import { SENTINEL_OPEN, SENTINEL_CLOSE } from './ImageTypes.js';
2
- export function generateImageId() {
3
- return Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 6);
4
- }
5
- export function createSentinel(id, displayNumber) {
6
- return `${SENTINEL_OPEN}${id}:${displayNumber}${SENTINEL_CLOSE}`;
7
- }
8
- export function parseSentinels(text) {
9
- const result = [];
10
- let i = 0;
11
- while (i < text.length) {
12
- const openIdx = text.indexOf(SENTINEL_OPEN, i);
13
- if (openIdx === -1)
14
- break;
15
- const closeIdx = text.indexOf(SENTINEL_CLOSE, openIdx + 1);
16
- if (closeIdx === -1)
17
- break;
18
- const raw = text.substring(openIdx + 1, closeIdx);
19
- const colonIdx = raw.lastIndexOf(':');
20
- const id = colonIdx >= 0 ? raw.substring(0, colonIdx) : raw;
21
- const displayNumber = colonIdx >= 0 ? parseInt(raw.substring(colonIdx + 1), 10) || 1 : 1;
22
- result.push({ id, displayNumber, start: openIdx, end: closeIdx + 1 });
23
- i = closeIdx + 1;
24
- }
25
- return result;
26
- }
27
- export function findSentinelAt(text, offset) {
28
- const sentinels = parseSentinels(text);
29
- for (const s of sentinels) {
30
- if (offset >= s.start && offset <= s.end) {
31
- return s;
32
- }
33
- }
34
- return null;
35
- }
36
- export function isInsideSentinel(text, offset) {
37
- const sentinels = parseSentinels(text);
38
- for (const s of sentinels) {
39
- if (offset >= s.start && offset < s.end) {
40
- return true;
41
- }
42
- }
43
- return false;
44
- }
45
- export function removeSentinel(text, offset) {
46
- const sentinel = findSentinelAt(text, offset);
47
- if (!sentinel)
48
- return text;
49
- return text.slice(0, sentinel.start) + text.slice(sentinel.end);
50
- }
51
- export function getPlaceholderText(displayNumber) {
52
- return `[Pasted Image #${displayNumber}]`;
53
- }
54
- export function getPlaceholderVisualWidth(displayNumber) {
55
- return getPlaceholderText(displayNumber).length;
56
- }
57
- export function getSentinelVisualWidthFromText(text, offset) {
58
- const s = findSentinelAt(text, offset);
59
- if (!s)
60
- return null;
61
- return getPlaceholderVisualWidth(s.displayNumber);
62
- }
@@ -1,154 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { generateImageId, createSentinel, parseSentinels, findSentinelAt, isInsideSentinel, removeSentinel, getPlaceholderText, getPlaceholderVisualWidth, } from '../ImageSentinel.js';
3
- import { SENTINEL_OPEN, SENTINEL_CLOSE } from '../ImageTypes.js';
4
- describe('ImageSentinel', () => {
5
- describe('generateImageId', () => {
6
- it('generates a string id', () => {
7
- const id = generateImageId();
8
- expect(typeof id).toBe('string');
9
- expect(id.length).toBeGreaterThan(0);
10
- });
11
- it('generates unique ids', () => {
12
- const ids = new Set(Array.from({ length: 100 }, () => generateImageId()));
13
- expect(ids.size).toBe(100);
14
- });
15
- });
16
- describe('createSentinel', () => {
17
- it('creates a sentinel block with the given id and display number', () => {
18
- const result = createSentinel('abc123', 1);
19
- expect(result).toBe(`${SENTINEL_OPEN}abc123:1${SENTINEL_CLOSE}`);
20
- });
21
- it('creates a sentinel with higher display number', () => {
22
- const result = createSentinel('def456', 42);
23
- expect(result).toBe(`${SENTINEL_OPEN}def456:42${SENTINEL_CLOSE}`);
24
- });
25
- });
26
- describe('parseSentinels', () => {
27
- it('returns empty array for text without sentinels', () => {
28
- expect(parseSentinels('hello world')).toEqual([]);
29
- });
30
- it('finds a single sentinel', () => {
31
- const text = `hello ${SENTINEL_OPEN}abc123:1${SENTINEL_CLOSE} world`;
32
- const result = parseSentinels(text);
33
- expect(result).toEqual([
34
- { id: 'abc123', displayNumber: 1, start: 6, end: 16 },
35
- ]);
36
- });
37
- it('finds multiple sentinels', () => {
38
- const text = `${SENTINEL_OPEN}id1:1${SENTINEL_CLOSE}hello${SENTINEL_OPEN}id2:2${SENTINEL_CLOSE}`;
39
- const result = parseSentinels(text);
40
- expect(result).toEqual([
41
- { id: 'id1', displayNumber: 1, start: 0, end: 7 },
42
- { id: 'id2', displayNumber: 2, start: 12, end: 19 },
43
- ]);
44
- });
45
- it('returns empty for unmatched opener', () => {
46
- const text = `hello ${SENTINEL_OPEN}abc:1`;
47
- const result = parseSentinels(text);
48
- expect(result).toEqual([]);
49
- });
50
- it('returns empty for unmatched closer', () => {
51
- const text = `hello abc:1${SENTINEL_CLOSE} world`;
52
- const result = parseSentinels(text);
53
- expect(result).toEqual([]);
54
- });
55
- });
56
- describe('findSentinelAt', () => {
57
- it('returns null when offset is not near a sentinel', () => {
58
- const text = `hello ${SENTINEL_OPEN}abc:1${SENTINEL_CLOSE} world`;
59
- expect(findSentinelAt(text, 0)).toBeNull();
60
- });
61
- it('finds sentinel when offset is at the opener', () => {
62
- const text = `hello ${SENTINEL_OPEN}abc:1${SENTINEL_CLOSE} world`;
63
- const result = findSentinelAt(text, 6);
64
- expect(result).toMatchObject({ id: 'abc', displayNumber: 1, start: 6 });
65
- });
66
- it('finds sentinel when offset is inside the id', () => {
67
- const text = `hello ${SENTINEL_OPEN}abc:1${SENTINEL_CLOSE} world`;
68
- const result = findSentinelAt(text, 8);
69
- expect(result).toMatchObject({ id: 'abc', displayNumber: 1 });
70
- });
71
- it('finds sentinel when offset is at the closer', () => {
72
- const text = `hello ${SENTINEL_OPEN}abc:1${SENTINEL_CLOSE} world`;
73
- const result = findSentinelAt(text, 12);
74
- expect(result).toMatchObject({ id: 'abc', displayNumber: 1 });
75
- });
76
- it('finds sentinel when offset is right after the closer', () => {
77
- const text = `${SENTINEL_OPEN}abc:1${SENTINEL_CLOSE}`;
78
- const result = findSentinelAt(text, 7);
79
- expect(result).toMatchObject({ id: 'abc', displayNumber: 1, start: 0, end: 7 });
80
- });
81
- });
82
- describe('isInsideSentinel', () => {
83
- it('returns false when offset is before any sentinel', () => {
84
- const text = `hi ${SENTINEL_OPEN}abc:1${SENTINEL_CLOSE}`;
85
- expect(isInsideSentinel(text, 0)).toBe(false);
86
- });
87
- it('returns false when offset is after sentinel', () => {
88
- const text = `${SENTINEL_OPEN}abc:1${SENTINEL_CLOSE} world`;
89
- expect(isInsideSentinel(text, 10)).toBe(false);
90
- });
91
- it('returns true when offset is at the opener', () => {
92
- const text = `${SENTINEL_OPEN}abc:1${SENTINEL_CLOSE}`;
93
- expect(isInsideSentinel(text, 0)).toBe(true);
94
- });
95
- it('returns true when offset is inside the id', () => {
96
- const text = `${SENTINEL_OPEN}abc:1${SENTINEL_CLOSE}`;
97
- expect(isInsideSentinel(text, 2)).toBe(true);
98
- });
99
- it('returns true when offset is at the closer', () => {
100
- const text = `${SENTINEL_OPEN}abc:1${SENTINEL_CLOSE}`;
101
- expect(isInsideSentinel(text, 6)).toBe(true);
102
- });
103
- it('returns false when offset is after the closer', () => {
104
- const text = `${SENTINEL_OPEN}abc:1${SENTINEL_CLOSE}`;
105
- expect(isInsideSentinel(text, 7)).toBe(false);
106
- });
107
- });
108
- describe('removeSentinel', () => {
109
- it('removes sentinel block at cursor position', () => {
110
- const text = `hello ${SENTINEL_OPEN}abc:1${SENTINEL_CLOSE} world`;
111
- const result = removeSentinel(text, 10);
112
- expect(result).toBe('hello world');
113
- });
114
- it('removes sentinel when cursor is at the opener', () => {
115
- const text = `${SENTINEL_OPEN}abc:1${SENTINEL_CLOSE}hello`;
116
- const result = removeSentinel(text, 0);
117
- expect(result).toBe('hello');
118
- });
119
- it('removes sentinel when cursor is at the closer', () => {
120
- const text = `hello${SENTINEL_OPEN}abc:1${SENTINEL_CLOSE}`;
121
- const result = removeSentinel(text, 11);
122
- expect(result).toBe('hello');
123
- });
124
- it('removes first sentinel when cursor is between two', () => {
125
- const text = `${SENTINEL_OPEN}a:1${SENTINEL_CLOSE}${SENTINEL_OPEN}b:2${SENTINEL_CLOSE}`;
126
- const result = removeSentinel(text, 1);
127
- expect(result).toBe(`${SENTINEL_OPEN}b:2${SENTINEL_CLOSE}`);
128
- });
129
- it('returns text unchanged if no sentinel at offset', () => {
130
- const text = 'hello world';
131
- const result = removeSentinel(text, 3);
132
- expect(result).toBe('hello world');
133
- });
134
- });
135
- describe('getPlaceholderText', () => {
136
- it('returns correct placeholder for display number 1', () => {
137
- expect(getPlaceholderText(1)).toBe('[Pasted Image #1]');
138
- });
139
- it('returns correct placeholder for display number 42', () => {
140
- expect(getPlaceholderText(42)).toBe('[Pasted Image #42]');
141
- });
142
- });
143
- describe('getPlaceholderVisualWidth', () => {
144
- it('returns correct width for display number 1', () => {
145
- expect(getPlaceholderVisualWidth(1)).toBe(17);
146
- });
147
- it('returns correct width for display number 100', () => {
148
- expect(getPlaceholderVisualWidth(100)).toBe(19);
149
- });
150
- it('returns correct width for display number 0', () => {
151
- expect(getPlaceholderVisualWidth(0)).toBe(17);
152
- });
153
- });
154
- });