Skip to content
14 changes: 7 additions & 7 deletions src/components/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const capturedCallbacks = vi.hoisted(() => ({
onCommand: null as ((command: string) => void) | null,
onModeChange: null as ((mode: string) => void) | null,
onSelect: null as ((model: string) => void) | null,
onCancel: null as (() => void) | null,
onClose: null as (() => void) | null,
onToggleMode: null as (() => void) | null,
}));

Expand Down Expand Up @@ -47,14 +47,14 @@ vi.mock('./Chat', () => ({
vi.mock('./ModelPicker', () => ({
ModelPicker: ({
onSelect,
onCancel,
onClose,
}: {
currentModel: string;
onSelect: (model: string) => void;
onCancel: () => void;
onClose: () => void;
}) => {
capturedCallbacks.onSelect = onSelect;
capturedCallbacks.onCancel = onCancel;
capturedCallbacks.onClose = onClose;
return <Text>ModelPicker</Text>;
},
}));
Expand All @@ -80,7 +80,7 @@ describe('App', () => {
capturedCallbacks.onCommand = null;
capturedCallbacks.onModeChange = null;
capturedCallbacks.onSelect = null;
capturedCallbacks.onCancel = null;
capturedCallbacks.onClose = null;
capturedCallbacks.onToggleMode = null;
});

Expand Down Expand Up @@ -114,12 +114,12 @@ describe('App', () => {
expect(lastFrame()).not.toContain('ModelPicker');
});

it('returns to chat when onCancel is called', async () => {
it('returns to chat when onClose is called', async () => {
const { lastFrame, rerender } = render(<App />);
capturedCallbacks.onCommand?.('/model');
rerender(<App />);
await test.tick();
capturedCallbacks.onCancel?.();
capturedCallbacks.onClose?.();
rerender(<App />);
await test.tick();
expect(lastFrame()).not.toContain('ModelPicker');
Expand Down
4 changes: 2 additions & 2 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function App() {
setPicking(false);
}, []);

const handleCancel = useCallback(() => {
const handleClose = useCallback(() => {
setPicking(false);
}, []);

Expand All @@ -37,7 +37,7 @@ export function App() {
<ModelPicker
currentModel={model}
onSelect={handleSelect}
onCancel={handleCancel}
onClose={handleClose}
/>
) : (
<Chat
Expand Down
133 changes: 118 additions & 15 deletions src/components/ModelPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,22 @@ import { ModelPicker } from './ModelPicker';

describe('ModelPicker', () => {
beforeEach(() => {
mockListModels.mockReset();
mockOnChange.mockReset();
mockListModels.mockResolvedValue(['gemma4', 'llama3', 'codellama']);
});

afterEach(() => {
vi.useRealTimers();
});

it('shows loading state before models arrive', () => {
mockListModels.mockReturnValue(new Promise(() => undefined));
const { lastFrame } = render(
<ModelPicker
currentModel="gemma4"
onSelect={vi.fn()}
onCancel={vi.fn()}
onClose={vi.fn()}
/>,
);
expect(lastFrame()).toContain('Loading models');
Expand All @@ -63,7 +69,7 @@ describe('ModelPicker', () => {
<ModelPicker
currentModel="gemma4"
onSelect={vi.fn()}
onCancel={vi.fn()}
onClose={vi.fn()}
/>,
);
await test.tick(10);
Expand All @@ -78,40 +84,137 @@ describe('ModelPicker', () => {
<ModelPicker
currentModel="llama3"
onSelect={vi.fn()}
onCancel={vi.fn()}
onClose={vi.fn()}
/>,
);
await test.tick(10);
expect(lastFrame()).toContain('llama3');
});

it('renders current model first in the list', async () => {
const { lastFrame } = render(
<ModelPicker
currentModel="llama3"
onSelect={vi.fn()}
onClose={vi.fn()}
/>,
);

await test.tick(10);

const frame = lastFrame() ?? '';
expect(frame.indexOf('llama3')).toBeLessThan(frame.indexOf('gemma4'));
expect(frame.indexOf('llama3')).toBeLessThan(frame.indexOf('codellama'));
});

it('does not inject the current model when it is not in the fetched list', async () => {
mockListModels.mockResolvedValue(['gemma4', 'codellama']);

const { lastFrame } = render(
<ModelPicker
currentModel="llama3"
onSelect={vi.fn()}
onClose={vi.fn()}
/>,
);

await test.tick(10);

const frame = lastFrame() ?? '';
expect(frame).toContain('gemma4');
expect(frame).toContain('codellama');
expect(frame).not.toContain('llama3');
});

it('reloads and reorders options when currentModel changes', async () => {
const { lastFrame, rerender } = render(
<ModelPicker
currentModel="gemma4"
onSelect={vi.fn()}
onClose={vi.fn()}
/>,
);

await test.tick(10);

rerender(
<ModelPicker
currentModel="llama3"
onSelect={vi.fn()}
onClose={vi.fn()}
/>,
);

await test.tick(10);

const frame = lastFrame() ?? '';
expect(mockListModels).toHaveBeenCalledTimes(2);
expect(frame.indexOf('llama3')).toBeLessThan(frame.indexOf('gemma4'));
});

it('calls onSelect when a model is chosen', async () => {
const onSelect = vi.fn();
render(
<ModelPicker
currentModel="gemma4"
onSelect={onSelect}
onCancel={vi.fn()}
onClose={vi.fn()}
/>,
);
await test.tick(10);
mockOnChange('llama3');
expect(onSelect).toHaveBeenCalledWith('llama3');
});

it('calls onCancel on Escape', async () => {
const onCancel = vi.fn();
it('calls onClose on Escape', async () => {
const onClose = vi.fn();
const { stdin } = render(
<ModelPicker
currentModel="gemma4"
onSelect={vi.fn()}
onCancel={onCancel}
onClose={onClose}
/>,
);
await test.tick(10);
stdin.write(KEY.ESCAPE);
await test.tick(50);
expect(onCancel).toHaveBeenCalled();
await test.tick(20);
expect(onClose).toHaveBeenCalled();
});

it('does not call onClose on Enter while models are loading', async () => {
vi.useFakeTimers();
mockListModels.mockReturnValue(new Promise(() => undefined));

const onClose = vi.fn();
const { stdin } = render(
<ModelPicker
currentModel="gemma4"
onSelect={vi.fn()}
onClose={onClose}
/>,
);

stdin.write(KEY.ENTER);
await vi.runAllTimersAsync();

expect(onClose).not.toHaveBeenCalled();
});

it('calls onClose on Enter after models load', async () => {
const onClose = vi.fn();
const { stdin } = render(
<ModelPicker
currentModel="gemma4"
onSelect={vi.fn()}
onClose={onClose}
/>,
);

await test.tick(10);
stdin.write(KEY.ENTER);
await test.tick(10);

expect(onClose).toHaveBeenCalledTimes(1);
});

it('shows error when listModels fails', async () => {
Expand All @@ -120,7 +223,7 @@ describe('ModelPicker', () => {
<ModelPicker
currentModel="gemma4"
onSelect={vi.fn()}
onCancel={vi.fn()}
onClose={vi.fn()}
/>,
);
await test.tick(10);
Expand All @@ -133,25 +236,25 @@ describe('ModelPicker', () => {
<ModelPicker
currentModel="gemma4"
onSelect={vi.fn()}
onCancel={vi.fn()}
onClose={vi.fn()}
/>,
);
await test.tick(10);
expect(lastFrame()).toContain('Error loading models: network timeout');
});

it('does not call onCancel for non-escape keys', async () => {
const onCancel = vi.fn();
it('does not call onClose for non-enter keys', async () => {
const onClose = vi.fn();
const { stdin } = render(
<ModelPicker
currentModel="gemma4"
onSelect={vi.fn()}
onCancel={onCancel}
onClose={onClose}
/>,
);
await test.tick(10);
stdin.write('a');
await test.tick(10);
expect(onCancel).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
});
49 changes: 28 additions & 21 deletions src/components/ModelPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,47 @@
import { Select, Spinner } from '@inkjs/ui';
import { Box, Text, useInput } from 'ink';
import { Spinner } from '@inkjs/ui';
import { Text, useInput } from 'ink';
import { useEffect, useState } from 'react';

import { ollama } from '../utils';
import { SelectPrompt } from './SelectPrompt';

interface Props {
currentModel: string;
onSelect: (model: string) => void;
onCancel: () => void;
onClose: () => void;
}

export function ModelPicker({ currentModel, onSelect, onCancel }: Props) {
export function ModelPicker({ currentModel, onSelect, onClose }: Props) {
const [options, setOptions] = useState<{ label: string; value: string }[]>(
[],
);
const [error, setError] = useState<string | null>(null);

// close select prompt if current model is chosen
useInput((_, key) => {
if (options.length && key.return) {
setTimeout(onClose);
}
});

useEffect(() => {
async function load() {
try {
const list = await ollama.listModels();
setOptions(list.map((name) => ({ label: name, value: name })));
const models = await ollama.listModels();
if (models.includes(currentModel)) {
models.splice(models.indexOf(currentModel), 1);
models.unshift(currentModel);
}

const options = models.map((model) => ({ label: model, value: model }));
setOptions(options);
} catch (error: unknown) {
setError(error instanceof Error ? error.message : String(error));
}
}

void load();
}, []);

useInput((_, key) => {
if (key.escape) {
onCancel();
}
});
}, [currentModel]);

if (error) {
return <Text color="red">Error loading models: {error}</Text>;
Expand All @@ -44,16 +52,15 @@ export function ModelPicker({ currentModel, onSelect, onCancel }: Props) {
}

return (
<Box flexDirection="column">
<SelectPrompt
options={options}
defaultValue={currentModel}
onChange={onSelect}
onEscape={onClose}
>
<Text dimColor>
Select a model (↑↓ + Enter to confirm, Esc to cancel)
</Text>

<Select
options={options}
defaultValue={currentModel}
onChange={onSelect}
/>
</Box>
</SelectPrompt>
);
}
4 changes: 2 additions & 2 deletions src/components/PlanApproval.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe('PlanApproval', () => {
);

stdin.write(KEY.ESCAPE);
await test.tick(50);
await test.tick(20);

expect(onModeChange).toHaveBeenCalledWith(MODE.NAME.PLAN);
});
Expand All @@ -96,7 +96,7 @@ describe('PlanApproval', () => {
);

stdin.write(KEY.ENTER);
await test.tick(50);
await test.tick(20);

expect(onModeChange).not.toHaveBeenCalled();
});
Expand Down
Loading