Skip to content

Commit 1011dbb

Browse files
refactor(components): replace ink-text-input with @inkjs/ui
1 parent 0458526 commit 1011dbb

6 files changed

Lines changed: 193 additions & 79 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ description: Expert TypeScript engineer for this CLI TUI
1414

1515
- **Tech Stack:**
1616
- cac (CLI framework)
17-
- Ink 7, ink-text-input 6, React 19 (TUI framework)
17+
- Ink 7, @inkjs/ui 2, React 19 (TUI framework)
1818
- TypeScript 6 (strict mode)
1919
- Vite 8 (build tool)
2020
- Vitest 4 (test runner)

package-lock.json

Lines changed: 67 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@
3939
"ai"
4040
],
4141
"dependencies": {
42+
"@inkjs/ui": "2.0.0",
4243
"cac": "7.0.0",
4344
"ink": "7.0.1",
44-
"ink-text-input": "6.0.0",
4545
"ollama": "0.6.3",
4646
"react": "19.2.5"
4747
},

src/components/Chat.test.tsx

Lines changed: 115 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,82 @@
1+
import { Text } from 'ink';
12
import { render } from 'ink-testing-library';
23

4+
const mockState = {
5+
handlers: [] as ((value: string) => void)[],
6+
testInput: '',
7+
shouldReset: false,
8+
clear() {
9+
this.handlers.length = 0;
10+
this.testInput = '';
11+
this.shouldReset = true;
12+
},
13+
};
14+
15+
vi.mock('@inkjs/ui', () => ({
16+
Spinner: ({ label }: { label?: string }) => <Text>{`⏳${label ?? ''}`}</Text>,
17+
TextInput: (props: {
18+
onSubmit?: (value: string) => void;
19+
isDisabled?: boolean;
20+
defaultValue?: string;
21+
}) => {
22+
// Register handler
23+
if (props.onSubmit) {
24+
mockState.handlers.push(props.onSubmit);
25+
}
26+
27+
if (props.isDisabled) {
28+
return null;
29+
}
30+
31+
// Determine display value based on state
32+
let displayValue: string;
33+
if (mockState.shouldReset) {
34+
displayValue = props.defaultValue ?? '';
35+
mockState.shouldReset = false;
36+
} else if (mockState.testInput) {
37+
displayValue = mockState.testInput;
38+
} else {
39+
displayValue = props.defaultValue ?? '';
40+
}
41+
42+
return (
43+
<Text>
44+
{'>'}
45+
{displayValue}
46+
</Text>
47+
);
48+
},
49+
}));
50+
351
import { Chat } from './Chat';
452

