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 +1 -0
- package/dist/components/MultilineInput/KeyHandler.js +9 -0
- package/dist/components/MultilineInput/TextBuffer.js +6 -0
- package/dist/components/MultilineInput/TextRenderer.d.ts +2 -1
- package/dist/components/MultilineInput/TextRenderer.js +37 -4
- package/dist/components/MultilineInput/__tests__/KeyHandler.test.js +8 -0
- package/dist/components/MultilineInput/__tests__/MultilineInput.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/MultilineInput.test.js +74 -0
- package/dist/components/MultilineInput/__tests__/TextBuffer.test.js +40 -0
- package/dist/components/MultilineInput/__tests__/TextRenderer.test.js +43 -0
- package/dist/components/MultilineInput/index.d.ts +2 -0
- package/dist/components/MultilineInput/index.js +23 -6
- package/dist/components/MultilineInput/types.d.ts +1 -1
- package/dist/hooks/__tests__/useTerminalHeight.test.d.ts +1 -0
- package/dist/hooks/__tests__/useTerminalHeight.test.js +128 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useTerminalHeight.d.ts +8 -0
- package/dist/hooks/useTerminalHeight.js +39 -0
- package/package.json +1 -1
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
|
|
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
|
|
215
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/dist/hooks/index.d.ts
CHANGED
package/dist/hooks/index.js
CHANGED
|
@@ -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
|
+
}
|