Skip to content

Commit 6113b00

Browse files
feat(components): add ModelPicker
1 parent e6bee66 commit 6113b00

8 files changed

Lines changed: 386 additions & 16 deletions

File tree

src/components/App.test.tsx

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

4+
import { tick } from '../utils/test';
5+
6+
const capturedCallbacks = vi.hoisted(() => ({
7+
onCommand: null as ((command: string) => void) | null,
8+
onSelect: null as ((model: string) => void) | null,
9+
onCancel: null as (() => void) | null,
10+
}));
11+
12+
vi.mock('./Chat', () => ({
13+
Chat: ({
14+
onCommand,
15+
}: {
16+
model: string;
17+
onCommand: (command: string) => void;
18+
}) => {
19+
capturedCallbacks.onCommand = onCommand;
20+
return <Text>{'>'}</Text>;
21+
},
22+
}));
23+
24+
vi.mock('./ModelPicker', () => ({
25+
ModelPicker: ({
26+
onSelect,
27+
onCancel,
28+
}: {
29+
currentModel: string;
30+
onSelect: (model: string) => void;
31+
onCancel: () => void;
32+
}) => {
33+
capturedCallbacks.onSelect = onSelect;
34+
capturedCallbacks.onCancel = onCancel;
35+
return <Text>ModelPicker</Text>;
36+
},
37+
}));
38+
339
import { App } from './App';
440

