From 0a628c4258b7f59f62393925bbbf15de045f6c82 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 18 May 2026 22:25:10 -0400 Subject: [PATCH 1/4] fix(ollama): add ollama health check to app readiness Add an explicit `checkHealth()` utility in `src/utils/ollama.ts` and use it in `src/components/App/App.tsx` to distinguish "Ollama server is unavailable" from generic model-loading failures. Keep `src/components/App/ReadinessCheck.tsx` presentation-only. Key Changes: - Add `checkHealth(): Promise` to `src/utils/ollama.ts`. Probe the configured Ollama `host` with a lightweight HTTP request and return `true` for a successful reachable response, `false` for network or unreachable failures. - Update `ReadinessState` in `ReadinessCheck.tsx` to add `ServerUnavailable`. - Update `App.tsx` readiness flow: If no configured model, keep `MissingModelConfig`. If on non-chat screens, keep current behavior and do not re-run readiness work. On chat screen with a configured model, set `Checking`, call `checkHealth()`, and: if `false`, set `ServerUnavailable` and skip `listModels()` if `true`, call `listModels()` if models exist, set `Ready` if no models exist, set `NoInstalledModels` if `listModels()` throws after a healthy probe, set `ModelLoadError` and preserve the thrown message - Update `ReadinessCheck.tsx` UI copy: `Checking`: clarify it is checking Ollama/model setup `ServerUnavailable`: show that the Ollama server is not running or unreachable and instruct the user to run `ollama serve` Keep existing model-config and no-installed-models flows unchanged Keep `ModelLoadError` for unexpected post-healthcheck failures - Export shape remains local to `ollama.ts`; no new module is needed. Public Interfaces: - `src/utils/ollama.ts` Add `checkHealth(): Promise` - `src/components/App/ReadinessCheck.tsx` Add `ReadinessState.ServerUnavailable` Test Plan: - Add `ollama.ts` tests for `checkHealth()`: reachable host returns `true` connection failure returns `false` non-network unexpected fetch/setup error behavior matches chosen implementation contract - Update `App.test.tsx`: healthy server + installed models -> chat renders unhealthy server -> readiness screen renders server-unavailable copy and does not depend on `listModels()` success healthy server + zero models -> `NoInstalledModels` healthy server + `listModels()` throws -> `ModelLoadError` - Update `ReadinessCheck.test.tsx`: render coverage for `ServerUnavailable` preserve current expectations for other states Assumptions: - `checkHealth()` will use the same configured `host` already loaded for the Ollama client, not a hardcoded `http://localhost:11434`. - A successful HTTP response from the Ollama base URL is sufficient to treat the server as reachable. - `checkHealth()` is a read-only probe and does not need to expose status codes to callers in v1; `App` only needs a boolean reachability result. --- src/components/App/App.test.tsx | 38 +++++++++++++++-- src/components/App/App.tsx | 10 ++++- src/components/App/ReadinessCheck.test.tsx | 16 +++++++- src/components/App/ReadinessCheck.tsx | 17 +++++++- src/utils/ollama.test.ts | 48 ++++++++++++++++++---- src/utils/ollama.ts | 9 ++++ 6 files changed, 125 insertions(+), 13 deletions(-) diff --git a/src/components/App/App.test.tsx b/src/components/App/App.test.tsx index ed189df9..31465a64 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 47a262d7..fce04341 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 d8f04287..5b8f7656 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/utils/ollama.test.ts b/src/utils/ollama.test.ts index 2ac22677..17ad3ad2 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 2f11bc60..628016ae 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, From a83a4e159cb26eea2ced134e6839d245e472d9c8 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 18 May 2026 22:33:53 -0400 Subject: [PATCH 2/4] fix(ModelManager): handle Esc/Ctrl+C for error state The load-error screen was rendering in a non-menu view, but the `useInput` handler only handled `Esc`/`Ctrl+C` for custom download and active download states, so the error screen prompt was dead. I added a load-error branch that sends `Esc` and `Ctrl+C` back to the menu, and updated the hint text to match. --- .../ModelManager/ModelManager.test.tsx | 52 +++++++++++++++++++ src/components/ModelManager/ModelManager.tsx | 7 ++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/components/ModelManager/ModelManager.test.tsx b/src/components/ModelManager/ModelManager.test.tsx index c57080c3..d07caeca 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 8ddb4497..81167acd 100644 --- a/src/components/ModelManager/ModelManager.tsx +++ b/src/components/ModelManager/ModelManager.tsx @@ -90,6 +90,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); @@ -319,7 +324,7 @@ export function ModelManager({ Error loading models: {loadError} - Press Esc to go back. + Press Esc or Ctrl+C to go back. ); From c707edae785a27e7600880e99ed7e04d796f7cf3 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 18 May 2026 22:50:14 -0400 Subject: [PATCH 3/4] refactor(components): add ExitHint component and update hints to include Ctrl+C - Created `ExitHint` component with internal theme handling - Updated `SelectPromptHint` to show "Esc/Ctrl+C" (using / separator) - Replaced inline exit hints with in 3 files --- src/components/ExitHint/ExitHint.test.tsx | 16 ++++++++++++++++ src/components/ExitHint/ExitHint.tsx | 17 +++++++++++++++++ src/components/ExitHint/index.ts | 1 + .../ModelManager/ModelCustomDownloadView.tsx | 8 ++++++-- src/components/ModelManager/ModelManager.tsx | 11 +++++------ src/components/SearchSettings.tsx | 8 ++++++-- .../SelectPrompt/SelectPromptHint.test.tsx | 1 + .../SelectPrompt/SelectPromptHint.tsx | 10 ++++++---- src/components/index.ts | 1 + 9 files changed, 59 insertions(+), 14 deletions(-) create mode 100644 src/components/ExitHint/ExitHint.test.tsx create mode 100644 src/components/ExitHint/ExitHint.tsx create mode 100644 src/components/ExitHint/index.ts diff --git a/src/components/ExitHint/ExitHint.test.tsx b/src/components/ExitHint/ExitHint.test.tsx new file mode 100644 index 00000000..ab873d72 --- /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 or Ctrl+C to go back.'); + }); + + it('renders with custom action', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('Press Esc or 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 00000000..a1200855 --- /dev/null +++ b/src/components/ExitHint/ExitHint.tsx @@ -0,0 +1,17 @@ +import { Text } from 'ink'; + +import { THEME } from '@/constants'; + +interface ExitHintProps { + action?: string; +} + +export function ExitHint({ action = 'go back' }: ExitHintProps) { + const theme = THEME.getTheme(); + + return ( + + Press Esc or Ctrl+C to {action}. + + ); +} diff --git a/src/components/ExitHint/index.ts b/src/components/ExitHint/index.ts new file mode 100644 index 00000000..a1ee71d2 --- /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 fa0225d8..d8188711 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.tsx b/src/components/ModelManager/ModelManager.tsx index 81167acd..b458951c 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'; @@ -319,14 +320,12 @@ export function ModelManager({ if (loadError && view !== ViewEnum.Menu) { return ( - + <> Error loading models: {loadError} - - Press Esc or Ctrl+C to go back. - - + + ); } diff --git a/src/components/SearchSettings.tsx b/src/components/SearchSettings.tsx index 25def4ba..303fb59f 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 69f547a7..01152523 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 6aa1be60..74a839c9 100644 --- a/src/components/SelectPrompt/SelectPromptHint.tsx +++ b/src/components/SelectPrompt/SelectPromptHint.tsx @@ -1,4 +1,4 @@ -import { Box, Text } from 'ink'; +import { Text } from 'ink'; interface SelectPromptHintProps { message?: string; @@ -10,15 +10,17 @@ export function SelectPromptHint({ escapeLabel = 'cancel', }: SelectPromptHintProps) { return ( - // Select option (↑↓ + Enter to confirm, Esc to cancel) - + // Select option (↑↓ + Enter to confirm, Esc/Ctrl+C to cancel) + {message} ( ↑↓ + Enter to confirm, Esc + / + Ctrl+C to {escapeLabel}) - + ); } diff --git a/src/components/index.ts b/src/components/index.ts index c1599f77..3012e9c5 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'; From a76e3c7b7ba2110ce0dda852b4cfa87d5a84ab80 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 18 May 2026 23:00:21 -0400 Subject: [PATCH 4/4] refactor(components): apply consistent styling to hint components --- src/components/ExitHint/ExitHint.test.tsx | 4 ++-- src/components/ExitHint/ExitHint.tsx | 12 ++++++++++-- src/components/SelectPrompt/SelectPromptHint.tsx | 11 +++++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/components/ExitHint/ExitHint.test.tsx b/src/components/ExitHint/ExitHint.test.tsx index ab873d72..0875229b 100644 --- a/src/components/ExitHint/ExitHint.test.tsx +++ b/src/components/ExitHint/ExitHint.test.tsx @@ -5,12 +5,12 @@ import { ExitHint } from './ExitHint'; describe('ExitHint', () => { it('renders with default action', () => { const { lastFrame } = render(); - expect(lastFrame()).toContain('Press Esc or Ctrl+C to go back.'); + expect(lastFrame()).toContain('Press Esc/Ctrl+C to go back.'); }); it('renders with custom action', () => { const { lastFrame } = render(); - expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel.'); + 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 index a1200855..42c7d328 100644 --- a/src/components/ExitHint/ExitHint.tsx +++ b/src/components/ExitHint/ExitHint.tsx @@ -6,12 +6,20 @@ 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 or Ctrl+C to {action}. + + Press + Esc + / + Ctrl+C + to {action}. ); } diff --git a/src/components/SelectPrompt/SelectPromptHint.tsx b/src/components/SelectPrompt/SelectPromptHint.tsx index 74a839c9..a8cf6c58 100644 --- a/src/components/SelectPrompt/SelectPromptHint.tsx +++ b/src/components/SelectPrompt/SelectPromptHint.tsx @@ -1,17 +1,24 @@ 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/Ctrl+C to cancel) - + {message} ( ↑↓ +