diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index e1170b1067..e7681b784c 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -129,6 +129,14 @@ def test_enable_gateway_prompts_sets_gateway_env(monkeypatch): assert server.os.environ["HERMES_INTERACTIVE"] == "1" +def test_setup_status_reports_provider_config(monkeypatch): + monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: False) + + resp = server.handle_request({"id": "1", "method": "setup.status", "params": {}}) + + assert resp["result"]["provider_configured"] is False + + def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch): monkeypatch.setattr(server, "_hermes_home", tmp_path) agent = types.SimpleNamespace(reasoning_config=None) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ff5e2cbf9c..3ef76a0f02 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1878,6 +1878,15 @@ def _(rid, params: dict) -> dict: return _err(rid, 4002, f"unknown config key: {key}") +@method("setup.status") +def _(rid, params: dict) -> dict: + try: + from hermes_cli.main import _has_any_provider_configured + return _ok(rid, {"provider_configured": bool(_has_any_provider_configured())}) + except Exception as e: + return _err(rid, 5016, str(e)) + + # ── Methods: tools & system ────────────────────────────────────────── @method("process.stop") diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 63675b8d3b..e546ce640e 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -26,6 +26,7 @@ const buildCtx = (appended: Msg[]) => colsRef: ref(80), newSession: vi.fn(), resetSession: vi.fn(), + resumeById: vi.fn(), setCatalog: vi.fn() }, system: { @@ -34,6 +35,8 @@ const buildCtx = (appended: Msg[]) => }, transcript: { appendMessage: (msg: Msg) => appended.push(msg), + panel: (title: string, sections: any[]) => + appended.push({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }), setHistoryItems: vi.fn() } }) as any @@ -138,4 +141,24 @@ describe('createGatewayEventHandler', () => { expect(appended[0]?.thinking).toBe(fromServer) expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer)) }) + + it('shows setup panel for missing provider startup error', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + onEvent({ + payload: { + message: + 'agent init failed: No LLM provider configured. Run `hermes model` to select a provider, or run `hermes setup` for first-time configuration.' + }, + type: 'error' + } as any) + + expect(appended).toHaveLength(1) + expect(appended[0]).toMatchObject({ + kind: 'panel', + panelData: { title: 'Setup Required' }, + role: 'system' + }) + }) }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 62b40f6193..8610b2551e 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,7 +1,7 @@ import { STREAM_BATCH_MS } from '../config/timing.js' -import { introMsg, toTranscriptMessages } from '../domain/messages.js' -import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin, SessionResumeResponse } from '../gatewayTypes.js' -import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' +import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js' +import { rpcErrorMessage } from '../lib/rpc.js' import { formatToolCall } from '../lib/text.js' import { fromSkin } from '../theme.js' import type { SubagentProgress } from '../types.js' @@ -12,6 +12,7 @@ import { turnController } from './turnController.js' import { getUiState, patchUiState } from './uiStore.js' const ERRLIKE_RE = /\b(error|traceback|exception|failed|spawn)\b/i +const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready') @@ -46,10 +47,10 @@ const pushTool = pushUnique(8) export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void { const { dequeue, queueEditRef, sendQueued } = ctx.composer - const { gw, rpc } = ctx.gateway - const { STARTUP_RESUME_ID, colsRef, newSession, resetSession, setCatalog } = ctx.session + const { rpc } = ctx.gateway + const { STARTUP_RESUME_ID, newSession, resumeById, setCatalog } = ctx.session const { bellOnComplete, stdout, sys } = ctx.system - const { appendMessage, setHistoryItems } = ctx.transcript + const { appendMessage, panel, setHistoryItems } = ctx.transcript let pendingThinkingStatus = '' let thinkingStatusTimer: null | ReturnType = null @@ -121,30 +122,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } patchUiState({ status: 'resuming…' }) - gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) - .then(raw => { - const r = asRpcResult(raw) - - if (!r) { - throw new Error('invalid response: session.resume') - } - - resetSession() - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: r.info?.usage ?? getUiState().usage - }) - setHistoryItems( - r.info ? [introMsg(r.info), ...toTranscriptMessages(r.messages)] : toTranscriptMessages(r.messages) - ) - }) - .catch((e: unknown) => { - sys(`resume failed: ${rpcErrorMessage(e)}`) - patchUiState({ status: 'forging session…' }) - newSession('started a new session') - }) + resumeById(STARTUP_RESUME_ID) } return (ev: GatewayEvent) => { @@ -438,9 +416,22 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: case 'error': turnController.recordError() - turnController.pushActivity(String(ev.payload?.message || 'unknown error'), 'error') - sys(`error: ${ev.payload?.message}`) - setStatus('ready') + + { + const message = String(ev.payload?.message || 'unknown error') + + turnController.pushActivity(message, 'error') + + if (NO_PROVIDER_RE.test(message)) { + panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections()) + setStatus('setup required') + + return + } + + sys(`error: ${message}`) + setStatus('ready') + } } } } diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index d14536624a..9af6e5dc64 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -197,6 +197,7 @@ export interface GatewayEventHandlerContext { colsRef: MutableRefObject newSession: (msg?: string) => void resetSession: () => void + resumeById: (id: string) => void setCatalog: StateSetter } system: { @@ -206,6 +207,7 @@ export interface GatewayEventHandlerContext { } transcript: { appendMessage: (msg: Msg) => void + panel: (title: string, sections: PanelSection[]) => void setHistoryItems: StateSetter } } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index df4a0b3b82..f83633cdab 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -1,4 +1,4 @@ -import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink' +import { useApp, useHasSelection, useSelection, useStdout, type ScrollBoxHandle } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -270,6 +270,7 @@ export function useMainApp(gw: GatewayClient) { colsRef, composerActions, gw, + panel, rpc, setHistoryItems, setLastUserMsg, @@ -413,10 +414,11 @@ export function useMainApp(gw: GatewayClient) { colsRef, newSession: session.newSession, resetSession: session.resetSession, + resumeById: session.resumeById, setCatalog }, system: { bellOnComplete, stdout, sys }, - transcript: { appendMessage, setHistoryItems } + transcript: { appendMessage, panel, setHistoryItems } }), [ appendMessage, diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 83341108e0..d54e9ae96e 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -1,9 +1,15 @@ import { useCallback } from 'react' +import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import { introMsg, toTranscriptMessages } from '../domain/messages.js' import { ZERO } from '../domain/usage.js' import { type GatewayClient } from '../gatewayClient.js' -import type { SessionCloseResponse, SessionCreateResponse, SessionResumeResponse } from '../gatewayTypes.js' +import type { + SessionCloseResponse, + SessionCreateResponse, + SessionResumeResponse, + SetupStatusResponse +} from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' import type { Msg, SessionInfo, Usage } from '../types.js' @@ -33,6 +39,7 @@ export interface UseSessionLifecycleOptions { colsRef: { current: number } composerActions: ComposerActions gw: GatewayClient + panel: (title: string, sections: import('../types.js').PanelSection[]) => void rpc: GatewayRpc setHistoryItems: StateSetter setLastUserMsg: StateSetter @@ -48,6 +55,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { colsRef, composerActions, gw, + panel, rpc, setHistoryItems, setLastUserMsg, @@ -94,6 +102,15 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { const newSession = useCallback( async (msg?: string) => { + const setup = await rpc('setup.status', {}) + + if (setup?.provider_configured === false) { + panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections()) + patchUiState({ status: 'setup required' }) + + return + } + await closeSession(getUiState().sid) const r = await rpc('session.create', { cols: colsRef.current }) @@ -126,7 +143,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { sys(msg) } }, - [closeSession, colsRef, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] + [closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] ) const resumeById = useCallback( @@ -134,38 +151,47 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { patchOverlayState({ picker: false }) patchUiState({ status: 'resuming…' }) - closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => - gw - .request('session.resume', { cols: colsRef.current, session_id: id }) - .then(raw => { - const r = asRpcResult(raw) + rpc('setup.status', {}).then(setup => { + if (setup?.provider_configured === false) { + panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections()) + patchUiState({ status: 'setup required' }) - if (!r) { - sys('error: invalid response: session.resume') + return + } - return patchUiState({ status: 'ready' }) - } + closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => + gw + .request('session.resume', { cols: colsRef.current, session_id: id }) + .then(raw => { + const r = asRpcResult(raw) - resetSession() - setSessionStartedAt(Date.now()) + if (!r) { + sys('error: invalid response: session.resume') - const resumed = toTranscriptMessages(r.messages) + return patchUiState({ status: 'ready' }) + } - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: usageFrom(r.info ?? null) + resetSession() + setSessionStartedAt(Date.now()) + + const resumed = toTranscriptMessages(r.messages) + + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + patchUiState({ + info: r.info ?? null, + sid: r.session_id, + status: 'ready', + usage: usageFrom(r.info ?? null) + }) }) - }) - .catch((e: Error) => { - sys(`error: ${e.message}`) - patchUiState({ status: 'ready' }) - }) - ) + .catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ status: 'ready' }) + }) + ) + }) }, - [closeSession, colsRef, gw, resetSession, setHistoryItems, setSessionStartedAt, sys] + [closeSession, colsRef, gw, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] ) const guardBusySessionSwitch = useCallback( diff --git a/ui-tui/src/content/setup.ts b/ui-tui/src/content/setup.ts new file mode 100644 index 0000000000..1170b638d5 --- /dev/null +++ b/ui-tui/src/content/setup.ts @@ -0,0 +1,18 @@ +import type { PanelSection } from '../types.js' + +export const SETUP_REQUIRED_TITLE = 'Setup Required' + +export const buildSetupRequiredSections = (): PanelSection[] => [ + { + text: 'Hermes needs a model provider before the TUI can start a session.' + }, + { + rows: [ + ['1.', 'Exit with Ctrl+C'], + ['2.', 'Run `hermes model` to choose a provider + model'], + ['3.', 'Or run `hermes setup` for full first-time setup'], + ['4.', 'Re-open `hermes --tui` when setup is done'] + ], + title: 'Next Steps' + } +] diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 31c58896b8..9e21b9bc58 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -80,6 +80,10 @@ export interface ConfigSetResponse { warning?: string } +export interface SetupStatusResponse { + provider_configured?: boolean +} + // ── Session lifecycle ──────────────────────────────────────────────── export interface SessionCreateResponse {