Skip to content

Commit 8f0e854

Browse files
Merge pull request #132 from ai-action/fix/input
2 parents ae748b4 + 62bf637 commit 8f0e854

6 files changed

Lines changed: 181 additions & 32 deletions

File tree

src/components/Chat/ChatInput.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ vi.mock('../TextInput', () => ({
4141
value,
4242
isDisabled,
4343
cursorPosition,
44+
allowMultilinePaste,
4445
wrapIndent,
4546
onChange,
4647
onSubmit,
@@ -49,6 +50,7 @@ vi.mock('../TextInput', () => ({
4950
value?: string;
5051
isDisabled?: boolean;
5152
cursorPosition?: number;
53+
allowMultilinePaste?: boolean;
5254
wrapIndent?: number;
5355
onChange?: (value: string) => void;
5456
onSubmit?: (value: string) => void;
@@ -58,6 +60,7 @@ vi.mock('../TextInput', () => ({
5860
value,
5961
isDisabled,
6062
cursorPosition,
63+
allowMultilinePaste,
6164
wrapIndent,
6265
onChange,
6366
onSubmit,
@@ -332,6 +335,45 @@ describe('ChatInput', () => {
332335
expect(onSubmit).toHaveBeenCalledWith({ content: 'hi' });
333336
});
334337

338+
it('enables multiline paste in the text input', () => {
339+
renderInput();
340+
341+
expect(mockTextInput.mock.calls.at(-1)?.[0]).toMatchObject({
342+
allowMultilinePaste: true,
343+
});
344+
});
345+
346+
it('submits pasted multiline text as one chat message', async () => {
347+
const onSubmit = vi.fn();
348+
const { stdin } = renderInput({ onSubmit });
349+
350+
stdin.write('line one\nline two');
351+
await time.tick(10);
352+
stdin.write(KEY.ENTER);
353+
await time.tick();
354+
355+
expect(onSubmit).toHaveBeenCalledWith({
356+
content: 'line one\nline two',
357+
});
358+
});
359+
360+
it('submits multiline text starting with slash as chat text', async () => {
361+
const onSubmit = vi.fn();
362+
const { lastFrame, stdin } = renderInput({ onSubmit });
363+
364+
stdin.write('/not-a-command\nexplain it');
365+
await time.tick(10);
366+
367+
expect(lastFrame()).not.toContain('/not-a-command - invalid command');
368+
369+
stdin.write(KEY.ENTER);
370+
await time.tick();
371+
372+
expect(onSubmit).toHaveBeenCalledWith({
373+
content: '/not-a-command\nexplain it',
374+
});
375+
});
376+
335377
it('inserts the focused file suggestion on Enter with a trailing space', async () => {
336378
const { lastFrame, stdin } = renderInput();
337379
stdin.write('@');

src/components/Chat/ChatInput.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,8 @@ export function ChatInput({
224224
[attachments, onSubmit, resetInput],
225225
);
226226

227-
const showCommandMenu = input.startsWith('/');
227+
const isMultilineInput = input.includes('\n');
228+
const showCommandMenu = input.startsWith('/') && !isMultilineInput;
228229
const showFileSuggestions = !showCommandMenu && hasFileSuggestionQuery(input);
229230

230231
const handleHistoryNavigation = useCallback(
@@ -281,7 +282,7 @@ export function ChatInput({
281282

282283
const handleSubmitText = useCallback(
283284
(value: string) => {
284-
if (value.startsWith('/')) {
285+
if (value.startsWith('/') && !value.includes('\n')) {
285286
return;
286287
}
287288

@@ -378,6 +379,7 @@ export function ChatInput({
378379
value={input}
379380
isDisabled={isDisabled}
380381
cursorPosition={cursorPosition}
382+
allowMultilinePaste
381383
wrapIndent={wrapIndent}
382384
onChange={handleInputChange}
383385
onSubmit={handleSubmitText}

src/components/Suggestions/Suggestions.test.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,24 @@ describe('Suggestions', () => {
8787
});
8888
});
8989

90+
it('ignores unrelated printable input', async () => {
91+
const onSelect = vi.fn();
92+
const { stdin } = render(
93+
<Suggestions
94+
options={[
95+
{ label: 'alpha', value: 'alpha' },
96+
{ label: 'beta', value: 'beta' },
97+
]}
98+
onSelect={onSelect}
99+
/>,
100+
);
101+
102+
stdin.write('x');
103+
await time.tick();
104+
105+
expect(onSelect).not.toHaveBeenCalled();
106+
});
107+
90108
it('returns null when options are empty', () => {
91109
const { lastFrame } = render(
92110
<Suggestions options={[]} onSelect={vi.fn()} />,

src/components/Suggestions/Suggestions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export function Suggestions<T = string>({
6666
return;
6767
}
6868

69-
if (key.tab || key.return || input === KEY.TAB || input === KEY.ENTER) {
69+
if (key.tab || key.return) {
7070
onSelect(options[focusedIndex]);
7171
}
7272
});

src/components/TextInput/TextInput.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ describe('TextInput', () => {
9797
expect(stripAnsi(lastFrame())).toBe('abcd\nefg');
9898
});
9999

100+
it('renders hard newlines as separate rows', () => {
101+
const { lastFrame } = render(
102+
<TextInput
103+
value={'one\ntwo'}
104+
cursorPosition={1}
105+
onChange={vi.fn()}
106+
onSubmit={vi.fn()}
107+
/>,
108+
);
109+
110+
expect(stripAnsi(lastFrame())).toBe('one\ntwo');
111+
});
112+
100113
it('renders a wrapped placeholder using the prompt indent width', () => {
101114
setTerminalWidth(8);
102115

@@ -123,6 +136,42 @@ describe('TextInput', () => {
123136
expect(onSubmit).toHaveBeenCalledWith('test');
124137
});
125138

139+
it('inserts bracketed pasted text with newlines without submitting', async () => {
140+
const onChange = vi.fn();
141+
const onSubmit = vi.fn();
142+
const { stdin } = render(
143+
<TextInput
144+
value=""
145+
allowMultilinePaste
146+
onChange={onChange}
147+
onSubmit={onSubmit}
148+
/>,
149+
);
150+
151+
stdin.write('\x1B[200~one\r\ntwo\x1B[201~');
152+
await time.tick();
153+
154+
expect(onChange).toHaveBeenCalledWith('one\ntwo');
155+
expect(onSubmit).not.toHaveBeenCalled();
156+
});
157+
158+
it('preserves newlines from unbracketed pasted input', async () => {
159+
const onChange = vi.fn();
160+
const { stdin } = render(
161+
<TextInput
162+
value=""
163+
allowMultilinePaste
164+
onChange={onChange}
165+
onSubmit={vi.fn()}
166+
/>,
167+
);
168+
169+
stdin.write('one\rtwo');
170+
await time.tick();
171+
172+
expect(onChange).toHaveBeenCalledWith('one\ntwo');
173+
});
174+
126175
it('ignores input when disabled', async () => {
127176
const onChange = vi.fn();
128177
const { stdin } = render(

src/components/TextInput/TextInput.tsx

Lines changed: 67 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { Box, Text, useInput, useStdout } from 'ink';
2-
import { useEffect, useMemo, useRef, useState } from 'react';
1+
import { Box, Text, useInput, usePaste, useStdout } from 'ink';
2+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
33

44
interface Props {
55
value: string;
66
isDisabled?: boolean;
77
placeholder?: string;
88
cursorPosition?: number;
9+
allowMultilinePaste?: boolean;
910
wrapIndent?: number;
1011
onChange: (value: string) => void;
1112
onSubmit: (value: string) => void;
@@ -25,48 +26,64 @@ function buildLineSegments(
2526
width: number,
2627
): LineSegment[] {
2728
const safeWidth = Math.max(1, width);
28-
const cursorChar = displayValue[cursorPosition] || ' ';
29-
const renderValue =
30-
displayValue.slice(0, cursorPosition) +
31-
cursorChar +
32-
displayValue.slice(cursorPosition + 1);
33-
const totalLength = Math.max(1, renderValue.length);
3429
const lines: LineSegment[] = [];
30+
const logicalLines = displayValue.split('\n');
31+
let lineStart = 0;
3532

36-
for (let start = 0; start < totalLength; start += safeWidth) {
37-
const end = start + safeWidth;
38-
const text = renderValue.slice(start, end);
39-
const hasCursor = cursorPosition >= start && cursorPosition < end;
33+
for (const [lineIndex, logicalLine] of logicalLines.entries()) {
34+
const lineEnd = lineStart + logicalLine.length;
35+
const hasCursorOnLine =
36+
cursorPosition >= lineStart && cursorPosition <= lineEnd;
37+
const cursorOffset = cursorPosition - lineStart;
38+
const renderValue =
39+
hasCursorOnLine && cursorOffset === logicalLine.length
40+
? `${logicalLine} `
41+
: logicalLine;
42+
const totalLength = Math.max(1, renderValue.length);
4043

41-
if (!hasCursor) {
44+
for (let start = 0; start < totalLength; start += safeWidth) {
45+
const end = start + safeWidth;
46+
const text = renderValue.slice(start, end);
47+
const hasCursor =
48+
hasCursorOnLine && cursorOffset >= start && cursorOffset < end;
49+
50+
if (!hasCursor) {
51+
lines.push({
52+
text,
53+
hasCursor,
54+
beforeCursor: '',
55+
cursorChar: ' ',
56+
afterCursor: '',
57+
});
58+
continue;
59+
}
60+
61+
const offset = cursorOffset - start;
4262
lines.push({
4363
text,
4464
hasCursor,
45-
beforeCursor: '',
46-
cursorChar: ' ',
47-
afterCursor: '',
65+
beforeCursor: text.slice(0, offset),
66+
cursorChar: text[offset],
67+
afterCursor: text.slice(offset + 1),
4868
});
49-
continue;
5069
}
5170

52-
const offset = cursorPosition - start;
53-
lines.push({
54-
text,
55-
hasCursor,
56-
beforeCursor: text.slice(0, offset),
57-
cursorChar: text[offset] || ' ',
58-
afterCursor: text.slice(offset + 1),
59-
});
71+
lineStart = lineEnd + (lineIndex < logicalLines.length - 1 ? 1 : 0);
6072
}
6173

6274
return lines;
6375
}
6476

77+
function normalizePastedText(input: string) {
78+
return input.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
79+
}
80+
6581
export function TextInput({
6682
value,
6783
isDisabled = false,
6884
placeholder,
6985
cursorPosition: externalCursorPosition,
86+
allowMultilinePaste = false,
7087
wrapIndent = 0,
7188
onChange,
7289
onSubmit,
@@ -109,13 +126,37 @@ export function TextInput({
109126
}
110127
}, [value, cursorPosition, externalCursorPosition]);
111128

129+
const insertText = useCallback(
130+
(text: string) => {
131+
const newValue =
132+
value.slice(0, cursorPosition) + text + value.slice(cursorPosition);
133+
onChange(newValue);
134+
setCursorPosition(cursorPosition + text.length);
135+
},
136+
[cursorPosition, onChange, value],
137+
);
138+
139+
usePaste(
140+
(text) => {
141+
insertText(normalizePastedText(text));
142+
},
143+
{ isActive: allowMultilinePaste && !isDisabled },
144+
);
145+
112146
useInput(
113147
(input, key) => {
114148
// v8 ignore next
115149
if (isDisabled) {
116150
return;
117151
}
118152

153+
const hasPastedNewlines = allowMultilinePaste && /[\r\n]/.test(input);
154+
155+
if (hasPastedNewlines) {
156+
insertText(normalizePastedText(input));
157+
return;
158+
}
159+
119160
if (key.return) {
120161
onSubmit(value);
121162
setCursorPosition(0);
@@ -181,10 +222,7 @@ export function TextInput({
181222

182223
// v8 ignore start
183224
if (input) {
184-
const newValue =
185-
value.slice(0, cursorPosition) + input + value.slice(cursorPosition);
186-
onChange(newValue);
187-
setCursorPosition(cursorPosition + input.length);
225+
insertText(input);
188226
}
189227
// v8 ignore stop
190228
},

0 commit comments

Comments
 (0)