ink-prompt 0.2.4 → 0.2.5
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 -1
- package/dist/components/MultilineInput/KeyHandler.js +18 -0
- package/dist/components/MultilineInput/__tests__/KeyHandler.test.js +18 -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.d.ts +0 -1
- package/dist/components/MultilineInput/__tests__/ImageSentinel.test.js +0 -154
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ render(<App />);
|
|
|
60
60
|
`MultilineInput` supports typical editing controls:
|
|
61
61
|
|
|
62
62
|
- **Arrow keys** for navigation
|
|
63
|
-
- `Ctrl+J
|
|
63
|
+
- **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
64
|
- `Ctrl+Z` / `Ctrl+Y` for undo/redo
|
|
65
65
|
- `Ctrl+A` / `Ctrl+E` for jump to line start/end
|
|
66
66
|
- **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
|
*
|
|
@@ -223,6 +232,15 @@ export function handleKey(key, input, buffer, actions, cursor, rawInput, width)
|
|
|
223
232
|
actions.deleteForward();
|
|
224
233
|
return;
|
|
225
234
|
}
|
|
235
|
+
// Shift+Enter inserts a newline regardless of buffer state.
|
|
236
|
+
// Detected via Ink's shift+return flags or raw escape sequences emitted by
|
|
237
|
+
// terminals that distinguish Shift+Enter from Enter.
|
|
238
|
+
if ((key.shift && key.return) ||
|
|
239
|
+
(key.meta && key.return) ||
|
|
240
|
+
isShiftEnterSequence(rawInput)) {
|
|
241
|
+
actions.newLine();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
226
244
|
// Submission / New Line
|
|
227
245
|
if (key.return) {
|
|
228
246
|
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}`);
|
|
@@ -289,6 +289,24 @@ describe('KeyHandler', () => {
|
|
|
289
289
|
expect(actions.newLine).not.toHaveBeenCalled();
|
|
290
290
|
expect(actions.submit).not.toHaveBeenCalled();
|
|
291
291
|
});
|
|
292
|
+
it('handles Shift+Enter as newline', () => {
|
|
293
|
+
buffer = { lines: ['hello'] };
|
|
294
|
+
handleKey({ shift: true, return: true }, '', buffer, actions);
|
|
295
|
+
expect(actions.newLine).toHaveBeenCalledTimes(1);
|
|
296
|
+
expect(actions.submit).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
it('handles ESC+CR raw sequence (Shift+Enter) as newline', () => {
|
|
299
|
+
buffer = { lines: ['hello'] };
|
|
300
|
+
handleKey({ meta: true, return: true }, '', buffer, actions, undefined, '\x1b\r');
|
|
301
|
+
expect(actions.newLine).toHaveBeenCalledTimes(1);
|
|
302
|
+
expect(actions.submit).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
it('handles kitty Shift+Enter CSI u sequence as newline', () => {
|
|
305
|
+
buffer = { lines: ['hello'] };
|
|
306
|
+
handleKey({}, '', buffer, actions, undefined, '\x1b[13;2u');
|
|
307
|
+
expect(actions.newLine).toHaveBeenCalledTimes(1);
|
|
308
|
+
expect(actions.submit).not.toHaveBeenCalled();
|
|
309
|
+
});
|
|
292
310
|
it('handles Enter as newline if line ends with backslash (multiple lines)', () => {
|
|
293
311
|
const cursor = { line: 1, column: 7 };
|
|
294
312
|
buffer = { lines: ['first', 'second\\'] };
|
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 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -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
|
-
});
|