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 +2 -1
- package/dist/components/MultilineInput/KeyHandler.js +27 -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 +26 -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/dist/components/MultilineInput/ImageSentinel.d.ts +0 -15
- package/dist/components/MultilineInput/ImageSentinel.js +0 -62
- package/dist/components/MultilineInput/__tests__/ImageSentinel.test.js +0 -154
- /package/dist/components/MultilineInput/__tests__/{ImageSentinel.test.d.ts → MultilineInput.test.d.ts} +0 -0
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
|
|
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
|
-
|
|
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', () => {
|
|
@@ -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
|
-
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -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
|
-
});
|
|
File without changes
|