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,