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.
Files changed (36) hide show
  1. package/dist/components/MultilineInput/KeyHandler.d.ts +3 -1
  2. package/dist/components/MultilineInput/KeyHandler.js +120 -2
  3. package/dist/components/MultilineInput/__tests__/KeyHandler.test.js +173 -0
  4. package/dist/components/MultilineInput/index.d.ts +82 -9
  5. package/dist/components/MultilineInput/index.js +3 -2
  6. package/dist/components/MultilineInput/types.d.ts +4 -0
  7. package/dist/index.d.ts +1 -0
  8. package/package.json +1 -1
  9. package/dist/examples/examples/basic.d.ts +0 -1
  10. package/dist/examples/examples/basic.js +0 -9
  11. package/dist/examples/src/components/MultilineInput/KeyHandler.d.ts +0 -15
  12. package/dist/examples/src/components/MultilineInput/KeyHandler.js +0 -97
  13. package/dist/examples/src/components/MultilineInput/TextBuffer.d.ts +0 -34
  14. package/dist/examples/src/components/MultilineInput/TextBuffer.js +0 -127
  15. package/dist/examples/src/components/MultilineInput/TextRenderer.d.ts +0 -24
  16. package/dist/examples/src/components/MultilineInput/TextRenderer.js +0 -72
  17. package/dist/examples/src/components/MultilineInput/__tests__/KeyHandler.test.d.ts +0 -1
  18. package/dist/examples/src/components/MultilineInput/__tests__/KeyHandler.test.js +0 -115
  19. package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.d.ts +0 -1
  20. package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.js +0 -254
  21. package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.d.ts +0 -1
  22. package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.js +0 -176
  23. package/dist/examples/src/components/MultilineInput/__tests__/integration.test.d.ts +0 -1
  24. package/dist/examples/src/components/MultilineInput/__tests__/integration.test.js +0 -71
  25. package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.d.ts +0 -1
  26. package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.js +0 -65
  27. package/dist/examples/src/components/MultilineInput/index.d.ts +0 -39
  28. package/dist/examples/src/components/MultilineInput/index.js +0 -82
  29. package/dist/examples/src/components/MultilineInput/types.d.ts +0 -55
  30. package/dist/examples/src/components/MultilineInput/types.js +0 -1
  31. package/dist/examples/src/components/MultilineInput/useTextInput.d.ts +0 -16
  32. package/dist/examples/src/components/MultilineInput/useTextInput.js +0 -82
  33. package/dist/examples/src/hello.test.d.ts +0 -1
  34. package/dist/examples/src/hello.test.js +0 -13
  35. package/dist/examples/src/index.d.ts +0 -2
  36. 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
- /** Controlled text value */
3
+ /**
4
+ * Controlled text value. When provided, the component becomes controlled
5
+ * and the value is managed externally.
6
+ */
4
7
  value?: string;
5
- /** Called when text changes */
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
- /** Called when user submits (Enter without backslash) */
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
- /** Placeholder text when empty */
18
+ /**
19
+ * Placeholder text displayed when the input is empty and the cursor
20
+ * is not shown.
21
+ */
10
22
  placeholder?: string;
11
- /** Whether to show the cursor (defaults to true) */
23
+ /**
24
+ * Whether to display the cursor. Defaults to true.
25
+ */
12
26
  showCursor?: boolean;
13
- /** Terminal width for word wrapping */
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
- /** Whether input is active/focused (defaults to true) */
32
+ /**
33
+ * Whether the input is active and focused, allowing keyboard input.
34
+ * Defaults to true.
35
+ */
16
36
  isActive?: boolean;
17
- /** Called whenever the cursor offset changes (flat index) */
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
- /** Optional external cursor override (flat index) */
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
@@ -1,2 +1,3 @@
1
1
  export { MultilineInput } from './components/MultilineInput/index.js';
2
2
  export type { MultilineInputProps } from './components/MultilineInput/index.js';
3
+ export type { BoundaryDirection } from './components/MultilineInput/types.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ink-prompt",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "A React Ink component for prompts",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1 +0,0 @@
1
- export {};
@@ -1,9 +0,0 @@
1
- import React from 'react';
2
- import { render } from 'ink';
3
- import { MultilineInput } from '../src/index.js';
4
- const App = () => {
5
- return (React.createElement(MultilineInput
6
- // Add props here
7
- , null));
8
- };
9
- render(React.createElement(App, null));
@@ -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
- }