Skip to content

Commit 168b17e

Browse files
Merge pull request #30 from ai-action/feat/input
2 parents b4ffd45 + c8fb782 commit 168b17e

5 files changed

Lines changed: 72 additions & 11 deletions

File tree

src/components/Chat/Input.test.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ import { useRef, useState } from 'react';
55
import { COMMAND, KEY } from '../../constants';
66
import { time } from '../../utils';
77

8+
const { mockExit } = vi.hoisted(() => ({
9+
mockExit: vi.fn(),
10+
}));
11+
12+
vi.mock('ink', async () => ({
13+
...(await vi.importActual('ink')),
14+
useApp: vi.fn(() => ({
15+
exit: mockExit,
16+
})),
17+
}));
18+
819
vi.mock('@inkjs/ui', () => ({
920
TextInput: ({
1021
defaultValue,
@@ -40,6 +51,10 @@ vi.mock('@inkjs/ui', () => ({
4051
return;
4152
}
4253

54+
if (key.ctrl) {
55+
return;
56+
}
57+
4358
if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) {
4459
return;
4560
}
@@ -177,6 +192,10 @@ vi.mock('./FileSuggestions', () => ({
177192
import { Input } from './Input';
178193

179194
describe('Input', () => {
195+
beforeEach(() => {
196+
mockExit.mockReset();
197+
});
198+
180199
it('renders input prompt', () => {
181200
const { lastFrame } = render(<Input onSubmit={vi.fn()} />);
182201
expect(lastFrame()).toContain('>');
@@ -387,6 +406,26 @@ describe('Input', () => {
387406
expect(lastFrame()).not.toContain('src/components/Chat/Input.tsx');
388407
});
389408

409+
it('clears input on Ctrl+C when input is non-empty', async () => {
410+
const { lastFrame, stdin } = render(<Input onSubmit={vi.fn()} />);
411+
stdin.write('hi');
412+
await time.tick();
413+
expect(lastFrame()).toContain('[value:hi]');
414+
stdin.write(KEY.CTRL_C);
415+
await time.tick();
416+
expect(lastFrame()).not.toContain('[value:hi]');
417+
expect(lastFrame()).toContain(
418+
'[placeholder:Ask anything... (/ commands, @ files)]',
419+
);
420+
});
421+
422+
it('calls exit on Ctrl+C when input is empty', async () => {
423+
const { stdin } = render(<Input onSubmit={vi.fn()} />);
424+
stdin.write(KEY.CTRL_C);
425+
await time.tick();
426+
expect(mockExit).toHaveBeenCalledOnce();
427+
});
428+
390429
it('does not accept input when disabled', async () => {
391430
const onSubmit = vi.fn();
392431
const { lastFrame, stdin } = render(

src/components/Chat/Input.tsx

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { TextInput } from '@inkjs/ui';
2-
import { Box, Text } from 'ink';
2+
import { Box, Text, useApp, useInput } from 'ink';
33
import { useCallback, useState } from 'react';
44

55
import { COMMAND, UI } from '../../constants';
@@ -17,8 +17,13 @@ function hasActiveMentionQuery(input: string): boolean {
1717
}
1818

1919
export function Input({ isDisabled = false, onSubmit }: Props) {
20+
const { exit } = useApp();
2021
const [input, setInput] = useState('');
21-
const [resetKey, setResetKey] = useState(0);
22+
const [inputKey, setInputKey] = useState(0);
23+
24+
const remountTextInput = useCallback(() => {
25+
setInputKey((key) => key + 1);
26+
}, [setInputKey]);
2227

2328
const handleSubmitText = useCallback(
2429
async (input: string) => {
@@ -35,9 +40,9 @@ export function Input({ isDisabled = false, onSubmit }: Props) {
3540

3641
onSubmit(trimmedInput);
3742
setInput('');
38-
setResetKey((key) => key + 1);
43+
remountTextInput();
3944
},
40-
[onSubmit],
45+
[onSubmit, remountTextInput],
4146
);
4247

4348
const handleSubmitCommand = useCallback(
@@ -48,15 +53,29 @@ export function Input({ isDisabled = false, onSubmit }: Props) {
4853

4954
onSubmit(input);
5055
setInput('');
51-
setResetKey((key) => key + 1);
56+
remountTextInput();
5257
},
53-
[onSubmit],
58+
[onSubmit, remountTextInput],
5459
);
5560

56-
const handleSelectFileSuggestion = useCallback((nextInput: string) => {
57-
setInput(nextInput);
58-
setResetKey((key) => key + 1);
59-
}, []);
61+
const handleSelectFileSuggestion = useCallback(
62+
(nextInput: string) => {
63+
setInput(nextInput);
64+
remountTextInput();
65+
},
66+
[remountTextInput],
67+
);
68+
69+
useInput((_input, key) => {
70+
if (key.ctrl && _input === 'c') {
71+
if (input) {
72+
setInput('');
73+
remountTextInput();
74+
} else {
75+
exit();
76+
}
77+
}
78+
});
6079

6180
const showCommandMenu = input.startsWith('/');
6281
const showFileSuggestions = !showCommandMenu && hasActiveMentionQuery(input);
@@ -69,7 +88,7 @@ export function Input({ isDisabled = false, onSubmit }: Props) {
6988
<TextInput
7089
defaultValue={input}
7190
isDisabled={isDisabled}
72-
key={resetKey}
91+
key={inputKey}
7392
onChange={setInput}
7493
// eslint-disable-next-line @typescript-eslint/no-misused-promises
7594
onSubmit={handleSubmitText}

src/constants/key.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const BACKSPACE = '\x7f';
2+
export const CTRL_C = '\x03';
23
export const DOWN = '\x1B[B';
34
export const ENTER = '\r';
45
export const ESCAPE = '\x1B\x1B';

src/tui.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ describe('tui', () => {
3535
renderApp();
3636

3737
expect(render).toHaveBeenCalledWith(expect.anything(), {
38+
exitOnCtrlC: false,
3839
incrementalRendering: true,
3940
maxFps: 60,
4041
});

src/tui.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { screen } from './utils';
66
export function renderApp(): void {
77
const tree = <App />;
88
const app = render(tree, {
9+
exitOnCtrlC: false,
910
incrementalRendering: true,
1011
maxFps: 60,
1112
});

0 commit comments

Comments
 (0)