ink-prompt 0.1.5 → 0.1.7
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/dist/components/MultilineInput/KeyHandler.d.ts +3 -1
- package/dist/components/MultilineInput/KeyHandler.js +120 -2
- package/dist/components/MultilineInput/__tests__/KeyHandler.test.js +173 -0
- package/dist/components/MultilineInput/index.d.ts +82 -9
- package/dist/components/MultilineInput/index.js +3 -2
- package/dist/components/MultilineInput/types.d.ts +4 -0
- package/dist/index.d.ts +1 -0
- package/package.json +1 -1
- package/dist/examples/examples/basic.d.ts +0 -1
- package/dist/examples/examples/basic.js +0 -9
- package/dist/examples/src/components/MultilineInput/KeyHandler.d.ts +0 -15
- package/dist/examples/src/components/MultilineInput/KeyHandler.js +0 -97
- package/dist/examples/src/components/MultilineInput/TextBuffer.d.ts +0 -34
- package/dist/examples/src/components/MultilineInput/TextBuffer.js +0 -127
- package/dist/examples/src/components/MultilineInput/TextRenderer.d.ts +0 -24
- package/dist/examples/src/components/MultilineInput/TextRenderer.js +0 -72
- package/dist/examples/src/components/MultilineInput/__tests__/KeyHandler.test.d.ts +0 -1
- package/dist/examples/src/components/MultilineInput/__tests__/KeyHandler.test.js +0 -115
- package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.d.ts +0 -1
- package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.js +0 -254
- package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.d.ts +0 -1
- package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.js +0 -176
- package/dist/examples/src/components/MultilineInput/__tests__/integration.test.d.ts +0 -1
- package/dist/examples/src/components/MultilineInput/__tests__/integration.test.js +0 -71
- package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.d.ts +0 -1
- package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.js +0 -65
- package/dist/examples/src/components/MultilineInput/index.d.ts +0 -39
- package/dist/examples/src/components/MultilineInput/index.js +0 -82
- package/dist/examples/src/components/MultilineInput/types.d.ts +0 -55
- package/dist/examples/src/components/MultilineInput/types.js +0 -1
- package/dist/examples/src/components/MultilineInput/useTextInput.d.ts +0 -16
- package/dist/examples/src/components/MultilineInput/useTextInput.js +0 -82
- package/dist/examples/src/hello.test.d.ts +0 -1
- package/dist/examples/src/hello.test.js +0 -13
- package/dist/examples/src/index.d.ts +0 -2
- package/dist/examples/src/index.js +0 -2
|
@@ -2,6 +2,7 @@ import { type Key, type Buffer, type Cursor } from './types.js';
|
|
|
2
2
|
import { type UseTextInputResult } from './useTextInput.js';
|
|
3
3
|
export interface KeyHandlerActions extends Omit<UseTextInputResult, 'value' | 'cursor' | 'cursorOffset' | 'setCursorOffset'> {
|
|
4
4
|
submit: () => void;
|
|
5
|
+
onBoundaryArrow?: (direction: 'up' | 'down' | 'left' | 'right') => void;
|
|
5
6
|
}
|
|
6
7
|
/**
|
|
7
8
|
* Handles keyboard input and maps it to text input actions.
|
|
@@ -12,5 +13,6 @@ export interface KeyHandlerActions extends Omit<UseTextInputResult, 'value' | 'c
|
|
|
12
13
|
* @param actions - The actions available to modify the state
|
|
13
14
|
* @param cursor - The current cursor position (optional, but required for some logic like backslash check)
|
|
14
15
|
* @param rawInput - The raw input sequence (optional, used for detecting Home/End keys)
|
|
16
|
+
* @param width - Terminal width for visual-aware boundary detection (optional)
|
|
15
17
|
*/
|
|
16
|
-
export declare function handleKey(key: Partial<Key>, input: string, buffer: Buffer, actions: KeyHandlerActions, cursor?: Cursor, rawInput?: string): void;
|
|
18
|
+
export declare function handleKey(key: Partial<Key>, input: string, buffer: Buffer, actions: KeyHandlerActions, cursor?: Cursor, rawInput?: string, width?: number): void;
|
|
@@ -1,4 +1,105 @@
|
|
|
1
|
+
import { getVisualRows } from './TextBuffer.js';
|
|
1
2
|
import { log } from '../../utils/logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* Check if cursor is at the left boundary (start of text).
|
|
5
|
+
*/
|
|
6
|
+
function isAtLeftBoundary(cursor) {
|
|
7
|
+
return cursor.line === 0 && cursor.column === 0;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Check if cursor is at the right boundary (end of text).
|
|
11
|
+
*/
|
|
12
|
+
function isAtRightBoundary(buffer, cursor) {
|
|
13
|
+
const lastLineIndex = buffer.lines.length - 1;
|
|
14
|
+
const lastLine = buffer.lines[lastLineIndex];
|
|
15
|
+
return cursor.line === lastLineIndex && cursor.column >= lastLine.length;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Check if cursor is at the top boundary (cannot move up).
|
|
19
|
+
* When width is provided, this considers visual line wrapping.
|
|
20
|
+
*/
|
|
21
|
+
function isAtTopBoundary(buffer, cursor, width) {
|
|
22
|
+
if (cursor.line > 0) {
|
|
23
|
+
// Not on first buffer line, check visual wrapping
|
|
24
|
+
if (width !== undefined) {
|
|
25
|
+
const currentLine = buffer.lines[cursor.line];
|
|
26
|
+
const rows = getVisualRows(currentLine, width);
|
|
27
|
+
// Find which visual row the cursor is on
|
|
28
|
+
let visualRow = 0;
|
|
29
|
+
for (let i = 0; i < rows.length; i++) {
|
|
30
|
+
const row = rows[i];
|
|
31
|
+
const rowEnd = row.start + row.length;
|
|
32
|
+
if (cursor.column >= row.start && cursor.column <= rowEnd) {
|
|
33
|
+
visualRow = i;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// If cursor is not on the first visual row of this line, it can move up
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
// Cursor is on first buffer line
|
|
43
|
+
if (width !== undefined) {
|
|
44
|
+
const currentLine = buffer.lines[0];
|
|
45
|
+
const rows = getVisualRows(currentLine, width);
|
|
46
|
+
// Find which visual row the cursor is on
|
|
47
|
+
for (let i = 0; i < rows.length; i++) {
|
|
48
|
+
const row = rows[i];
|
|
49
|
+
const rowEnd = row.start + row.length;
|
|
50
|
+
if (cursor.column >= row.start && cursor.column <= rowEnd) {
|
|
51
|
+
// At top boundary only if on first visual row
|
|
52
|
+
return i === 0;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// No width - buffer line-based: first line means at top
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Check if cursor is at the bottom boundary (cannot move down).
|
|
61
|
+
* When width is provided, this considers visual line wrapping.
|
|
62
|
+
*/
|
|
63
|
+
function isAtBottomBoundary(buffer, cursor, width) {
|
|
64
|
+
const lastLineIndex = buffer.lines.length - 1;
|
|
65
|
+
if (cursor.line < lastLineIndex) {
|
|
66
|
+
// Not on last buffer line, check visual wrapping
|
|
67
|
+
if (width !== undefined) {
|
|
68
|
+
const currentLine = buffer.lines[cursor.line];
|
|
69
|
+
const rows = getVisualRows(currentLine, width);
|
|
70
|
+
// Find which visual row the cursor is on
|
|
71
|
+
let visualRow = 0;
|
|
72
|
+
for (let i = 0; i < rows.length; i++) {
|
|
73
|
+
const row = rows[i];
|
|
74
|
+
const rowEnd = row.start + row.length;
|
|
75
|
+
if (cursor.column >= row.start && cursor.column <= rowEnd) {
|
|
76
|
+
visualRow = i;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// If cursor is not on the last visual row of this line, it can move down
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
// Cursor is on last buffer line
|
|
86
|
+
if (width !== undefined) {
|
|
87
|
+
const currentLine = buffer.lines[lastLineIndex];
|
|
88
|
+
const rows = getVisualRows(currentLine, width);
|
|
89
|
+
const lastVisualRow = rows.length - 1;
|
|
90
|
+
// Find which visual row the cursor is on
|
|
91
|
+
for (let i = 0; i < rows.length; i++) {
|
|
92
|
+
const row = rows[i];
|
|
93
|
+
const rowEnd = row.start + row.length;
|
|
94
|
+
if (cursor.column >= row.start && cursor.column <= rowEnd) {
|
|
95
|
+
// At bottom boundary only if on last visual row
|
|
96
|
+
return i === lastVisualRow;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// No width - buffer line-based: last line means at bottom
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
2
103
|
/**
|
|
3
104
|
* Escape sequences for Home key (various terminal emulators)
|
|
4
105
|
*/
|
|
@@ -33,22 +134,39 @@ function isBackspaceSequence(seq) {
|
|
|
33
134
|
* @param actions - The actions available to modify the state
|
|
34
135
|
* @param cursor - The current cursor position (optional, but required for some logic like backslash check)
|
|
35
136
|
* @param rawInput - The raw input sequence (optional, used for detecting Home/End keys)
|
|
137
|
+
* @param width - Terminal width for visual-aware boundary detection (optional)
|
|
36
138
|
*/
|
|
37
|
-
export function handleKey(key, input, buffer, actions, cursor, rawInput) {
|
|
38
|
-
// Navigation
|
|
139
|
+
export function handleKey(key, input, buffer, actions, cursor, rawInput, width) {
|
|
140
|
+
// Navigation with boundary detection
|
|
39
141
|
if (key.upArrow) {
|
|
142
|
+
if (cursor && actions.onBoundaryArrow && isAtTopBoundary(buffer, cursor, width)) {
|
|
143
|
+
actions.onBoundaryArrow('up');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
40
146
|
actions.moveCursor('up');
|
|
41
147
|
return;
|
|
42
148
|
}
|
|
43
149
|
if (key.downArrow) {
|
|
150
|
+
if (cursor && actions.onBoundaryArrow && isAtBottomBoundary(buffer, cursor, width)) {
|
|
151
|
+
actions.onBoundaryArrow('down');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
44
154
|
actions.moveCursor('down');
|
|
45
155
|
return;
|
|
46
156
|
}
|
|
47
157
|
if (key.leftArrow) {
|
|
158
|
+
if (cursor && actions.onBoundaryArrow && isAtLeftBoundary(cursor)) {
|
|
159
|
+
actions.onBoundaryArrow('left');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
48
162
|
actions.moveCursor('left');
|
|
49
163
|
return;
|
|
50
164
|
}
|
|
51
165
|
if (key.rightArrow) {
|
|
166
|
+
if (cursor && actions.onBoundaryArrow && isAtRightBoundary(buffer, cursor)) {
|
|
167
|
+
actions.onBoundaryArrow('right');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
52
170
|
actions.moveCursor('right');
|
|
53
171
|
return;
|
|
54
172
|
}
|
|
@@ -62,6 +62,179 @@ describe('KeyHandler', () => {
|
|
|
62
62
|
expect(actions.moveCursor).toHaveBeenCalledWith('lineEnd');
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
|
+
describe('Boundary Arrow', () => {
|
|
66
|
+
describe('Left boundary', () => {
|
|
67
|
+
it('calls onBoundaryArrow("left") when cursor is at position 0', () => {
|
|
68
|
+
buffer = { lines: ['hello'] };
|
|
69
|
+
const cursor = { line: 0, column: 0 };
|
|
70
|
+
const onBoundaryArrow = vi.fn();
|
|
71
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
72
|
+
handleKey({ leftArrow: true }, '', buffer, actions, cursor);
|
|
73
|
+
expect(onBoundaryArrow).toHaveBeenCalledWith('left');
|
|
74
|
+
expect(actions.moveCursor).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
it('does not call onBoundaryArrow when cursor can move left', () => {
|
|
77
|
+
buffer = { lines: ['hello'] };
|
|
78
|
+
const cursor = { line: 0, column: 3 };
|
|
79
|
+
const onBoundaryArrow = vi.fn();
|
|
80
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
81
|
+
handleKey({ leftArrow: true }, '', buffer, actions, cursor);
|
|
82
|
+
expect(onBoundaryArrow).not.toHaveBeenCalled();
|
|
83
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('left');
|
|
84
|
+
});
|
|
85
|
+
it('does not call onBoundaryArrow when at start of line but previous line exists', () => {
|
|
86
|
+
buffer = { lines: ['first', 'second'] };
|
|
87
|
+
const cursor = { line: 1, column: 0 };
|
|
88
|
+
const onBoundaryArrow = vi.fn();
|
|
89
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
90
|
+
handleKey({ leftArrow: true }, '', buffer, actions, cursor);
|
|
91
|
+
expect(onBoundaryArrow).not.toHaveBeenCalled();
|
|
92
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('left');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('Right boundary', () => {
|
|
96
|
+
it('calls onBoundaryArrow("right") when cursor is at end of text', () => {
|
|
97
|
+
buffer = { lines: ['hello'] };
|
|
98
|
+
const cursor = { line: 0, column: 5 };
|
|
99
|
+
const onBoundaryArrow = vi.fn();
|
|
100
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
101
|
+
handleKey({ rightArrow: true }, '', buffer, actions, cursor);
|
|
102
|
+
expect(onBoundaryArrow).toHaveBeenCalledWith('right');
|
|
103
|
+
expect(actions.moveCursor).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
it('does not call onBoundaryArrow when cursor can move right', () => {
|
|
106
|
+
buffer = { lines: ['hello'] };
|
|
107
|
+
const cursor = { line: 0, column: 2 };
|
|
108
|
+
const onBoundaryArrow = vi.fn();
|
|
109
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
110
|
+
handleKey({ rightArrow: true }, '', buffer, actions, cursor);
|
|
111
|
+
expect(onBoundaryArrow).not.toHaveBeenCalled();
|
|
112
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('right');
|
|
113
|
+
});
|
|
114
|
+
it('does not call onBoundaryArrow when at end of line but next line exists', () => {
|
|
115
|
+
buffer = { lines: ['first', 'second'] };
|
|
116
|
+
const cursor = { line: 0, column: 5 };
|
|
117
|
+
const onBoundaryArrow = vi.fn();
|
|
118
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
119
|
+
handleKey({ rightArrow: true }, '', buffer, actions, cursor);
|
|
120
|
+
expect(onBoundaryArrow).not.toHaveBeenCalled();
|
|
121
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('right');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
describe('Up boundary', () => {
|
|
125
|
+
it('calls onBoundaryArrow("up") when cursor is on first line', () => {
|
|
126
|
+
buffer = { lines: ['hello'] };
|
|
127
|
+
const cursor = { line: 0, column: 2 };
|
|
128
|
+
const onBoundaryArrow = vi.fn();
|
|
129
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
130
|
+
handleKey({ upArrow: true }, '', buffer, actions, cursor);
|
|
131
|
+
expect(onBoundaryArrow).toHaveBeenCalledWith('up');
|
|
132
|
+
expect(actions.moveCursor).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
it('does not call onBoundaryArrow when previous line exists', () => {
|
|
135
|
+
buffer = { lines: ['first', 'second'] };
|
|
136
|
+
const cursor = { line: 1, column: 2 };
|
|
137
|
+
const onBoundaryArrow = vi.fn();
|
|
138
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
139
|
+
handleKey({ upArrow: true }, '', buffer, actions, cursor);
|
|
140
|
+
expect(onBoundaryArrow).not.toHaveBeenCalled();
|
|
141
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('up');
|
|
142
|
+
});
|
|
143
|
+
it('considers visual rows when width is provided - cursor on first visual row', () => {
|
|
144
|
+
// "hello world" with width 5 wraps to:
|
|
145
|
+
// "hello" (visual row 0)
|
|
146
|
+
// " worl" (visual row 1)
|
|
147
|
+
// "d" (visual row 2)
|
|
148
|
+
buffer = { lines: ['hello world'] };
|
|
149
|
+
const cursor = { line: 0, column: 2 }; // On first visual row
|
|
150
|
+
const onBoundaryArrow = vi.fn();
|
|
151
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
152
|
+
handleKey({ upArrow: true }, '', buffer, actions, cursor, undefined, 5);
|
|
153
|
+
expect(onBoundaryArrow).toHaveBeenCalledWith('up');
|
|
154
|
+
expect(actions.moveCursor).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
it('considers visual rows when width is provided - cursor on second visual row', () => {
|
|
157
|
+
// "hello world" with width 5 wraps to visual rows
|
|
158
|
+
buffer = { lines: ['hello world'] };
|
|
159
|
+
const cursor = { line: 0, column: 7 }; // On second visual row (after first wrap)
|
|
160
|
+
const onBoundaryArrow = vi.fn();
|
|
161
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
162
|
+
handleKey({ upArrow: true }, '', buffer, actions, cursor, undefined, 5);
|
|
163
|
+
// Can move up within the same buffer line (to previous visual row)
|
|
164
|
+
expect(onBoundaryArrow).not.toHaveBeenCalled();
|
|
165
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('up');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe('Down boundary', () => {
|
|
169
|
+
it('calls onBoundaryArrow("down") when cursor is on last line', () => {
|
|
170
|
+
buffer = { lines: ['hello'] };
|
|
171
|
+
const cursor = { line: 0, column: 2 };
|
|
172
|
+
const onBoundaryArrow = vi.fn();
|
|
173
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
174
|
+
handleKey({ downArrow: true }, '', buffer, actions, cursor);
|
|
175
|
+
expect(onBoundaryArrow).toHaveBeenCalledWith('down');
|
|
176
|
+
expect(actions.moveCursor).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
it('does not call onBoundaryArrow when next line exists', () => {
|
|
179
|
+
buffer = { lines: ['first', 'second'] };
|
|
180
|
+
const cursor = { line: 0, column: 2 };
|
|
181
|
+
const onBoundaryArrow = vi.fn();
|
|
182
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
183
|
+
handleKey({ downArrow: true }, '', buffer, actions, cursor);
|
|
184
|
+
expect(onBoundaryArrow).not.toHaveBeenCalled();
|
|
185
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('down');
|
|
186
|
+
});
|
|
187
|
+
it('considers visual rows when width is provided - cursor on last visual row', () => {
|
|
188
|
+
// "hello world" with width 5 wraps to visual rows
|
|
189
|
+
buffer = { lines: ['hello world'] };
|
|
190
|
+
const cursor = { line: 0, column: 10 }; // On last visual row (at 'd')
|
|
191
|
+
const onBoundaryArrow = vi.fn();
|
|
192
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
193
|
+
handleKey({ downArrow: true }, '', buffer, actions, cursor, undefined, 5);
|
|
194
|
+
expect(onBoundaryArrow).toHaveBeenCalledWith('down');
|
|
195
|
+
expect(actions.moveCursor).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
it('considers visual rows when width is provided - cursor on first visual row', () => {
|
|
198
|
+
// "hello world" with width 5 wraps to visual rows
|
|
199
|
+
buffer = { lines: ['hello world'] };
|
|
200
|
+
const cursor = { line: 0, column: 2 }; // On first visual row
|
|
201
|
+
const onBoundaryArrow = vi.fn();
|
|
202
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
203
|
+
handleKey({ downArrow: true }, '', buffer, actions, cursor, undefined, 5);
|
|
204
|
+
// Can move down within the same buffer line (to next visual row)
|
|
205
|
+
expect(onBoundaryArrow).not.toHaveBeenCalled();
|
|
206
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('down');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
describe('No callback provided', () => {
|
|
210
|
+
it('moves cursor normally when onBoundaryArrow is not set', () => {
|
|
211
|
+
buffer = { lines: ['hello'] };
|
|
212
|
+
const cursor = { line: 0, column: 0 };
|
|
213
|
+
// actions.onBoundaryArrow is not set
|
|
214
|
+
handleKey({ leftArrow: true }, '', buffer, actions, cursor);
|
|
215
|
+
expect(actions.moveCursor).toHaveBeenCalledWith('left');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
describe('Empty buffer', () => {
|
|
219
|
+
it('calls onBoundaryArrow for all directions in empty buffer', () => {
|
|
220
|
+
buffer = { lines: [''] };
|
|
221
|
+
const cursor = { line: 0, column: 0 };
|
|
222
|
+
const onBoundaryArrow = vi.fn();
|
|
223
|
+
actions.onBoundaryArrow = onBoundaryArrow;
|
|
224
|
+
handleKey({ upArrow: true }, '', buffer, actions, cursor);
|
|
225
|
+
expect(onBoundaryArrow).toHaveBeenCalledWith('up');
|
|
226
|
+
onBoundaryArrow.mockClear();
|
|
227
|
+
handleKey({ downArrow: true }, '', buffer, actions, cursor);
|
|
228
|
+
expect(onBoundaryArrow).toHaveBeenCalledWith('down');
|
|
229
|
+
onBoundaryArrow.mockClear();
|
|
230
|
+
handleKey({ leftArrow: true }, '', buffer, actions, cursor);
|
|
231
|
+
expect(onBoundaryArrow).toHaveBeenCalledWith('left');
|
|
232
|
+
onBoundaryArrow.mockClear();
|
|
233
|
+
handleKey({ rightArrow: true }, '', buffer, actions, cursor);
|
|
234
|
+
expect(onBoundaryArrow).toHaveBeenCalledWith('right');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
65
238
|
describe('Editing', () => {
|
|
66
239
|
it('handles Backspace', () => {
|
|
67
240
|
handleKey({ backspace: true }, 'backspace', buffer, actions);
|
|
@@ -1,37 +1,110 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
export interface MultilineInputProps {
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Controlled text value. When provided, the component becomes controlled
|
|
5
|
+
* and the value is managed externally.
|
|
6
|
+
*/
|
|
4
7
|
value?: string;
|
|
5
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Called when the text content changes due to user input. Receives the
|
|
10
|
+
* new text value as a parameter.
|
|
11
|
+
*/
|
|
6
12
|
onChange?: (value: string) => void;
|
|
7
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Called when the user submits the input (typically by pressing Enter
|
|
15
|
+
* without a backslash at the end). Receives the final text value.
|
|
16
|
+
*/
|
|
8
17
|
onSubmit?: (value: string) => void;
|
|
9
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Placeholder text displayed when the input is empty and the cursor
|
|
20
|
+
* is not shown.
|
|
21
|
+
*/
|
|
10
22
|
placeholder?: string;
|
|
11
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Whether to display the cursor. Defaults to true.
|
|
25
|
+
*/
|
|
12
26
|
showCursor?: boolean;
|
|
13
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Terminal width for word wrapping. If not provided, uses the terminal's
|
|
29
|
+
* current width with resize support.
|
|
30
|
+
*/
|
|
14
31
|
width?: number;
|
|
15
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Whether the input is active and focused, allowing keyboard input.
|
|
34
|
+
* Defaults to true.
|
|
35
|
+
*/
|
|
16
36
|
isActive?: boolean;
|
|
17
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Called whenever the cursor position changes. Receives the flat
|
|
39
|
+
* character offset as a parameter.
|
|
40
|
+
*/
|
|
18
41
|
onCursorChange?: (offset: number) => void;
|
|
19
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Optional external cursor position override. When set, forces the
|
|
44
|
+
* cursor to the specified flat character offset.
|
|
45
|
+
*/
|
|
20
46
|
cursorOverride?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Called when an arrow key is pressed but cursor is at a boundary.
|
|
49
|
+
* - 'up': cursor is on the first/topmost line
|
|
50
|
+
* - 'down': cursor is on the last/bottommost line
|
|
51
|
+
* - 'left': cursor is at position 0 (start of text)
|
|
52
|
+
* - 'right': cursor is at end of text (after last character)
|
|
53
|
+
*/
|
|
54
|
+
onBoundaryArrow?: (direction: 'up' | 'down' | 'left' | 'right') => void;
|
|
21
55
|
}
|
|
22
56
|
/**
|
|
23
57
|
* Props for the core component (without Ink-specific hooks)
|
|
24
58
|
* This allows testing the rendering logic separately.
|
|
25
59
|
*/
|
|
26
60
|
export interface MultilineInputCoreProps {
|
|
61
|
+
/**
|
|
62
|
+
* Controlled text value. When provided, the component becomes controlled
|
|
63
|
+
* and the text is managed externally.
|
|
64
|
+
*/
|
|
27
65
|
value?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Called when the text content changes. Receives the new text value
|
|
68
|
+
* as a parameter.
|
|
69
|
+
*/
|
|
28
70
|
onChange?: (value: string) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Called when the user submits the input (typically by pressing Enter
|
|
73
|
+
* without a backslash at the end).
|
|
74
|
+
*/
|
|
29
75
|
onSubmit?: (value: string) => void;
|
|
76
|
+
/**
|
|
77
|
+
* Placeholder text displayed when the input is empty and the cursor
|
|
78
|
+
* is not shown.
|
|
79
|
+
*/
|
|
30
80
|
placeholder?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Whether to display the cursor. Defaults to true.
|
|
83
|
+
*/
|
|
31
84
|
showCursor?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Terminal width for word wrapping. If not provided, uses the terminal's
|
|
87
|
+
* current width.
|
|
88
|
+
*/
|
|
32
89
|
width?: number;
|
|
90
|
+
/**
|
|
91
|
+
* Called whenever the cursor position changes. Receives the flat
|
|
92
|
+
* character offset as a parameter.
|
|
93
|
+
*/
|
|
33
94
|
onCursorChange?: (offset: number) => void;
|
|
95
|
+
/**
|
|
96
|
+
* Optional external cursor position override. When set, forces the
|
|
97
|
+
* cursor to the specified flat character offset.
|
|
98
|
+
*/
|
|
34
99
|
cursorOverride?: number;
|
|
100
|
+
/**
|
|
101
|
+
* Called when an arrow key is pressed but cursor is at a boundary.
|
|
102
|
+
* - 'up': cursor is on the first/topmost line
|
|
103
|
+
* - 'down': cursor is on the last/bottommost line
|
|
104
|
+
* - 'left': cursor is at position 0 (start of text)
|
|
105
|
+
* - 'right': cursor is at end of text (after last character)
|
|
106
|
+
*/
|
|
107
|
+
onBoundaryArrow?: (direction: 'up' | 'down' | 'left' | 'right') => void;
|
|
35
108
|
}
|
|
36
109
|
/**
|
|
37
110
|
* Core rendering component that can be tested without Ink runtime.
|
|
@@ -66,7 +66,7 @@ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor =
|
|
|
66
66
|
* Full MultilineInput with Ink keyboard handling.
|
|
67
67
|
* This component uses Ink-specific hooks and must be rendered in an Ink context.
|
|
68
68
|
*/
|
|
69
|
-
export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCursor = true, width, isActive = true, onCursorChange, cursorOverride, }) => {
|
|
69
|
+
export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCursor = true, width, isActive = true, onCursorChange, cursorOverride, onBoundaryArrow, }) => {
|
|
70
70
|
// Get terminal width from Ink (with resize support) if not provided
|
|
71
71
|
const terminalWidth = useTerminalWidth(width);
|
|
72
72
|
// Track raw input for detecting Home/End keys
|
|
@@ -140,11 +140,12 @@ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCur
|
|
|
140
140
|
redo: textInput.redo,
|
|
141
141
|
setText: textInput.setText,
|
|
142
142
|
submit: handleSubmit,
|
|
143
|
+
onBoundaryArrow,
|
|
143
144
|
};
|
|
144
145
|
// Handle keyboard input
|
|
145
146
|
useInput((input, key) => {
|
|
146
147
|
log(`[USEINPUT] input="${input.replace(/[\x00-\x1F\x7F-\uFFFF]/g, c => `\\x${c.charCodeAt(0).toString(16)}`)}" key=${JSON.stringify(key)} rawLen=${lastRawInput.current?.length || 0}`);
|
|
147
|
-
handleKey(key, input, buffer, actions, textInput.cursor, lastRawInput.current);
|
|
148
|
+
handleKey(key, input, buffer, actions, textInput.cursor, lastRawInput.current, terminalWidth);
|
|
148
149
|
}, { isActive });
|
|
149
150
|
// Show placeholder if empty and no cursor shown
|
|
150
151
|
const isEmpty = textInput.value === '';
|
|
@@ -18,6 +18,10 @@ export interface Buffer {
|
|
|
18
18
|
* Cursor movement directions
|
|
19
19
|
*/
|
|
20
20
|
export type Direction = 'up' | 'down' | 'left' | 'right' | 'lineStart' | 'lineEnd';
|
|
21
|
+
/**
|
|
22
|
+
* Boundary arrow directions (subset of Direction used for boundary detection)
|
|
23
|
+
*/
|
|
24
|
+
export type BoundaryDirection = 'up' | 'down' | 'left' | 'right';
|
|
21
25
|
/**
|
|
22
26
|
* Result of wrapping buffer lines for visual display
|
|
23
27
|
*/
|
package/dist/index.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { type Key, type Buffer, type Cursor } from './types';
|
|
2
|
-
import { type UseTextInputResult } from './useTextInput';
|
|
3
|
-
export interface KeyHandlerActions extends Omit<UseTextInputResult, 'value' | 'cursor'> {
|
|
4
|
-
submit: () => void;
|
|
5
|
-
}
|
|
6
|
-
/**
|
|
7
|
-
* Handles keyboard input and maps it to text input actions.
|
|
8
|
-
*
|
|
9
|
-
* @param key - The Ink key object
|
|
10
|
-
* @param input - The input string (if any)
|
|
11
|
-
* @param buffer - The current text buffer
|
|
12
|
-
* @param actions - The actions available to modify the state
|
|
13
|
-
* @param cursor - The current cursor position (optional, but required for some logic like backslash check)
|
|
14
|
-
*/
|
|
15
|
-
export declare function handleKey(key: Partial<Key>, input: string, buffer: Buffer, actions: KeyHandlerActions, cursor?: Cursor): void;
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Handles keyboard input and maps it to text input actions.
|
|
3
|
-
*
|
|
4
|
-
* @param key - The Ink key object
|
|
5
|
-
* @param input - The input string (if any)
|
|
6
|
-
* @param buffer - The current text buffer
|
|
7
|
-
* @param actions - The actions available to modify the state
|
|
8
|
-
* @param cursor - The current cursor position (optional, but required for some logic like backslash check)
|
|
9
|
-
*/
|
|
10
|
-
export function handleKey(key, input, buffer, actions, cursor) {
|
|
11
|
-
// Navigation
|
|
12
|
-
if (key.upArrow) {
|
|
13
|
-
actions.moveCursor('up');
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
if (key.downArrow) {
|
|
17
|
-
actions.moveCursor('down');
|
|
18
|
-
return;
|
|
19
|
-
}
|
|
20
|
-
if (key.leftArrow) {
|
|
21
|
-
actions.moveCursor('left');
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
if (key.rightArrow) {
|
|
25
|
-
actions.moveCursor('right');
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
// Home/End (Ink might not provide these directly in all environments, but if it does)
|
|
29
|
-
// We check for 'home' and 'end' properties if they exist on the key object,
|
|
30
|
-
// or specific sequences if we were parsing raw input, but here we assume Ink's Key object.
|
|
31
|
-
// Note: Ink's Key interface might not have home/end in all versions, but we'll assume it does or we extend it.
|
|
32
|
-
// If not, we might need to check specific input sequences, but for now let's trust the test/types.
|
|
33
|
-
if (key.home) {
|
|
34
|
-
actions.moveCursor('lineStart');
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
if (key.end) {
|
|
38
|
-
actions.moveCursor('lineEnd');
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
// History
|
|
42
|
-
if (key.ctrl) {
|
|
43
|
-
if (input === 'z') {
|
|
44
|
-
actions.undo();
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
if (input === 'y') {
|
|
48
|
-
actions.redo();
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
if (input === 'j') {
|
|
52
|
-
actions.newLine();
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
// Editing
|
|
57
|
-
if (key.backspace) {
|
|
58
|
-
actions.delete();
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
if (key.delete) {
|
|
62
|
-
// Currently mapped to delete (backspace behavior) as per requirements/tests,
|
|
63
|
-
// but usually delete is forward.
|
|
64
|
-
// The plan said "Delete (delete at cursor)", which usually means forward.
|
|
65
|
-
// But our useTextInput only has `delete` (which is backspace).
|
|
66
|
-
// For now, we map it to `delete` as per the test "handles Delete".
|
|
67
|
-
actions.delete();
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
// Submission / New Line
|
|
71
|
-
if (key.return) {
|
|
72
|
-
if (cursor) {
|
|
73
|
-
const currentLine = buffer.lines[cursor.line];
|
|
74
|
-
// Check if line ends with backslash AND cursor is at the end (or we just check the line content?)
|
|
75
|
-
// Requirement: "Line ending with \ + Enter continues to next line"
|
|
76
|
-
// Usually this implies the user typed '\' then Enter.
|
|
77
|
-
// We should probably check if the character *before* the cursor is '\' if we want to be precise,
|
|
78
|
-
// or just if the line ends with '\'.
|
|
79
|
-
// Let's assume "line ends with \" means the last char of the line is '\'.
|
|
80
|
-
if (currentLine.endsWith('\\')) {
|
|
81
|
-
actions.delete(); // Remove the backslash
|
|
82
|
-
actions.newLine(); // Insert newline
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
actions.submit();
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
// Text Insertion
|
|
90
|
-
// Ignore control keys if they don't have a specific handler above
|
|
91
|
-
if (key.ctrl || key.meta) {
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
if (input) {
|
|
95
|
-
actions.insert(input);
|
|
96
|
-
}
|
|
97
|
-
}
|