541
describe('App', () => {
42+
beforeEach(() => {
43+
capturedCallbacks.onCommand = null;
44+
capturedCallbacks.onSelect = null;
45+
capturedCallbacks.onCancel = null;
46+
});
47+
648
it('renders title', () => {
749
const { lastFrame } = render(<App />);
850
expect(lastFrame()).toContain('Code Ollama');
@@ -12,4 +54,44 @@ describe('App', () => {
1254
const { lastFrame } = render(<App />);
1355
expect(lastFrame()).toContain('>');
1456
});
57+
58+
it('shows ModelPicker when /model command is issued', async () => {
59+
const { lastFrame, rerender } = render(<App />);
60+
capturedCallbacks.onCommand?.('/model');
61+
rerender(<App />);
62+
await tick();
63+
expect(lastFrame()).toContain('ModelPicker');
64+
});
65+
66+
it('returns to chat and updates model when onSelect is called', async () => {
67+
const { lastFrame, rerender } = render(<App />);
68+
capturedCallbacks.onCommand?.('/model');
69+
rerender(<App />);
70+
await tick();
71+
capturedCallbacks.onSelect?.('llama3');
72+
rerender(<App />);
73+
await tick();
74+
expect(lastFrame()).toContain('model: llama3');
75+
expect(lastFrame()).not.toContain('ModelPicker');
76+
});
77+
78+
it('returns to chat when onCancel is called', async () => {
79+
const { lastFrame, rerender } = render(<App />);
80+
capturedCallbacks.onCommand?.('/model');
81+
rerender(<App />);
82+
await tick();
83+
capturedCallbacks.onCancel?.();
84+
rerender(<App />);
85+
await tick();
86+
expect(lastFrame()).not.toContain('ModelPicker');
87+
expect(lastFrame()).toContain('>');
88+
});
89+
90+
it('does not open ModelPicker for unknown commands', async () => {
91+
const { lastFrame, rerender } = render(<App />);
92+
capturedCallbacks.onCommand?.('/unknown');
93+
rerender(<App />);
94+
await tick();
95+
expect(lastFrame()).not.toContain('ModelPicker');
96+
});
1597
});

src/components/App.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,44 @@
11
import { Box, Text } from 'ink';
2+
import { useCallback, useState } from 'react';
23

34
import { Chat } from './Chat';
5+
import { ModelPicker } from './ModelPicker';
6+
7+
const DEFAULT_MODEL = process.env.OLLAMA_MODEL ?? 'gemma4';
48

59
export function App() {
10+
const [model, setModel] = useState(DEFAULT_MODEL);
11+
const [picking, setPicking] = useState(false);
12+
13+
const handleCommand = useCallback((command: string) => {
14+
if (command === '/model') {
15+
setPicking(true);
16+
}
17+
}, []);
18+
19+
const handleSelect = useCallback((selected: string) => {
20+
setModel(selected);
21+
setPicking(false);
22+
}, []);
23+
24+
const handleCancel = useCallback(() => {
25+
setPicking(false);
26+
}, []);
27+
628
return (
729
<Box flexDirection="column">
830
<Text>Code Ollama</Text>
9-
<Chat />
31+
<Text dimColor>model: {model}</Text>
32+
33+
{picking ? (
34+
<ModelPicker
35+
currentModel={model}
36+
onSelect={handleSelect}
37+
onCancel={handleCancel}
38+
/>
39+
) : (
40+
<Chat model={model} onCommand={handleCommand} />
41+
)}
1042
</Box>
1143
);
1244
}

src/components/Chat.test.tsx

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { Text } from 'ink';
22
import { render } from 'ink-testing-library';
33

4-
const mockState = {
4+
import { tick } from '../utils/test';
5+
6+
const mockState = vi.hoisted(() => ({
57
handlers: [] as ((value: string) => void)[],
68
testInput: '',
79
shouldReset: false,
@@ -10,7 +12,7 @@ const mockState = {
1012
this.testInput = '';
1113
this.shouldReset = true;
1214
},
13-
};
15+
}));
1416

1517
vi.mock('@inkjs/ui', () => ({
1618
Spinner: ({ label }: { label?: string }) => <Text>{`⏳${label ?? ''}`}</Text>,
@@ -59,9 +61,6 @@ vi.mock('../utils', () => ({
5961
},
6062
}));
6163

62-
const tick = (ms = 0) =>
63-
new Promise<void>((resolve) => setTimeout(resolve, ms));
64-
6564
async function typeText(
6665
rerender: (tree: React.ReactElement) => void,
6766
text: string,
@@ -90,12 +89,12 @@ describe('Chat', () => {
9089
});
9190

9291
it('renders input prompt', () => {
93-
const { lastFrame } = render(<Chat />);
92+
const { lastFrame } = render(<Chat model="gemma4" onCommand={vi.fn()} />);
9493
expect(lastFrame()).toContain('>');
9594
});
9695

9796
it('shows message after submit', async () => {
98-
const chat = <Chat />;
97+
const chat = <Chat model="gemma4" onCommand={vi.fn()} />;
9998
const { lastFrame, rerender } = render(chat);
10099
await typeText(rerender, 'hello', chat);
101100
submitInput('hello');
@@ -105,7 +104,7 @@ describe('Chat', () => {
105104
});
106105

107106
it('clears input after submit', async () => {
108-
const chat = <Chat />;
107+
const chat = <Chat model="gemma4" onCommand={vi.fn()} />;
109108
const { lastFrame, rerender } = render(chat);
110109
await typeText(rerender, 'hello', chat);
111110
submitInput('hello');
@@ -116,7 +115,7 @@ describe('Chat', () => {
116115
});
117116

118117
it('does not add blank messages', async () => {
119-
const chat = <Chat />;
118+
const chat = <Chat model="gemma4" onCommand={vi.fn()} />;
120119
const { lastFrame, rerender } = render(chat);
121120
await typeText(rerender, ' ', chat);
122121
submitInput(' ');
@@ -130,7 +129,7 @@ describe('Chat', () => {
130129
});
131130

132131
it('shows multiple messages in order', async () => {
133-
const chat = <Chat />;
132+
const chat = <Chat model="gemma4" onCommand={vi.fn()} />;
134133
const { lastFrame, rerender } = render(chat);
135134
await typeText(rerender, 'first', chat);
136135
submitInput('first');
@@ -146,6 +145,33 @@ describe('Chat', () => {
146145
expect(firstIdx).toBeGreaterThanOrEqual(0);
147146
expect(secondIdx).toBeGreaterThan(firstIdx);
148147
});
148+
149+
it('calls onCommand when a slash command is submitted', async () => {
150+
const onCommand = vi.fn();
151+
const chat = <Chat model="gemma4" onCommand={onCommand} />;
152+
const { rerender } = render(chat);
153+
submitInput('/model');
154+
rerender(chat);
155+
await tick();
156+
expect(onCommand).toHaveBeenCalledWith('/model');
157+
});
158+
159+
it('passes model prop to streamChat', async () => {
160+
const { ollama } = await import('../utils');
161+
const { streamChat } = ollama;
162+
vi.mocked(streamChat).mockClear();
163+
164+
const chat = <Chat model="llama3" onCommand={vi.fn()} />;
165+
const { rerender } = render(chat);
166+
submitInput('hello');
167+
rerender(chat);
168+
await waitForStream();
169+
170+
expect(vi.mocked(streamChat)).toHaveBeenLastCalledWith(
171+
expect.any(Array),
172+
'llama3',
173+
);
174+
});
149175
});
150176

151177
describe('Chat with error', () => {
@@ -162,7 +188,7 @@ describe('Chat with error', () => {
162188
throw new Error('Connection failed');
163189
});
164190

165-
const chat = <Chat />;
191+
const chat = <Chat model="gemma4" onCommand={vi.fn()} />;
166192
const { lastFrame, rerender } = render(chat);
167193

168194
await typeText(rerender, 'hello', chat);
@@ -183,7 +209,7 @@ describe('Chat with error', () => {
183209
throw { toString: () => 'Custom error' };
184210
});
185211

186-
const chat = <Chat />;
212+
const chat = <Chat model="gemma4" onCommand={vi.fn()} />;
187213
const { lastFrame, rerender } = render(chat);
188214

189215
await typeText(rerender, 'hello', chat);

src/components/Chat.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import { ollama } from '../utils';
77

88
const PROMPT_PREFIX = '> ';
99

10-
export function Chat() {
10+
interface Props {
11+
model: string;
12+
onCommand: (command: string) => void;
13+
}
14+
15+
export function Chat({ model, onCommand }: Props) {
1116
const [messages, setMessages] = useState<ollama.Message[]>([]);
1217
const [submitKey, setSubmitKey] = useState(0);
1318
const [isLoading, setIsLoading] = useState(false);
@@ -18,6 +23,12 @@ export function Chat() {
1823
if (!userContent) return;
1924

2025
setSubmitKey((key) => key + 1);
26+
27+
if (userContent.startsWith('/')) {
28+
onCommand(userContent);
29+
return;
30+
}
31+
2132
setIsLoading(true);
2233

2334
const userMessage: ollama.Message = {
@@ -34,7 +45,7 @@ export function Chat() {
3445
setMessages((prev) => [...prev, assistantMessage]);
3546

3647
try {
37-
for await (const chunk of ollama.streamChat(updatedMessages)) {
48+
for await (const chunk of ollama.streamChat(updatedMessages, model)) {
3849
assistantMessage.content += chunk;
3950
setMessages((prev) => {
4051
const newMessages = [...prev];
@@ -53,7 +64,7 @@ export function Chat() {
5364
setIsLoading(false);
5465
}
5566
},
56-
[messages],
67+
[messages, model, onCommand],
5768
);
5869

5970
return (

0 commit comments

Comments
 (0)