Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions src/components/App/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const deleteSessionIfEmpty = vi.hoisted(() => vi.fn());
const appendMessage = vi.hoisted(() => vi.fn());
const updateSessionModel = vi.hoisted(() => vi.fn());
const saveConfig = vi.hoisted(() => vi.fn());
const checkHealth = vi.hoisted(() => vi.fn());
const listModels = vi.hoisted(() => vi.fn());

vi.mock('@/utils', async () => ({
Expand All @@ -46,6 +47,7 @@ vi.mock('@/utils', async () => ({
saveConfig,
},
ollama: {
checkHealth,
listModels,
},
screen: {
Expand Down Expand Up @@ -217,9 +219,11 @@ vi.mock('./ReadinessCheck', async () => {
? 'Select or download a model'
: setupState === 'no-installed-models'
? 'Download a model'
: errorMessage
? `Unable to load models: ${errorMessage}`
: setupState;
: setupState === 'server-unavailable'
? 'Run ollama serve'
: errorMessage
? `Unable to load models: ${errorMessage}`
: setupState;
return <Text>{`Setup Required ${message}`}</Text>;
},
};
Expand Down Expand Up @@ -254,7 +258,9 @@ describe('App', () => {
appendMessage.mockReset();
updateSessionModel.mockReset();
saveConfig.mockReset();
checkHealth.mockReset();
listModels.mockReset();
checkHealth.mockResolvedValue(true);
listModels.mockResolvedValue(['gemma4']);

let counter = 0;
Expand Down Expand Up @@ -542,6 +548,7 @@ describe('App', () => {
expect(lastFrame()).toContain('Setup Required');
expect(lastFrame()).toContain('Select or download a model');
expect(lastFrame()).not.toContain('session:');
expect(checkHealth).not.toHaveBeenCalled();
expect(listModels).not.toHaveBeenCalled();
});

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

it('renders setup-needed content when Ollama is unreachable', async () => {
checkHealth.mockResolvedValueOnce(false);

const { lastFrame } = render(<App />);
await time.tick();
await time.tick();

expect(lastFrame()).toContain('Setup Required');
expect(lastFrame()).toContain('Run ollama serve');
expect(lastFrame()).not.toContain('session:');
expect(listModels).not.toHaveBeenCalled();
});

it('renders model-load error content when listing models fails', async () => {
listModels.mockRejectedValueOnce(new Error('boom'));

const { lastFrame } = render(<App />);
await time.tick();
await time.tick();

expect(lastFrame()).toContain('Setup Required');
expect(lastFrame()).toContain('Unable to load models: boom');
expect(lastFrame()).not.toContain('session:');
});

it('routes to ModelManager from setup-needed state', async () => {
const { config } = await import('@/utils');
vi.mocked(config.loadConfig).mockReturnValueOnce({
Expand Down
10 changes: 9 additions & 1 deletion src/components/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,19 @@ export function App({ sessionId }: Props) {
}

try {
const installedModels = await ollama.listModels();
const isHealthy = await ollama.checkHealth();

if (!isMounted) {
return;
}

if (!isHealthy) {
setSetupState(ReadinessState.ServerUnavailable);
return;
}

const installedModels = await ollama.listModels();

setSetupState(
installedModels.length > 0
? ReadinessState.Ready
Expand Down
16 changes: 15 additions & 1 deletion src/components/App/ReadinessCheck.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe('ReadinessCheck', () => {
onCommand={vi.fn()}
/>,
);
expect(lastFrame()).toContain('Checking model setup');
expect(lastFrame()).toContain('Checking Ollama server and model setup');
});

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

it('renders server unavailable state', () => {
const { lastFrame } = render(
<ReadinessCheck
setupState={ReadinessState.ServerUnavailable}
onCommand={vi.fn()}
/>,
);
expect(lastFrame()).toContain('Ollama Server Unavailable');
expect(lastFrame()).toContain(
'Ollama server is not running or unreachable',
);
expect(lastFrame()).toContain('ollama serve');
});

it('renders model load error state with message', () => {
const { lastFrame } = render(
<ReadinessCheck
Expand Down
17 changes: 16 additions & 1 deletion src/components/App/ReadinessCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export enum ReadinessState {
Ready = 'ready',
MissingModelConfig = 'missing-model-config',
NoInstalledModels = 'no-installed-models',
ServerUnavailable = 'server-unavailable',
ModelLoadError = 'model-load-error',
}

Expand All @@ -21,6 +22,9 @@ interface Props {

function getTitle(setupState: ReadinessState): string | undefined {
switch (setupState) {
case ReadinessState.ServerUnavailable:
return 'Ollama Server Unavailable';

case ReadinessState.ModelLoadError:
return 'Connection Error';

Expand All @@ -40,7 +44,7 @@ function getMessage(

switch (setupState) {
case ReadinessState.Checking:
return <Text>Checking model setup...</Text>;
return <Text>Checking Ollama server and model setup...</Text>;

case ReadinessState.MissingModelConfig:
return (
Expand All @@ -57,6 +61,17 @@ function getMessage(
</Text>
);

case ReadinessState.ServerUnavailable:
return (
<>
<Text>Ollama server is not running or unreachable.</Text>
<Text>
Start it with <Text color={theme.colors.command}>ollama serve</Text>{' '}
and restart the app
</Text>
</>
);

case ReadinessState.ModelLoadError:
return (
<>
Expand Down
16 changes: 16 additions & 0 deletions src/components/ExitHint/ExitHint.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { render } from 'ink-testing-library';

import { ExitHint } from './ExitHint';

describe('ExitHint', () => {
it('renders with default action', () => {
const { lastFrame } = render(<ExitHint />);
expect(lastFrame()).toContain('Press Esc/Ctrl+C to go back.');
});

it('renders with custom action', () => {
const { lastFrame } = render(<ExitHint action="cancel" />);
expect(lastFrame()).toContain('Press Esc/Ctrl+C to cancel.');
expect(lastFrame()).not.toContain('go back');
});
});
25 changes: 25 additions & 0 deletions src/components/ExitHint/ExitHint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Text } from 'ink';

import { THEME } from '@/constants';

interface ExitHintProps {
action?: string;
}

/**
* Exit hint component that displays:
* Press Esc/Ctrl+C to go back.
*/
export function ExitHint({ action = 'go back' }: ExitHintProps) {
const theme = THEME.getTheme();

return (
<Text color={theme.colors.secondary}>
<Text dimColor>Press </Text>
<Text bold>Esc</Text>
<Text dimColor>/</Text>
<Text bold>Ctrl+C</Text>
<Text dimColor> to {action}.</Text>
</Text>
);
}
1 change: 1 addition & 0 deletions src/components/ExitHint/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ExitHint } from './ExitHint';
8 changes: 6 additions & 2 deletions src/components/ModelManager/ModelCustomDownloadView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Box, Text } from 'ink';

import { ExitHint } from '@/components';
import { UI } from '@/constants';
import type { ThemeDefinition } from '@/types';

Expand Down Expand Up @@ -56,8 +57,11 @@ export function ModelCustomDownloadView({

{renderNotice()}

<Text color={theme.colors.secondary} dimColor>
Press Enter to download, Esc or Ctrl+C to go back.
<Text>
<Text color={theme.colors.secondary} dimColor>
Press Enter to download.
</Text>{' '}
<ExitHint />
</Text>
</Box>
);
Expand Down
52 changes: 52 additions & 0 deletions src/components/ModelManager/ModelManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,58 @@ describe('ModelManager', () => {
expect(lastFrame()).toContain('Error loading models');
expect(lastFrame()).toContain('Network error');
});

it('returns to the menu from the load error screen with Escape', async () => {
mockListModels.mockRejectedValueOnce(new Error('fetch failed'));

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

await time.tick(10);

const props = getLastSelectProps();
props.onChange?.('switch');
await time.tick(10);

expect(lastFrame()).toContain('Error loading models: fetch failed');

stdin.write('\x1B\x1B');
await time.tick(20);

expect(lastFrame()).toContain('Switch model');
expect(lastFrame()).toContain('Download model');
});

it('returns to the menu from the load error screen with Ctrl+C', async () => {
mockListModels.mockRejectedValueOnce(new Error('fetch failed'));

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

await time.tick(10);

const props = getLastSelectProps();
props.onChange?.('switch');
await time.tick(10);

expect(lastFrame()).toContain('Error loading models: fetch failed');

stdin.write('\x03');
await time.tick(20);

expect(lastFrame()).toContain('Switch model');
expect(lastFrame()).toContain('Download model');
});
});

describe('switch view', () => {
Expand Down
16 changes: 10 additions & 6 deletions src/components/ModelManager/ModelManager.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Box, Text, useInput } from 'ink';
import { Text, useInput } from 'ink';
import { useCallback, useEffect, useRef, useState } from 'react';

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

if (loadError && view !== ViewEnum.Menu && (isEscape || isCtrlC)) {
handleBackToMenu();
return;
}

if (view === ViewEnum.CustomDownload && (isEscape || isCtrlC)) {
setNotice(null);
setHighlightedSuggestion(null);
Expand Down Expand Up @@ -314,14 +320,12 @@ export function ModelManager({

if (loadError && view !== ViewEnum.Menu) {
return (
<Box flexDirection="column">
<>
<Text color={theme.colors.error}>
Error loading models: {loadError}
</Text>
<Text color={theme.colors.secondary} dimColor>
Press Esc to go back.
</Text>
</Box>
<ExitHint />
</>
);
}

Expand Down
8 changes: 6 additions & 2 deletions src/components/SearchSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Box, Text, useInput } from 'ink';
import { useCallback, useMemo, useState } from 'react';

import { ExitHint } from '@/components';
import { THEME, UI } from '@/constants';
import type { Config, ThemeDefinition } from '@/types';

Expand Down Expand Up @@ -129,8 +130,11 @@ export function SearchSettings({

{error && <Text color={theme.colors.error}>{error}</Text>}

<Text color={theme.colors.secondary} dimColor>
Press Enter to save, Esc to go back.
<Text>
<Text color={theme.colors.secondary} dimColor>
Press Enter to save.
</Text>{' '}
<ExitHint />
</Text>
</Box>
);
Expand Down
1 change: 1 addition & 0 deletions src/components/SelectPrompt/SelectPromptHint.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ describe('SelectPromptHint', () => {
expect(lastFrame()).toContain('↑↓');
expect(lastFrame()).toContain('Enter');
expect(lastFrame()).toContain('Esc');
expect(lastFrame()).toContain('Ctrl+C');
expect(lastFrame()).toContain('cancel');
});

Expand Down
Loading