Skip to content

Commit 7b73189

Browse files
Merge pull request #88 from ai-action/fix/ollama
2 parents b5887e8 + a76e3c7 commit 7b73189

16 files changed

Lines changed: 256 additions & 27 deletions

src/components/App/App.test.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const deleteSessionIfEmpty = vi.hoisted(() => vi.fn());
2929
const appendMessage = vi.hoisted(() => vi.fn());
3030
const updateSessionModel = vi.hoisted(() => vi.fn());
3131
const saveConfig = vi.hoisted(() => vi.fn());
32+
const checkHealth = vi.hoisted(() => vi.fn());
3233
const listModels = vi.hoisted(() => vi.fn());
3334

3435
vi.mock('@/utils', async () => ({
@@ -46,6 +47,7 @@ vi.mock('@/utils', async () => ({
4647
saveConfig,
4748
},
4849
ollama: {
50+
checkHealth,
4951
listModels,
5052
},
5153
screen: {
@@ -217,9 +219,11 @@ vi.mock('./ReadinessCheck', async () => {
217219
? 'Select or download a model'
218220
: setupState === 'no-installed-models'
219221
? 'Download a model'
220-
: errorMessage
221-
? `Unable to load models: ${errorMessage}`
222-
: setupState;
222+
: setupState === 'server-unavailable'
223+
? 'Run ollama serve'
224+
: errorMessage
225+
? `Unable to load models: ${errorMessage}`
226+
: setupState;
223227
return <Text>{`Setup Required ${message}`}</Text>;
224228
},
225229
};
@@ -254,7 +258,9 @@ describe('App', () => {
254258
appendMessage.mockReset();
255259
updateSessionModel.mockReset();
256260
saveConfig.mockReset();
261+
checkHealth.mockReset();
257262
listModels.mockReset();
263+
checkHealth.mockResolvedValue(true);
258264
listModels.mockResolvedValue(['gemma4']);
259265

260266
let counter = 0;
@@ -542,6 +548,7 @@ describe('App', () => {
542548
expect(lastFrame()).toContain('Setup Required');
543549
expect(lastFrame()).toContain('Select or download a model');
544550
expect(lastFrame()).not.toContain('session:');
551+
expect(checkHealth).not.toHaveBeenCalled();
545552
expect(listModels).not.toHaveBeenCalled();
546553
});
547554

@@ -557,6 +564,31 @@ describe('App', () => {
557564
expect(lastFrame()).not.toContain('session:');
558565
});
559566

567+
it('renders setup-needed content when Ollama is unreachable', async () => {
568+
checkHealth.mockResolvedValueOnce(false);
569+
570+
const { lastFrame } = render(<App />);
571+
await time.tick();
572+
await time.tick();
573+
574+
expect(lastFrame()).toContain('Setup Required');
575+
expect(lastFrame()).toContain('Run ollama serve');
576+
expect(lastFrame()).not.toContain('session:');
577+
expect(listModels).not.toHaveBeenCalled();
578+
});
579+
580+
it('renders model-load error content when listing models fails', async () => {
581+
listModels.mockRejectedValueOnce(new Error('boom'));
582+
583+
const { lastFrame } = render(<App />);
584+
await time.tick();
585+
await time.tick();
586+
587+
expect(lastFrame()).toContain('Setup Required');
588+
expect(lastFrame()).toContain('Unable to load models: boom');
589+
expect(lastFrame()).not.toContain('session:');
590+
});
591+
560592
it('routes to ModelManager from setup-needed state', async () => {
561593
const { config } = await import('@/utils');
562594
vi.mocked(config.loadConfig).mockReturnValueOnce({

src/components/App/App.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,19 @@ export function App({ sessionId }: Props) {
7171
}
7272

7373
try {
74-
const installedModels = await ollama.listModels();
74+
const isHealthy = await ollama.checkHealth();
75+
7576
if (!isMounted) {
7677
return;
7778
}
7879

80+
if (!isHealthy) {
81+
setSetupState(ReadinessState.ServerUnavailable);
82+
return;
83+
}
84+
85+
const installedModels = await ollama.listModels();
86+
7987
setSetupState(
8088
installedModels.length > 0
8189
? ReadinessState.Ready

src/components/App/ReadinessCheck.test.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe('ReadinessCheck', () => {
1919
onCommand={vi.fn()}
2020
/>,
2121
);
22-
expect(lastFrame()).toContain('Checking model setup');
22+
expect(lastFrame()).toContain('Checking Ollama server and model setup');
2323
});
2424

2525
it('renders missing model config state', () => {
@@ -58,6 +58,20 @@ describe('ReadinessCheck', () => {
5858
expect(lastFrame()).toContain('Fix the connection and restart the app');
5959
});
6060

61+
it('renders server unavailable state', () => {
62+
const { lastFrame } = render(
63+
<ReadinessCheck
64+
setupState={ReadinessState.ServerUnavailable}
65+
onCommand={vi.fn()}
66+
/>,
67+
);
68+
expect(lastFrame()).toContain('Ollama Server Unavailable');
69+
expect(lastFrame()).toContain(
70+
'Ollama server is not running or unreachable',
71+
);
72+
expect(lastFrame()).toContain('ollama serve');
73+
});
74+
6175
it('renders model load error state with message', () => {
6276
const { lastFrame } = render(
6377
<ReadinessCheck

src/components/App/ReadinessCheck.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export enum ReadinessState {
99
Ready = 'ready',
1010
MissingModelConfig = 'missing-model-config',
1111
NoInstalledModels = 'no-installed-models',
12+
ServerUnavailable = 'server-unavailable',
1213
ModelLoadError = 'model-load-error',
1314
}
1415

@@ -21,6 +22,9 @@ interface Props {
2122

2223
function getTitle(setupState: ReadinessState): string | undefined {
2324
switch (setupState) {
25+
case ReadinessState.ServerUnavailable:
26+
return 'Ollama Server Unavailable';
27+
2428
case ReadinessState.ModelLoadError:
2529
return 'Connection Error';
2630

@@ -40,7 +44,7 @@ function getMessage(
4044

4145
switch (setupState) {
4246
case ReadinessState.Checking:
43-
return <Text>Checking model setup...</Text>;
47+
return <Text>Checking Ollama server and model setup...</Text>;
4448

4549
case ReadinessState.MissingModelConfig:
4650
return (
@@ -57,6 +61,17 @@ function getMessage(
5761
</Text>
5862
);
5963

64+
case ReadinessState.ServerUnavailable:
65+
return (
66+
<>
67+
<Text>Ollama server is not running or unreachable.</Text>
68+
<Text>
69+
Start it with <Text color={theme.colors.command}>ollama serve</Text>{' '}
70+
and restart the app
71+
</Text>
72+
</>
73+
);
74+
6075
case ReadinessState.ModelLoadError:
6176
return (
6277
<>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { render } from 'ink-testing-library';
2+
3+
import { ExitHint } from './ExitHint';
4+
5+
describe('ExitHint', () => {
6+
it('renders with default action', () => {
7+
const { lastFrame } = render(<ExitHint />);
8+
expect(lastFrame()).toContain('Press Esc/Ctrl+C to go back.');
9+
});
10+
11+
it('renders with custom action', () => {
12+
const { lastFrame } = render(<ExitHint action="cancel" />);
13+
expect(lastFrame()).toContain('Press Esc/Ctrl+C to cancel.');
14+
expect(lastFrame()).not.toContain('go back');
15+
});
16+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Text } from 'ink';
2+
3+
import { THEME } from '@/constants';
4+
5+
interface ExitHintProps {
6+
action?: string;
7+
}
8+
9+
/**
10+
* Exit hint component that displays:
11+
* Press Esc/Ctrl+C to go back.
12+
*/
13+
export function ExitHint({ action = 'go back' }: ExitHintProps) {
14+
const theme = THEME.getTheme();
15+
16+
return (
17+
<Text color={theme.colors.secondary}>
18+
<Text dimColor>Press </Text>
19+
<Text bold>Esc</Text>
20+
<Text dimColor>/</Text>
21+
<Text bold>Ctrl+C</Text>
22+
<Text dimColor> to {action}.</Text>
23+
</Text>
24+
);
25+
}

src/components/ExitHint/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ExitHint } from './ExitHint';

src/components/ModelManager/ModelCustomDownloadView.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Box, Text } from 'ink';
22

3+
import { ExitHint } from '@/components';
34
import { UI } from '@/constants';
45
import type { ThemeDefinition } from '@/types';
56

@@ -56,8 +57,11 @@ export function ModelCustomDownloadView({
5657

5758
{renderNotice()}
5859

59-
<Text color={theme.colors.secondary} dimColor>
60-
Press Enter to download, Esc or Ctrl+C to go back.
60+
<Text>
61+
<Text color={theme.colors.secondary} dimColor>
62+
Press Enter to download.
63+
</Text>{' '}
64+
<ExitHint />
6165
</Text>
6266
</Box>
6367
);

src/components/ModelManager/ModelManager.test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,58 @@ describe('ModelManager', () => {
198198
expect(lastFrame()).toContain('Error loading models');
199199
expect(lastFrame()).toContain('Network error');
200200
});
201+
202+
it('returns to the menu from the load error screen with Escape', async () => {
203+
mockListModels.mockRejectedValueOnce(new Error('fetch failed'));
204+
205+
const { lastFrame, stdin } = render(
206+
<ModelManager
207+
currentModel="gemma4"
208+
onSelect={vi.fn()}
209+
onClose={vi.fn()}
210+
/>,
211+
);
212+
213+
await time.tick(10);
214+
215+
const props = getLastSelectProps();
216+
props.onChange?.('switch');
217+
await time.tick(10);
218+
219+
expect(lastFrame()).toContain('Error loading models: fetch failed');
220+
221+
stdin.write('\x1B\x1B');
222+
await time.tick(20);
223+
224+
expect(lastFrame()).toContain('Switch model');
225+
expect(lastFrame()).toContain('Download model');
226+
});
227+
228+
it('returns to the menu from the load error screen with Ctrl+C', async () => {
229+
mockListModels.mockRejectedValueOnce(new Error('fetch failed'));
230+
231+
const { lastFrame, stdin } = render(
232+
<ModelManager
233+
currentModel="gemma4"
234+
onSelect={vi.fn()}
235+
onClose={vi.fn()}
236+
/>,
237+
);
238+
239+
await time.tick(10);
240+
241+
const props = getLastSelectProps();
242+
props.onChange?.('switch');
243+
await time.tick(10);
244+
245+
expect(lastFrame()).toContain('Error loading models: fetch failed');
246+
247+
stdin.write('\x03');
248+
await time.tick(20);
249+
250+
expect(lastFrame()).toContain('Switch model');
251+
expect(lastFrame()).toContain('Download model');
252+
});
201253
});
202254

203255
describe('switch view', () => {

src/components/ModelManager/ModelManager.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { Box, Text, useInput } from 'ink';
1+
import { Text, useInput } from 'ink';
22
import { useCallback, useEffect, useRef, useState } from 'react';
33

4+
import { ExitHint } from '@/components';
45
import { KEY, THEME, UI } from '@/constants';
56
import type { ThemeDefinition } from '@/types';
67
import { ollama } from '@/utils';
@@ -90,6 +91,11 @@ export function ModelManager({
9091
const isEscape = key.escape || input === KEY.ESCAPE;
9192
const isCtrlC = (key.ctrl && input === 'c') || input === KEY.CTRL_C;
9293

94+
if (loadError && view !== ViewEnum.Menu && (isEscape || isCtrlC)) {
95+
handleBackToMenu();
96+
return;
97+
}
98+
9399
if (view === ViewEnum.CustomDownload && (isEscape || isCtrlC)) {
94100
setNotice(null);
95101
setHighlightedSuggestion(null);
@@ -314,14 +320,12 @@ export function ModelManager({
314320

315321
if (loadError && view !== ViewEnum.Menu) {
316322
return (
317-
<Box flexDirection="column">
323+
<>
318324
<Text color={theme.colors.error}>
319325
Error loading models: {loadError}
320326
</Text>
321-
<Text color={theme.colors.secondary} dimColor>
322-
Press Esc to go back.
323-
</Text>
324-
</Box>
327+
<ExitHint />
328+
</>
325329
);
326330
}
327331

0 commit comments

Comments
 (0)