ink-prompt 0.1.2
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/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/components/MultilineInput/KeyHandler.d.ts +16 -0
- package/dist/components/MultilineInput/KeyHandler.js +124 -0
- package/dist/components/MultilineInput/TextBuffer.d.ts +52 -0
- package/dist/components/MultilineInput/TextBuffer.js +312 -0
- package/dist/components/MultilineInput/TextRenderer.d.ts +24 -0
- package/dist/components/MultilineInput/TextRenderer.js +80 -0
- package/dist/components/MultilineInput/__tests__/KeyHandler.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/KeyHandler.test.js +124 -0
- package/dist/components/MultilineInput/__tests__/TextBuffer.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/TextBuffer.test.js +450 -0
- package/dist/components/MultilineInput/__tests__/TextRenderer.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/TextRenderer.test.js +185 -0
- package/dist/components/MultilineInput/__tests__/integration.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/integration.test.js +88 -0
- package/dist/components/MultilineInput/__tests__/useTextInput.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/useTextInput.test.js +172 -0
- package/dist/components/MultilineInput/index.d.ts +45 -0
- package/dist/components/MultilineInput/index.js +132 -0
- package/dist/components/MultilineInput/types.d.ts +55 -0
- package/dist/components/MultilineInput/types.js +1 -0
- package/dist/components/MultilineInput/useTextInput.d.ts +22 -0
- package/dist/components/MultilineInput/useTextInput.js +108 -0
- package/dist/hello.test.d.ts +1 -0
- package/dist/hello.test.js +13 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/utils/logger.d.ts +15 -0
- package/dist/utils/logger.js +42 -0
- package/package.json +57 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { render } from '@testing-library/react';
|
|
4
|
+
import { TextRenderer, wrapLines } from '../TextRenderer.js';
|
|
5
|
+
describe('wrapLines', () => {
|
|
6
|
+
describe('no wrapping needed', () => {
|
|
7
|
+
it('returns single line unchanged when shorter than width', () => {
|
|
8
|
+
const buffer = { lines: ['hello'] };
|
|
9
|
+
const cursor = { line: 0, column: 5 };
|
|
10
|
+
const result = wrapLines(buffer, cursor, 80);
|
|
11
|
+
expect(result.visualLines).toEqual(['hello']);
|
|
12
|
+
expect(result.cursorVisualRow).toBe(0);
|
|
13
|
+
expect(result.cursorVisualCol).toBe(5);
|
|
14
|
+
});
|
|
15
|
+
it('returns multiple lines unchanged when all shorter than width', () => {
|
|
16
|
+
const buffer = { lines: ['hello', 'world'] };
|
|
17
|
+
const cursor = { line: 1, column: 3 };
|
|
18
|
+
const result = wrapLines(buffer, cursor, 80);
|
|
19
|
+
expect(result.visualLines).toEqual(['hello', 'world']);
|
|
20
|
+
expect(result.cursorVisualRow).toBe(1);
|
|
21
|
+
expect(result.cursorVisualCol).toBe(3);
|
|
22
|
+
});
|
|
23
|
+
it('handles empty buffer', () => {
|
|
24
|
+
const buffer = { lines: [''] };
|
|
25
|
+
const cursor = { line: 0, column: 0 };
|
|
26
|
+
const result = wrapLines(buffer, cursor, 80);
|
|
27
|
+
expect(result.visualLines).toEqual(['']);
|
|
28
|
+
expect(result.cursorVisualRow).toBe(0);
|
|
29
|
+
expect(result.cursorVisualCol).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('wrapping single line', () => {
|
|
33
|
+
it('wraps line exceeding width', () => {
|
|
34
|
+
const buffer = { lines: ['abcdefghij'] };
|
|
35
|
+
const cursor = { line: 0, column: 0 };
|
|
36
|
+
const result = wrapLines(buffer, cursor, 5);
|
|
37
|
+
expect(result.visualLines).toEqual(['abcde', 'fghij']);
|
|
38
|
+
});
|
|
39
|
+
it('wraps line into multiple visual lines', () => {
|
|
40
|
+
const buffer = { lines: ['abcdefghijklmno'] };
|
|
41
|
+
const cursor = { line: 0, column: 0 };
|
|
42
|
+
const result = wrapLines(buffer, cursor, 5);
|
|
43
|
+
expect(result.visualLines).toEqual(['abcde', 'fghij', 'klmno']);
|
|
44
|
+
});
|
|
45
|
+
it('handles exact width match (no extra empty line)', () => {
|
|
46
|
+
const buffer = { lines: ['abcde'] };
|
|
47
|
+
const cursor = { line: 0, column: 5 };
|
|
48
|
+
const result = wrapLines(buffer, cursor, 5);
|
|
49
|
+
expect(result.visualLines).toEqual(['abcde']);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('cursor position in wrapped lines', () => {
|
|
53
|
+
it('cursor on first visual row', () => {
|
|
54
|
+
const buffer = { lines: ['abcdefghij'] };
|
|
55
|
+
const cursor = { line: 0, column: 3 };
|
|
56
|
+
const result = wrapLines(buffer, cursor, 5);
|
|
57
|
+
expect(result.cursorVisualRow).toBe(0);
|
|
58
|
+
expect(result.cursorVisualCol).toBe(3);
|
|
59
|
+
});
|
|
60
|
+
it('cursor on second visual row', () => {
|
|
61
|
+
const buffer = { lines: ['abcdefghij'] };
|
|
62
|
+
const cursor = { line: 0, column: 7 };
|
|
63
|
+
const result = wrapLines(buffer, cursor, 5);
|
|
64
|
+
expect(result.cursorVisualRow).toBe(1);
|
|
65
|
+
expect(result.cursorVisualCol).toBe(2);
|
|
66
|
+
});
|
|
67
|
+
it('cursor at wrap boundary (end of first visual row)', () => {
|
|
68
|
+
const buffer = { lines: ['abcdefghij'] };
|
|
69
|
+
const cursor = { line: 0, column: 5 };
|
|
70
|
+
const result = wrapLines(buffer, cursor, 5);
|
|
71
|
+
expect(result.cursorVisualRow).toBe(1);
|
|
72
|
+
expect(result.cursorVisualCol).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
it('cursor at end of wrapped line', () => {
|
|
75
|
+
const buffer = { lines: ['abcdefghij'] };
|
|
76
|
+
const cursor = { line: 0, column: 10 };
|
|
77
|
+
const result = wrapLines(buffer, cursor, 5);
|
|
78
|
+
expect(result.cursorVisualRow).toBe(1);
|
|
79
|
+
expect(result.cursorVisualCol).toBe(5);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('wrapping multiple lines', () => {
|
|
83
|
+
it('wraps only the line that exceeds width', () => {
|
|
84
|
+
const buffer = { lines: ['hi', 'abcdefghij'] };
|
|
85
|
+
const cursor = { line: 0, column: 0 };
|
|
86
|
+
const result = wrapLines(buffer, cursor, 5);
|
|
87
|
+
expect(result.visualLines).toEqual(['hi', 'abcde', 'fghij']);
|
|
88
|
+
});
|
|
89
|
+
it('cursor on second logical line that wraps', () => {
|
|
90
|
+
const buffer = { lines: ['hi', 'abcdefghij'] };
|
|
91
|
+
const cursor = { line: 1, column: 7 };
|
|
92
|
+
const result = wrapLines(buffer, cursor, 5);
|
|
93
|
+
expect(result.visualLines).toEqual(['hi', 'abcde', 'fghij']);
|
|
94
|
+
expect(result.cursorVisualRow).toBe(2);
|
|
95
|
+
expect(result.cursorVisualCol).toBe(2);
|
|
96
|
+
});
|
|
97
|
+
it('handles multiple wrapped lines', () => {
|
|
98
|
+
const buffer = { lines: ['abcdefg', 'hijklmn'] };
|
|
99
|
+
const cursor = { line: 1, column: 5 };
|
|
100
|
+
const result = wrapLines(buffer, cursor, 4);
|
|
101
|
+
expect(result.visualLines).toEqual(['abcd', 'efg', 'hijk', 'lmn']);
|
|
102
|
+
expect(result.cursorVisualRow).toBe(3);
|
|
103
|
+
expect(result.cursorVisualCol).toBe(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe('TextRenderer', () => {
|
|
108
|
+
describe('basic rendering', () => {
|
|
109
|
+
it('renders single line', () => {
|
|
110
|
+
const buffer = { lines: ['hello'] };
|
|
111
|
+
const cursor = { line: 0, column: 0 };
|
|
112
|
+
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor }));
|
|
113
|
+
expect(container.textContent).toContain('hello');
|
|
114
|
+
});
|
|
115
|
+
it('renders multiple lines', () => {
|
|
116
|
+
const buffer = { lines: ['hello', 'world'] };
|
|
117
|
+
const cursor = { line: 0, column: 0 };
|
|
118
|
+
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor }));
|
|
119
|
+
expect(container.textContent).toContain('hello');
|
|
120
|
+
expect(container.textContent).toContain('world');
|
|
121
|
+
});
|
|
122
|
+
it('renders empty buffer', () => {
|
|
123
|
+
const buffer = { lines: [''] };
|
|
124
|
+
const cursor = { line: 0, column: 0 };
|
|
125
|
+
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor }));
|
|
126
|
+
// Should render cursor as a space in empty buffer
|
|
127
|
+
expect(container.textContent).toContain(' ');
|
|
128
|
+
});
|
|
129
|
+
it('renders empty lines between content', () => {
|
|
130
|
+
const buffer = { lines: ['first line', '', 'second line'] };
|
|
131
|
+
const cursor = { line: 0, column: 0 };
|
|
132
|
+
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, showCursor: false }));
|
|
133
|
+
// Should preserve empty line structure
|
|
134
|
+
expect(container.textContent).toBe('first line second line');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
describe('cursor display', () => {
|
|
138
|
+
it('shows cursor at start of line', () => {
|
|
139
|
+
const buffer = { lines: ['hello'] };
|
|
140
|
+
const cursor = { line: 0, column: 0 };
|
|
141
|
+
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, showCursor: true }));
|
|
142
|
+
// Cursor highlights the first character 'h'
|
|
143
|
+
expect(container.textContent).toBe('hello');
|
|
144
|
+
});
|
|
145
|
+
it('shows cursor at end of line', () => {
|
|
146
|
+
const buffer = { lines: ['hello'] };
|
|
147
|
+
const cursor = { line: 0, column: 5 };
|
|
148
|
+
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, showCursor: true }));
|
|
149
|
+
// Cursor at end shows a space after 'hello'
|
|
150
|
+
expect(container.textContent).toBe('hello ');
|
|
151
|
+
});
|
|
152
|
+
it('shows cursor in middle of line', () => {
|
|
153
|
+
const buffer = { lines: ['hello'] };
|
|
154
|
+
const cursor = { line: 0, column: 2 };
|
|
155
|
+
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, showCursor: true }));
|
|
156
|
+
// Cursor highlights the character at position 2 ('l')
|
|
157
|
+
expect(container.textContent).toBe('hello');
|
|
158
|
+
});
|
|
159
|
+
it('hides cursor when showCursor is false', () => {
|
|
160
|
+
const buffer = { lines: ['hello'] };
|
|
161
|
+
const cursor = { line: 0, column: 0 };
|
|
162
|
+
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, showCursor: false }));
|
|
163
|
+
expect(container.textContent).toBe('hello');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe('word wrapping in render', () => {
|
|
167
|
+
it('renders wrapped lines', () => {
|
|
168
|
+
const buffer = { lines: ['abcdefghij'] };
|
|
169
|
+
const cursor = { line: 0, column: 0 };
|
|
170
|
+
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, width: 5 }));
|
|
171
|
+
// Both visual lines should be present
|
|
172
|
+
expect(container.textContent).toContain('abcde');
|
|
173
|
+
expect(container.textContent).toContain('fghij');
|
|
174
|
+
});
|
|
175
|
+
it('shows cursor correctly on wrapped line', () => {
|
|
176
|
+
const buffer = { lines: ['abcdefghij'] };
|
|
177
|
+
const cursor = { line: 0, column: 7 };
|
|
178
|
+
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, width: 5, showCursor: true }));
|
|
179
|
+
// Cursor at position 7 wraps to second visual row, column 2
|
|
180
|
+
// The cursor highlights character 'h' at that position
|
|
181
|
+
expect(container.textContent).toContain('abcde');
|
|
182
|
+
expect(container.textContent).toContain('fghij');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { render } from '@testing-library/react';
|
|
4
|
+
import { MultilineInputCore } from '../index.js';
|
|
5
|
+
/**
|
|
6
|
+
* Integration tests for MultilineInputCore.
|
|
7
|
+
*
|
|
8
|
+
* These tests validate the component's rendering and props behavior.
|
|
9
|
+
* We test MultilineInputCore instead of MultilineInput to avoid requiring
|
|
10
|
+
* the Ink runtime (useInput, useStdout hooks).
|
|
11
|
+
*
|
|
12
|
+
* Keyboard handling is thoroughly tested via KeyHandler.test.ts.
|
|
13
|
+
* State management is tested via useTextInput.test.ts.
|
|
14
|
+
*/
|
|
15
|
+
describe('MultilineInputCore', () => {
|
|
16
|
+
describe('Submission', () => {
|
|
17
|
+
it('clears input after submit', () => {
|
|
18
|
+
const onSubmit = vi.fn();
|
|
19
|
+
const onChange = vi.fn();
|
|
20
|
+
// Simulate controlled usage: value is managed by parent
|
|
21
|
+
let value = 'hello world';
|
|
22
|
+
const { rerender, container } = render(_jsx(MultilineInputCore, { value: value, onSubmit: onSubmit, onChange: onChange }));
|
|
23
|
+
// Simulate submit: parent receives value, then clears
|
|
24
|
+
onSubmit(value);
|
|
25
|
+
value = '';
|
|
26
|
+
rerender(_jsx(MultilineInputCore, { value: value, onSubmit: onSubmit, onChange: onChange }));
|
|
27
|
+
// After rerender, input should be empty
|
|
28
|
+
expect(container.textContent).toContain(' '); // Cursor in empty buffer
|
|
29
|
+
expect(onChange).toHaveBeenCalledWith('');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('Rendering', () => {
|
|
33
|
+
it('renders empty input with cursor', () => {
|
|
34
|
+
const { container } = render(_jsx(MultilineInputCore, {}));
|
|
35
|
+
// Cursor shows as a space in empty buffer
|
|
36
|
+
expect(container.textContent).toContain(' ');
|
|
37
|
+
});
|
|
38
|
+
it('renders with initial value', () => {
|
|
39
|
+
const { container } = render(_jsx(MultilineInputCore, { value: "hello" }));
|
|
40
|
+
expect(container.textContent).toContain('hello');
|
|
41
|
+
});
|
|
42
|
+
it('renders multiline value', () => {
|
|
43
|
+
const { container } = render(_jsx(MultilineInputCore, { value: "line1\\nline2" }));
|
|
44
|
+
expect(container.textContent).toContain('line1');
|
|
45
|
+
expect(container.textContent).toContain('line2');
|
|
46
|
+
});
|
|
47
|
+
it('shows cursor at end of value', () => {
|
|
48
|
+
const { container } = render(_jsx(MultilineInputCore, { value: "hi" }));
|
|
49
|
+
// Cursor at end shows as a space after the text
|
|
50
|
+
expect(container.textContent).toBe('hi ');
|
|
51
|
+
});
|
|
52
|
+
it('hides cursor when showCursor is false', () => {
|
|
53
|
+
const { container } = render(_jsx(MultilineInputCore, { value: "hello", showCursor: false }));
|
|
54
|
+
expect(container.textContent).toBe('hello');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('Props', () => {
|
|
58
|
+
it('accepts width prop for word wrapping', () => {
|
|
59
|
+
const { container } = render(_jsx(MultilineInputCore, { value: "abcdefghij", width: 5 }));
|
|
60
|
+
// Should wrap at width 5
|
|
61
|
+
expect(container.textContent).toContain('abcde');
|
|
62
|
+
expect(container.textContent).toContain('fghij');
|
|
63
|
+
});
|
|
64
|
+
it('calls onChange on initial render with value', () => {
|
|
65
|
+
const onChange = vi.fn();
|
|
66
|
+
render(_jsx(MultilineInputCore, { value: "test", onChange: onChange }));
|
|
67
|
+
// onChange is called with the initial value
|
|
68
|
+
expect(onChange).toHaveBeenCalledWith('test');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('Placeholder', () => {
|
|
72
|
+
it('shows placeholder when empty and cursor hidden', () => {
|
|
73
|
+
const { container } = render(_jsx(MultilineInputCore, { placeholder: "Type here...", showCursor: false }));
|
|
74
|
+
expect(container.textContent).toContain('Type here...');
|
|
75
|
+
});
|
|
76
|
+
it('does not show placeholder when showCursor is true', () => {
|
|
77
|
+
const { container } = render(_jsx(MultilineInputCore, { placeholder: "Type here...", showCursor: true }));
|
|
78
|
+
// Shows cursor (as space) instead, not placeholder
|
|
79
|
+
expect(container.textContent).toContain(' ');
|
|
80
|
+
expect(container.textContent).not.toContain('Type here...');
|
|
81
|
+
});
|
|
82
|
+
it('does not show placeholder when has value', () => {
|
|
83
|
+
const { container } = render(_jsx(MultilineInputCore, { value: "hello", placeholder: "Type here...", showCursor: false }));
|
|
84
|
+
expect(container.textContent).toContain('hello');
|
|
85
|
+
expect(container.textContent).not.toContain('Type here...');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { useTextInput } from '../useTextInput.js';
|
|
4
|
+
describe('useTextInput', () => {
|
|
5
|
+
it('should initialize with empty buffer', () => {
|
|
6
|
+
const { result } = renderHook(() => useTextInput());
|
|
7
|
+
expect(result.current.value).toBe('');
|
|
8
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 0 });
|
|
9
|
+
});
|
|
10
|
+
it('should initialize with initial value', () => {
|
|
11
|
+
const { result } = renderHook(() => useTextInput({ initialValue: 'Hello\nWorld' }));
|
|
12
|
+
expect(result.current.value).toBe('Hello\nWorld');
|
|
13
|
+
expect(result.current.cursor).toEqual({ line: 1, column: 5 }); // Cursor at end
|
|
14
|
+
});
|
|
15
|
+
it('should insert character', () => {
|
|
16
|
+
const { result } = renderHook(() => useTextInput());
|
|
17
|
+
act(() => {
|
|
18
|
+
result.current.insert('a');
|
|
19
|
+
});
|
|
20
|
+
expect(result.current.value).toBe('a');
|
|
21
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 1 });
|
|
22
|
+
});
|
|
23
|
+
it('should delete character', () => {
|
|
24
|
+
const { result } = renderHook(() => useTextInput({ initialValue: 'abc' }));
|
|
25
|
+
act(() => {
|
|
26
|
+
result.current.delete();
|
|
27
|
+
});
|
|
28
|
+
expect(result.current.value).toBe('ab');
|
|
29
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 2 });
|
|
30
|
+
});
|
|
31
|
+
it('should insert new line', () => {
|
|
32
|
+
const { result } = renderHook(() => useTextInput({ initialValue: 'abc' }));
|
|
33
|
+
// Move cursor to middle
|
|
34
|
+
act(() => {
|
|
35
|
+
result.current.moveCursor('left');
|
|
36
|
+
});
|
|
37
|
+
act(() => {
|
|
38
|
+
result.current.newLine();
|
|
39
|
+
});
|
|
40
|
+
expect(result.current.value).toBe('ab\nc');
|
|
41
|
+
expect(result.current.cursor).toEqual({ line: 1, column: 0 });
|
|
42
|
+
});
|
|
43
|
+
it('should move cursor', () => {
|
|
44
|
+
const { result } = renderHook(() => useTextInput({ initialValue: 'abc' }));
|
|
45
|
+
act(() => {
|
|
46
|
+
result.current.moveCursor('left');
|
|
47
|
+
});
|
|
48
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 2 });
|
|
49
|
+
});
|
|
50
|
+
it('should support undo/redo', () => {
|
|
51
|
+
const { result } = renderHook(() => useTextInput());
|
|
52
|
+
act(() => {
|
|
53
|
+
result.current.insert('a');
|
|
54
|
+
});
|
|
55
|
+
expect(result.current.value).toBe('a');
|
|
56
|
+
act(() => {
|
|
57
|
+
result.current.undo();
|
|
58
|
+
});
|
|
59
|
+
expect(result.current.value).toBe('');
|
|
60
|
+
act(() => {
|
|
61
|
+
result.current.redo();
|
|
62
|
+
});
|
|
63
|
+
expect(result.current.value).toBe('a');
|
|
64
|
+
});
|
|
65
|
+
it('should remove backslash when deleted at end of line', () => {
|
|
66
|
+
const { result } = renderHook(() => useTextInput({ initialValue: 'hello\\' }));
|
|
67
|
+
// Cursor should be at end: { line: 0, column: 6 } (after the backslash)
|
|
68
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 6 });
|
|
69
|
+
expect(result.current.value).toBe('hello\\');
|
|
70
|
+
// Delete the backslash
|
|
71
|
+
act(() => {
|
|
72
|
+
result.current.delete();
|
|
73
|
+
});
|
|
74
|
+
// The backslash should be removed
|
|
75
|
+
expect(result.current.value).toBe('hello');
|
|
76
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 5 });
|
|
77
|
+
// Then add newline
|
|
78
|
+
act(() => {
|
|
79
|
+
result.current.newLine();
|
|
80
|
+
});
|
|
81
|
+
// Result should be 'hello' on first line, empty second line
|
|
82
|
+
expect(result.current.value).toBe('hello\n');
|
|
83
|
+
expect(result.current.cursor).toEqual({ line: 1, column: 0 });
|
|
84
|
+
});
|
|
85
|
+
it('should fail when delete and newLine are called separately (demonstrates the bug)', () => {
|
|
86
|
+
const { result } = renderHook(() => useTextInput({ initialValue: 'hello\\' }));
|
|
87
|
+
// Cursor should be at end: { line: 0, column: 6 } (after the backslash)
|
|
88
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 6 });
|
|
89
|
+
expect(result.current.value).toBe('hello\\');
|
|
90
|
+
// Call delete and newLine in the same synchronous execution (as old KeyHandler did)
|
|
91
|
+
act(() => {
|
|
92
|
+
result.current.delete();
|
|
93
|
+
result.current.newLine(); // This will use the OLD state!
|
|
94
|
+
});
|
|
95
|
+
// BUG: The backslash is NOT removed because newLine uses old state
|
|
96
|
+
// This demonstrates why we need deleteAndNewLine
|
|
97
|
+
expect(result.current.value).toBe('hello\\\n');
|
|
98
|
+
expect(result.current.cursor).toEqual({ line: 1, column: 0 });
|
|
99
|
+
});
|
|
100
|
+
it('should correctly remove backslash using deleteAndNewLine action', () => {
|
|
101
|
+
const { result } = renderHook(() => useTextInput({ initialValue: 'hello\\' }));
|
|
102
|
+
// Cursor should be at end: { line: 0, column: 6 } (after the backslash)
|
|
103
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 6 });
|
|
104
|
+
expect(result.current.value).toBe('hello\\');
|
|
105
|
+
// Use the combined action
|
|
106
|
+
act(() => {
|
|
107
|
+
result.current.deleteAndNewLine();
|
|
108
|
+
});
|
|
109
|
+
// The backslash should be removed and newline added
|
|
110
|
+
expect(result.current.value).toBe('hello\n');
|
|
111
|
+
expect(result.current.cursor).toEqual({ line: 1, column: 0 });
|
|
112
|
+
});
|
|
113
|
+
describe('visual navigation with width', () => {
|
|
114
|
+
// 25-char line with width=10 wraps into 3 visual lines
|
|
115
|
+
const longLine = '0123456789012345678901234';
|
|
116
|
+
it('should move cursor down within wrapped line', () => {
|
|
117
|
+
const { result } = renderHook(() => useTextInput({ initialValue: longLine, width: 10 }));
|
|
118
|
+
// Start at end of line (column 25)
|
|
119
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 25 });
|
|
120
|
+
// Move to column 5 (visual row 0) - each move needs its own act()
|
|
121
|
+
for (let i = 0; i < 20; i++) {
|
|
122
|
+
act(() => {
|
|
123
|
+
result.current.moveCursor('left');
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 5 });
|
|
127
|
+
// Move down - should go to visual row 1, column 5 → buffer column 15
|
|
128
|
+
act(() => {
|
|
129
|
+
result.current.moveCursor('down');
|
|
130
|
+
});
|
|
131
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 15 });
|
|
132
|
+
});
|
|
133
|
+
it('should move cursor up within wrapped line', () => {
|
|
134
|
+
const { result } = renderHook(() => useTextInput({ initialValue: longLine, width: 10 }));
|
|
135
|
+
// Start at end of line (column 25)
|
|
136
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 25 });
|
|
137
|
+
// Move to column 15 (visual row 1, visual col 5)
|
|
138
|
+
for (let i = 0; i < 10; i++) {
|
|
139
|
+
act(() => {
|
|
140
|
+
result.current.moveCursor('left');
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 15 });
|
|
144
|
+
// Move up - should go to visual row 0, column 5 → buffer column 5
|
|
145
|
+
act(() => {
|
|
146
|
+
result.current.moveCursor('up');
|
|
147
|
+
});
|
|
148
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 5 });
|
|
149
|
+
});
|
|
150
|
+
it('should move cursor between buffer lines crossing wrapped visual lines', () => {
|
|
151
|
+
const { result } = renderHook(() => useTextInput({ initialValue: longLine + '\nhello', width: 10 }));
|
|
152
|
+
// Cursor starts at end of 'hello' (line 1, column 5)
|
|
153
|
+
expect(result.current.cursor).toEqual({ line: 1, column: 5 });
|
|
154
|
+
// Move up - should go to last visual row of first line (visual row 2, col 5 → buffer col 25)
|
|
155
|
+
act(() => {
|
|
156
|
+
result.current.moveCursor('up');
|
|
157
|
+
});
|
|
158
|
+
// Visual row 2 only has 5 chars, so col 5 clamps to end of line
|
|
159
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 25 });
|
|
160
|
+
});
|
|
161
|
+
it('should use buffer-line movement when width is not provided', () => {
|
|
162
|
+
const { result } = renderHook(() => useTextInput({ initialValue: longLine + '\nhello' }));
|
|
163
|
+
// Cursor starts at end of 'hello' (line 1, column 5)
|
|
164
|
+
expect(result.current.cursor).toEqual({ line: 1, column: 5 });
|
|
165
|
+
// Move up without width - should go to line 0, column 5 (buffer line movement)
|
|
166
|
+
act(() => {
|
|
167
|
+
result.current.moveCursor('up');
|
|
168
|
+
});
|
|
169
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 5 });
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface MultilineInputProps {
|
|
3
|
+
/** Controlled text value */
|
|
4
|
+
value?: string;
|
|
5
|
+
/** Called when text changes */
|
|
6
|
+
onChange?: (value: string) => void;
|
|
7
|
+
/** Called when user submits (Enter without backslash) */
|
|
8
|
+
onSubmit?: (value: string) => void;
|
|
9
|
+
/** Placeholder text when empty */
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
/** Whether to show the cursor (defaults to true) */
|
|
12
|
+
showCursor?: boolean;
|
|
13
|
+
/** Terminal width for word wrapping */
|
|
14
|
+
width?: number;
|
|
15
|
+
/** Whether input is active/focused (defaults to true) */
|
|
16
|
+
isActive?: boolean;
|
|
17
|
+
/** Called whenever the cursor offset changes (flat index) */
|
|
18
|
+
onCursorChange?: (offset: number) => void;
|
|
19
|
+
/** Optional external cursor override (flat index) */
|
|
20
|
+
cursorOverride?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Props for the core component (without Ink-specific hooks)
|
|
24
|
+
* This allows testing the rendering logic separately.
|
|
25
|
+
*/
|
|
26
|
+
export interface MultilineInputCoreProps {
|
|
27
|
+
value?: string;
|
|
28
|
+
onChange?: (value: string) => void;
|
|
29
|
+
onSubmit?: (value: string) => void;
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
showCursor?: boolean;
|
|
32
|
+
width?: number;
|
|
33
|
+
onCursorChange?: (offset: number) => void;
|
|
34
|
+
cursorOverride?: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Core rendering component that can be tested without Ink runtime.
|
|
38
|
+
* Does not include useInput/useStdout hooks.
|
|
39
|
+
*/
|
|
40
|
+
export declare const MultilineInputCore: React.FC<MultilineInputCoreProps>;
|
|
41
|
+
/**
|
|
42
|
+
* Full MultilineInput with Ink keyboard handling.
|
|
43
|
+
* This component uses Ink-specific hooks and must be rendered in an Ink context.
|
|
44
|
+
*/
|
|
45
|
+
export declare const MultilineInput: React.FC<MultilineInputProps>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useCallback, useRef } from 'react';
|
|
3
|
+
import { useInput, useStdout, useStdin, Box, Text } from 'ink';
|
|
4
|
+
import { useTextInput } from './useTextInput.js';
|
|
5
|
+
import { handleKey } from './KeyHandler.js';
|
|
6
|
+
import { TextRenderer } from './TextRenderer.js';
|
|
7
|
+
import { createBuffer } from './TextBuffer.js';
|
|
8
|
+
import { log } from '../../utils/logger.js';
|
|
9
|
+
/**
|
|
10
|
+
* Core rendering component that can be tested without Ink runtime.
|
|
11
|
+
* Does not include useInput/useStdout hooks.
|
|
12
|
+
*/
|
|
13
|
+
export const MultilineInputCore = ({ value, onChange, placeholder, showCursor = true, width = 80, onCursorChange, cursorOverride, }) => {
|
|
14
|
+
const textInput = useTextInput({ initialValue: value ?? '' });
|
|
15
|
+
// Handle cursor override
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (cursorOverride !== undefined) {
|
|
18
|
+
textInput.setCursorOffset(cursorOverride);
|
|
19
|
+
}
|
|
20
|
+
}, [cursorOverride]);
|
|
21
|
+
// Notify parent of cursor change
|
|
22
|
+
// Use a ref to avoid dependency on onCursorChange callback identity
|
|
23
|
+
const onCursorChangeRef = useRef(onCursorChange);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
onCursorChangeRef.current = onCursorChange;
|
|
26
|
+
}, [onCursorChange]);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (onCursorChangeRef.current) {
|
|
29
|
+
onCursorChangeRef.current(textInput.cursorOffset);
|
|
30
|
+
}
|
|
31
|
+
}, [textInput.cursorOffset]);
|
|
32
|
+
// Sync external value changes
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (value !== undefined && value !== textInput.value) {
|
|
35
|
+
textInput.setText(value);
|
|
36
|
+
}
|
|
37
|
+
}, [value]);
|
|
38
|
+
// Notify parent of changes
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
onChange?.(textInput.value);
|
|
41
|
+
}, [textInput.value, onChange]);
|
|
42
|
+
// Create buffer for TextRenderer
|
|
43
|
+
const buffer = createBuffer(textInput.value);
|
|
44
|
+
// Show placeholder if empty and no cursor shown
|
|
45
|
+
const isEmpty = textInput.value === '';
|
|
46
|
+
const showPlaceholder = isEmpty && placeholder && !showCursor;
|
|
47
|
+
if (showPlaceholder) {
|
|
48
|
+
return _jsx("div", { style: { opacity: 0.5 }, children: placeholder });
|
|
49
|
+
}
|
|
50
|
+
return (_jsx(TextRenderer, { buffer: buffer, cursor: textInput.cursor, width: width, showCursor: showCursor }));
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Full MultilineInput with Ink keyboard handling.
|
|
54
|
+
* This component uses Ink-specific hooks and must be rendered in an Ink context.
|
|
55
|
+
*/
|
|
56
|
+
export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCursor = true, width, isActive = true, onCursorChange, cursorOverride, }) => {
|
|
57
|
+
// Get terminal width from Ink if not provided
|
|
58
|
+
const { stdout } = useStdout();
|
|
59
|
+
const terminalWidth = width ?? stdout?.columns ?? 80;
|
|
60
|
+
// Track raw input for detecting Home/End keys
|
|
61
|
+
const { stdin } = useStdin();
|
|
62
|
+
const lastRawInput = useRef('');
|
|
63
|
+
// Listen for raw stdin data to capture escape sequences
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!stdin || !isActive)
|
|
66
|
+
return;
|
|
67
|
+
const handleData = (data) => {
|
|
68
|
+
lastRawInput.current = data.toString();
|
|
69
|
+
};
|
|
70
|
+
stdin.on('data', handleData);
|
|
71
|
+
return () => {
|
|
72
|
+
stdin.off('data', handleData);
|
|
73
|
+
};
|
|
74
|
+
}, [stdin, isActive]);
|
|
75
|
+
const textInput = useTextInput({ initialValue: value ?? '', width: terminalWidth });
|
|
76
|
+
// Handle cursor override
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (cursorOverride !== undefined) {
|
|
79
|
+
textInput.setCursorOffset(cursorOverride);
|
|
80
|
+
}
|
|
81
|
+
}, [cursorOverride]);
|
|
82
|
+
// Notify parent of cursor change
|
|
83
|
+
const onCursorChangeRef = useRef(onCursorChange);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
onCursorChangeRef.current = onCursorChange;
|
|
86
|
+
}, [onCursorChange]);
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
onCursorChangeRef.current?.(textInput.cursorOffset);
|
|
89
|
+
}, [textInput.cursorOffset]);
|
|
90
|
+
// Sync external value changes
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (value !== undefined && value !== textInput.value) {
|
|
93
|
+
textInput.setText(value);
|
|
94
|
+
}
|
|
95
|
+
}, [value]);
|
|
96
|
+
// Notify parent of changes
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
onChange?.(textInput.value);
|
|
99
|
+
}, [textInput.value, onChange]);
|
|
100
|
+
// Create buffer for TextRenderer and KeyHandler
|
|
101
|
+
const buffer = createBuffer(textInput.value);
|
|
102
|
+
// Create submit handler
|
|
103
|
+
const handleSubmit = useCallback(() => {
|
|
104
|
+
onSubmit?.(textInput.value);
|
|
105
|
+
textInput.setText(''); // Clear input after submit
|
|
106
|
+
}, [onSubmit, textInput.value, textInput.setText]);
|
|
107
|
+
// Create actions for KeyHandler
|
|
108
|
+
const actions = {
|
|
109
|
+
insert: textInput.insert,
|
|
110
|
+
delete: textInput.delete,
|
|
111
|
+
deleteForward: textInput.deleteForward,
|
|
112
|
+
newLine: textInput.newLine,
|
|
113
|
+
deleteAndNewLine: textInput.deleteAndNewLine,
|
|
114
|
+
moveCursor: textInput.moveCursor,
|
|
115
|
+
undo: textInput.undo,
|
|
116
|
+
redo: textInput.redo,
|
|
117
|
+
setText: textInput.setText,
|
|
118
|
+
submit: handleSubmit,
|
|
119
|
+
};
|
|
120
|
+
// Handle keyboard input
|
|
121
|
+
useInput((input, key) => {
|
|
122
|
+
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}`);
|
|
123
|
+
handleKey(key, input, buffer, actions, textInput.cursor, lastRawInput.current);
|
|
124
|
+
}, { isActive });
|
|
125
|
+
// Show placeholder if empty and no cursor shown
|
|
126
|
+
const isEmpty = textInput.value === '';
|
|
127
|
+
const showPlaceholder = isEmpty && placeholder && !showCursor;
|
|
128
|
+
if (showPlaceholder) {
|
|
129
|
+
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: placeholder }) }));
|
|
130
|
+
}
|
|
131
|
+
return (_jsx(TextRenderer, { buffer: buffer, cursor: textInput.cursor, width: terminalWidth, showCursor: showCursor }));
|
|
132
|
+
};
|