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 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` or typing `\` before **Enter** to add a newline
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,6 +1,6 @@
1
1
  {
2
2
  "name": "ink-prompt",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
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
- });