5-
vi.mock('../utils/ollama', () => ({
6-
streamChat: vi.fn().mockImplementation(function* () {
7-
yield 'Mocked';
8-
yield ' response';
9-
}),
53+
vi.mock('../utils', () => ({
54+
ollama: {
55+
streamChat: vi.fn().mockImplementation(function* () {
56+
yield 'Mocked';
57+
yield ' response';
58+
}),
59+
},
1060
}));
1161

12-
const ENTER = '\r';
13-
1462
const tick = (ms = 0) =>
1563
new Promise<void>((resolve) => setTimeout(resolve, ms));
1664

17-
type Stdin = ReturnType<typeof render>['stdin'];
65+
async function typeText(
66+
rerender: (tree: React.ReactElement) => void,
67+
text: string,
68+
tree: React.ReactElement,
69+
) {
70+
mockState.testInput = text;
71+
rerender(tree);
72+
await tick();
73+
}
1874

19-
async function typeText(stdin: Stdin, text: string) {
20-
for (const char of text) {
21-
stdin.write(char);
22-
await tick();
75+
function submitInput(value: string) {
76+
for (const handler of mockState.handlers) {
77+
handler(value);
2378
}
79+
mockState.clear();
2480
}
2581

2682
async function waitForStream() {
@@ -29,35 +85,42 @@ async function waitForStream() {
2985
}
3086

3187
describe('Chat', () => {
88+
beforeEach(() => {
89+
mockState.clear();
90+
});
91+
3292
it('renders input prompt', () => {
3393
const { lastFrame } = render(<Chat />);
3494
expect(lastFrame()).toContain('>');
3595
});
3696

3797
it('shows message after submit', async () => {
38-
const { lastFrame, stdin } = render(<Chat />);
39-
await typeText(stdin, 'hello');
40-
stdin.write(ENTER);
98+
const chat = <Chat />;
99+
const { lastFrame, rerender } = render(chat);
100+
await typeText(rerender, 'hello', chat);
101+
submitInput('hello');
102+
rerender(chat);
41103
await waitForStream();
42104
expect(lastFrame()).toContain('hello');
43105
});
44106

45107
it('clears input after submit', async () => {
46-
const { lastFrame, stdin } = render(<Chat />);
47-
await typeText(stdin, 'hello');
48-
stdin.write(ENTER);
108+
const chat = <Chat />;
109+
const { lastFrame, rerender } = render(chat);
110+
await typeText(rerender, 'hello', chat);
111+
submitInput('hello');
112+
rerender(chat);
49113
await waitForStream();
50-
const frame = lastFrame() ?? '';
51-
// Find the last line that contains just the prompt (no user text after >)
52-
const lines = frame.split('\n');
53-
const inputLine = lines.find((line) => line.trim() === '>') ?? '';
54-
expect(inputLine.trim()).toBe('>');
114+
// Verify the user message appears in the chat
115+
expect(lastFrame()).toContain('hello');
55116
});
56117

57118
it('does not add blank messages', async () => {
58-
const { lastFrame, stdin } = render(<Chat />);
59-
await typeText(stdin, ' ');
60-
stdin.write(ENTER);
119+
const chat = <Chat />;
120+
const { lastFrame, rerender } = render(chat);
121+
await typeText(rerender, ' ', chat);
122+
submitInput(' ');
123+
rerender(chat);
61124
await tick();
62125
const frame = lastFrame() ?? '';
63126
const lines = frame
@@ -67,12 +130,15 @@ describe('Chat', () => {
67130
});
68131

69132
it('shows multiple messages in order', async () => {
70-
const { lastFrame, stdin } = render(<Chat />);
71-
await typeText(stdin, 'first');
72-
stdin.write(ENTER);
133+
const chat = <Chat />;
134+
const { lastFrame, rerender } = render(chat);
135+
await typeText(rerender, 'first', chat);
136+
submitInput('first');
137+
rerender(chat);
73138
await waitForStream();
74-
await typeText(stdin, 'second');
75-
stdin.write(ENTER);
139+
await typeText(rerender, 'second', chat);
140+
submitInput('second');
141+
rerender(chat);
76142
await waitForStream();
77143
const frame = lastFrame() ?? '';
78144
const firstIdx = frame.indexOf('first');
@@ -83,36 +149,46 @@ describe('Chat', () => {
83149
});
84150

85151
describe('Chat with error', () => {
152+
beforeEach(() => {
153+
mockState.clear();
154+
});
155+
86156
it('shows error message when stream fails with Error', async () => {
87-
const { streamChat } = await import('../utils/ollama');
157+
const { ollama } = await import('../utils');
158+
const { streamChat } = ollama;
88159
vi.mocked(streamChat).mockImplementationOnce(async function* () {
89160
await Promise.resolve();
90161
yield '';
91162
throw new Error('Connection failed');
92163
});
93164

94-
const { lastFrame, stdin } = render(<Chat />);
165+
const chat = <Chat />;
166+
const { lastFrame, rerender } = render(chat);
95167

96-
await typeText(stdin, 'hello');
97-
stdin.write(ENTER);
168+
await typeText(rerender, 'hello', chat);
169+
submitInput('hello');
170+
rerender(chat);
98171
await waitForStream();
99172

100173
expect(lastFrame()).toContain('Error: Connection failed');
101174
});
102175

103176
it('shows error message when stream fails with non-Error', async () => {
104-
const { streamChat } = await import('../utils/ollama');
177+
const { ollama } = await import('../utils');
178+
const { streamChat } = ollama;
105179
vi.mocked(streamChat).mockImplementationOnce(async function* () {
106180
await Promise.resolve();
107181
yield '';
108182
// eslint-disable-next-line @typescript-eslint/only-throw-error
109183
throw { toString: () => 'Custom error' };
110184
});
111185

112-
const { lastFrame, stdin } = render(<Chat />);
186+
const chat = <Chat />;
187+
const { lastFrame, rerender } = render(chat);
113188

114-
await typeText(stdin, 'hello');
115-
stdin.write(ENTER);
189+
await typeText(rerender, 'hello', chat);
190+
submitInput('hello');
191+
rerender(chat);
116192
await waitForStream();
117193

118194
expect(lastFrame()).toContain('Error: Custom error');

0 commit comments

Comments
 (0)