diff --git a/src/components/App/App.test.tsx b/src/components/App/App.test.tsx
index ed189df..31465a6 100644
--- a/src/components/App/App.test.tsx
+++ b/src/components/App/App.test.tsx
@@ -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 () => ({
@@ -46,6 +47,7 @@ vi.mock('@/utils', async () => ({
saveConfig,
},
ollama: {
+ checkHealth,
listModels,
},
screen: {
@@ -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 {`Setup Required ${message}`};
},
};
@@ -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;
@@ -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();
});
@@ -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();
+ 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();
+ 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({
diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx
index 47a262d..fce0434 100644
--- a/src/components/App/App.tsx
+++ b/src/components/App/App.tsx
@@ -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
diff --git a/src/components/App/ReadinessCheck.test.tsx b/src/components/App/ReadinessCheck.test.tsx
index d8f0428..5b8f765 100644
--- a/src/components/App/ReadinessCheck.test.tsx
+++ b/src/components/App/ReadinessCheck.test.tsx
@@ -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', () => {
@@ -58,6 +58,20 @@ describe('ReadinessCheck', () => {
expect(lastFrame()).toContain('Fix the connection and restart the app');
});
+ it('renders server unavailable state', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ 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(
Checking model setup...;
+ return Checking Ollama server and model setup...;
case ReadinessState.MissingModelConfig:
return (
@@ -57,6 +61,17 @@ function getMessage(
);
+ case ReadinessState.ServerUnavailable:
+ return (
+ <>
+ Ollama server is not running or unreachable.
+
+ Start it with ollama serve{' '}
+ and restart the app
+
+ >
+ );
+
case ReadinessState.ModelLoadError:
return (
<>
diff --git a/src/components/ExitHint/ExitHint.test.tsx b/src/components/ExitHint/ExitHint.test.tsx
new file mode 100644
index 0000000..0875229
--- /dev/null
+++ b/src/components/ExitHint/ExitHint.test.tsx
@@ -0,0 +1,16 @@
+import { render } from 'ink-testing-library';
+
+import { ExitHint } from './ExitHint';
+
+describe('ExitHint', () => {
+ it('renders with default action', () => {
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('Press Esc/Ctrl+C to go back.');
+ });
+
+ it('renders with custom action', () => {
+ const { lastFrame } = render();
+ expect(lastFrame()).toContain('Press Esc/Ctrl+C to cancel.');
+ expect(lastFrame()).not.toContain('go back');
+ });
+});
diff --git a/src/components/ExitHint/ExitHint.tsx b/src/components/ExitHint/ExitHint.tsx
new file mode 100644
index 0000000..42c7d32
--- /dev/null
+++ b/src/components/ExitHint/ExitHint.tsx
@@ -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 (
+
+ Press
+ Esc
+ /
+ Ctrl+C
+ to {action}.
+
+ );
+}
diff --git a/src/components/ExitHint/index.ts b/src/components/ExitHint/index.ts
new file mode 100644
index 0000000..a1ee71d
--- /dev/null
+++ b/src/components/ExitHint/index.ts
@@ -0,0 +1 @@
+export { ExitHint } from './ExitHint';
diff --git a/src/components/ModelManager/ModelCustomDownloadView.tsx b/src/components/ModelManager/ModelCustomDownloadView.tsx
index fa0225d..d818871 100644
--- a/src/components/ModelManager/ModelCustomDownloadView.tsx
+++ b/src/components/ModelManager/ModelCustomDownloadView.tsx
@@ -1,5 +1,6 @@
import { Box, Text } from 'ink';
+import { ExitHint } from '@/components';
import { UI } from '@/constants';
import type { ThemeDefinition } from '@/types';
@@ -56,8 +57,11 @@ export function ModelCustomDownloadView({
{renderNotice()}
-
- Press Enter to download, Esc or Ctrl+C to go back.
+
+
+ Press Enter to download.
+ {' '}
+
);
diff --git a/src/components/ModelManager/ModelManager.test.tsx b/src/components/ModelManager/ModelManager.test.tsx
index c57080c..d07caec 100644
--- a/src/components/ModelManager/ModelManager.test.tsx
+++ b/src/components/ModelManager/ModelManager.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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', () => {
diff --git a/src/components/ModelManager/ModelManager.tsx b/src/components/ModelManager/ModelManager.tsx
index 8ddb449..b458951 100644
--- a/src/components/ModelManager/ModelManager.tsx
+++ b/src/components/ModelManager/ModelManager.tsx
@@ -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';
@@ -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);
@@ -314,14 +320,12 @@ export function ModelManager({
if (loadError && view !== ViewEnum.Menu) {
return (
-
+ <>
Error loading models: {loadError}
-
- Press Esc to go back.
-
-
+
+ >
);
}
diff --git a/src/components/SearchSettings.tsx b/src/components/SearchSettings.tsx
index 25def4b..303fb59 100644
--- a/src/components/SearchSettings.tsx
+++ b/src/components/SearchSettings.tsx
@@ -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';
@@ -129,8 +130,11 @@ export function SearchSettings({
{error && {error}}
-
- Press Enter to save, Esc to go back.
+
+
+ Press Enter to save.
+ {' '}
+
);
diff --git a/src/components/SelectPrompt/SelectPromptHint.test.tsx b/src/components/SelectPrompt/SelectPromptHint.test.tsx
index 69f547a..0115252 100644
--- a/src/components/SelectPrompt/SelectPromptHint.test.tsx
+++ b/src/components/SelectPrompt/SelectPromptHint.test.tsx
@@ -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');
});
diff --git a/src/components/SelectPrompt/SelectPromptHint.tsx b/src/components/SelectPrompt/SelectPromptHint.tsx
index 6aa1be6..a8cf6c5 100644
--- a/src/components/SelectPrompt/SelectPromptHint.tsx
+++ b/src/components/SelectPrompt/SelectPromptHint.tsx
@@ -1,24 +1,33 @@
-import { Box, Text } from 'ink';
+import { Text } from 'ink';
+
+import { THEME } from '@/constants';
interface SelectPromptHintProps {
message?: string;
escapeLabel?: string;
}
+/**
+ * Select prompt hint component that displays:
+ * Select option (↑↓ + Enter to confirm, Esc/Ctrl+C to cancel)
+ */
export function SelectPromptHint({
message = 'Select option',
escapeLabel = 'cancel',
}: SelectPromptHintProps) {
+ const theme = THEME.getTheme();
+
return (
- // Select option (↑↓ + Enter to confirm, Esc to cancel)
-
+
{message} (
↑↓
+
Enter
to confirm,
Esc
+ /
+ Ctrl+C
to {escapeLabel})
-
+
);
}
diff --git a/src/components/index.ts b/src/components/index.ts
index c1599f7..3012e9c 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,6 +1,7 @@
export { App } from './App';
export { Chat } from './Chat';
export { CodeBlock } from './CodeBlock';
+export { ExitHint } from './ExitHint';
export { Footer } from './Footer';
export { Header } from './Header';
export { Markdown } from './Markdown';
diff --git a/src/utils/ollama.test.ts b/src/utils/ollama.test.ts
index 2ac2267..17ad3ad 100644
--- a/src/utils/ollama.test.ts
+++ b/src/utils/ollama.test.ts
@@ -1,9 +1,12 @@
-const { mockChat, mockDelete, mockList, mockPull } = vi.hoisted(() => ({
- mockChat: vi.fn(),
- mockDelete: vi.fn(),
- mockList: vi.fn(),
- mockPull: vi.fn(),
-}));
+const { mockChat, mockDelete, mockFetch, mockList, mockPull } = vi.hoisted(
+ () => ({
+ mockChat: vi.fn(),
+ mockDelete: vi.fn(),
+ mockFetch: vi.fn(),
+ mockList: vi.fn(),
+ mockPull: vi.fn(),
+ }),
+);
vi.mock('ollama', () => ({
Ollama: class MockOllama {
@@ -25,16 +28,24 @@ vi.mock('ollama', () => ({
},
}));
-import { deleteModel, listModels, pullModel, streamChat } from './ollama';
+import {
+ checkHealth,
+ deleteModel,
+ listModels,
+ pullModel,
+ streamChat,
+} from './ollama';
describe('ollama', () => {
beforeEach(() => {
+ vi.stubGlobal('fetch', mockFetch);
mockChat.mockResolvedValue({
async *[Symbol.asyncIterator]() {
await Promise.resolve();
yield { message: { content: 'Hello' } };
},
});
+ mockFetch.mockResolvedValue({ ok: true, status: 200 });
mockList.mockResolvedValue({
models: [{ name: 'codellama' }, { name: 'llama2' }],
});
@@ -53,6 +64,29 @@ describe('ollama', () => {
mockDelete.mockResolvedValue({ status: 'success' });
});
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ describe('checkHealth', () => {
+ it('returns true when the server is reachable', async () => {
+ await expect(checkHealth()).resolves.toBe(true);
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:11434');
+ });
+
+ it('returns false when the server is unreachable', async () => {
+ mockFetch.mockRejectedValueOnce(new Error('connect ECONNREFUSED'));
+
+ await expect(checkHealth()).resolves.toBe(false);
+ });
+
+ it('returns false when the server responds without an ok status', async () => {
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 503 });
+
+ await expect(checkHealth()).resolves.toBe(false);
+ });
+ });
+
describe('streamChat', () => {
it('yields content from stream', async () => {
const messages = [{ role: 'user' as const, content: 'hello' }];
diff --git a/src/utils/ollama.ts b/src/utils/ollama.ts
index 2f11bc6..628016a 100644
--- a/src/utils/ollama.ts
+++ b/src/utils/ollama.ts
@@ -25,6 +25,15 @@ export type StreamChunk =
| { type: 'content'; content: string }
| { type: 'tool_calls'; tool_calls: ToolCall[] };
+export async function checkHealth(): Promise {
+ try {
+ const response = await fetch(host);
+ return response.ok;
+ } catch {
+ return false;
+ }
+}
+
export async function* streamChat(
messages: Message[],
model: string,