diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4e5dea2..7333315 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -49,55 +49,62 @@ program .description('Launch interactive TUI connected to the gateway') .option('-g, --gateway ', 'Gateway URL', 'http://localhost:4000') .option('-c, --conversation ', 'Resume a conversation by ID') - .action(async (opts: { gateway: string; conversation?: string }) => { - const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js'); + .option('-m, --model ', 'Model ID to use (e.g. gpt-4o, llama3.2)') + .option('-p, --provider ', 'Provider to use (e.g. openai, ollama)') + .action( + async (opts: { gateway: string; conversation?: string; model?: string; provider?: string }) => { + const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js'); - // Try loading saved session - let session = loadSession(opts.gateway); + // Try loading saved session + let session = loadSession(opts.gateway); - if (session) { - const valid = await validateSession(opts.gateway, session.cookie); - if (!valid) { - console.log('Session expired. Please sign in again.'); - session = null; + if (session) { + const valid = await validateSession(opts.gateway, session.cookie); + if (!valid) { + console.log('Session expired. Please sign in again.'); + session = null; + } } - } - // No valid session — prompt for credentials - if (!session) { - const readline = await import('node:readline'); - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); + // No valid session — prompt for credentials + if (!session) { + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string): Promise => + new Promise((resolve) => rl.question(q, resolve)); - console.log(`Sign in to ${opts.gateway}`); - const email = await ask('Email: '); - const password = await ask('Password: '); - rl.close(); + console.log(`Sign in to ${opts.gateway}`); + const email = await ask('Email: '); + const password = await ask('Password: '); + rl.close(); - try { - const auth = await signIn(opts.gateway, email, password); - saveSession(opts.gateway, auth); - session = auth; - console.log(`Signed in as ${auth.email}\n`); - } catch (err) { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); + try { + const auth = await signIn(opts.gateway, email, password); + saveSession(opts.gateway, auth); + session = auth; + console.log(`Signed in as ${auth.email}\n`); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } } - } - // Dynamic import to avoid loading React/Ink for other commands - const { render } = await import('ink'); - const React = await import('react'); - const { TuiApp } = await import('./tui/app.js'); + // Dynamic import to avoid loading React/Ink for other commands + const { render } = await import('ink'); + const React = await import('react'); + const { TuiApp } = await import('./tui/app.js'); - render( - React.createElement(TuiApp, { - gatewayUrl: opts.gateway, - conversationId: opts.conversation, - sessionCookie: session.cookie, - }), - ); - }); + render( + React.createElement(TuiApp, { + gatewayUrl: opts.gateway, + conversationId: opts.conversation, + sessionCookie: session.cookie, + initialModel: opts.model, + initialProvider: opts.provider, + }), + ); + }, + ); // ─── prdy ─────────────────────────────────────────────────────────────── diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 3a6698f..56372be 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -3,9 +3,10 @@ import { Box, Text, useInput, useApp } from 'ink'; import TextInput from 'ink-text-input'; import Spinner from 'ink-spinner'; import { io, type Socket } from 'socket.io-client'; +import { fetchAvailableModels, type ModelInfo } from './gateway-api.js'; interface Message { - role: 'user' | 'assistant'; + role: 'user' | 'assistant' | 'system'; content: string; } @@ -13,12 +14,29 @@ interface TuiAppProps { gatewayUrl: string; conversationId?: string; sessionCookie?: string; + initialModel?: string; + initialProvider?: string; +} + +/** + * Parse a slash command from user input. + * Returns null if the input is not a slash command. + */ +function parseSlashCommand(value: string): { command: string; args: string[] } | null { + const trimmed = value.trim(); + if (!trimmed.startsWith('/')) return null; + const parts = trimmed.slice(1).split(/\s+/); + const command = parts[0]?.toLowerCase() ?? ''; + const args = parts.slice(1); + return { command, args }; } export function TuiApp({ gatewayUrl, conversationId: initialConversationId, sessionCookie, + initialModel, + initialProvider, }: TuiAppProps) { const { exit } = useApp(); const [messages, setMessages] = useState([]); @@ -27,8 +45,33 @@ export function TuiApp({ const [connected, setConnected] = useState(false); const [conversationId, setConversationId] = useState(initialConversationId); const [currentStreamText, setCurrentStreamText] = useState(''); + + // Model/provider state + const [currentModel, setCurrentModel] = useState(initialModel); + const [currentProvider, setCurrentProvider] = useState(initialProvider); + const [availableModels, setAvailableModels] = useState([]); + const socketRef = useRef(null); + // Fetch available models on mount + useEffect(() => { + fetchAvailableModels(gatewayUrl, sessionCookie) + .then((models) => { + setAvailableModels(models); + // If no model/provider specified and models are available, show the default + if (!initialModel && !initialProvider && models.length > 0) { + const first = models[0]; + if (first) { + setCurrentModel(first.id); + setCurrentProvider(first.provider); + } + } + }) + .catch(() => { + // Non-fatal: TUI works without model list + }); + }, [gatewayUrl, sessionCookie, initialModel, initialProvider]); + useEffect(() => { const socket = io(`${gatewayUrl}/chat`, { transports: ['websocket'], @@ -86,9 +129,164 @@ export function TuiApp({ }; }, [gatewayUrl]); + /** + * Handle /model and /provider slash commands. + * Returns true if the input was a handled slash command (should not be sent to gateway). + */ + const handleSlashCommand = useCallback( + (value: string): boolean => { + const parsed = parseSlashCommand(value); + if (!parsed) return false; + + const { command, args } = parsed; + + if (command === 'model') { + if (args.length === 0) { + // List available models + if (availableModels.length === 0) { + setMessages((msgs) => [ + ...msgs, + { + role: 'system', + content: + 'No models available (could not reach gateway). Use /model to set one manually.', + }, + ]); + } else { + const lines = availableModels.map( + (m) => + ` ${m.provider}/${m.id}${m.id === currentModel && m.provider === currentProvider ? ' (active)' : ''}`, + ); + setMessages((msgs) => [ + ...msgs, + { + role: 'system', + content: `Available models:\n${lines.join('\n')}`, + }, + ]); + } + } else { + // Switch model: /model or /model / + const arg = args[0]!; + const slashIdx = arg.indexOf('/'); + let newProvider: string | undefined; + let newModelId: string; + + if (slashIdx !== -1) { + newProvider = arg.slice(0, slashIdx); + newModelId = arg.slice(slashIdx + 1); + } else { + newModelId = arg; + // Try to find provider from available models list + const match = availableModels.find((m) => m.id === newModelId); + newProvider = match?.provider ?? currentProvider; + } + + setCurrentModel(newModelId); + if (newProvider) setCurrentProvider(newProvider); + + setMessages((msgs) => [ + ...msgs, + { + role: 'system', + content: `Switched to model: ${newProvider ? `${newProvider}/` : ''}${newModelId}. Takes effect on next message.`, + }, + ]); + } + return true; + } + + if (command === 'provider') { + if (args.length === 0) { + // List providers from available models + const providers = [...new Set(availableModels.map((m) => m.provider))]; + if (providers.length === 0) { + setMessages((msgs) => [ + ...msgs, + { + role: 'system', + content: + 'No providers available (could not reach gateway). Use /provider to set one manually.', + }, + ]); + } else { + const lines = providers.map((p) => ` ${p}${p === currentProvider ? ' (active)' : ''}`); + setMessages((msgs) => [ + ...msgs, + { + role: 'system', + content: `Available providers:\n${lines.join('\n')}`, + }, + ]); + } + } else { + const newProvider = args[0]!; + setCurrentProvider(newProvider); + // If switching provider, auto-select first model for that provider + const providerModels = availableModels.filter((m) => m.provider === newProvider); + if (providerModels.length > 0 && providerModels[0]) { + setCurrentModel(providerModels[0].id); + setMessages((msgs) => [ + ...msgs, + { + role: 'system', + content: `Switched to provider: ${newProvider} (model: ${providerModels[0]!.id}). Takes effect on next message.`, + }, + ]); + } else { + setMessages((msgs) => [ + ...msgs, + { + role: 'system', + content: `Switched to provider: ${newProvider}. Takes effect on next message.`, + }, + ]); + } + } + return true; + } + + if (command === 'help') { + setMessages((msgs) => [ + ...msgs, + { + role: 'system', + content: [ + 'Available commands:', + ' /model — list available models', + ' /model — switch model (e.g. /model gpt-4o)', + ' /model

/ — switch model with provider (e.g. /model ollama/llama3.2)', + ' /provider — list available providers', + ' /provider — switch provider (e.g. /provider ollama)', + ' /help — show this help', + ].join('\n'), + }, + ]); + return true; + } + + // Unknown slash command — let the user know + setMessages((msgs) => [ + ...msgs, + { + role: 'system', + content: `Unknown command: /${command}. Type /help for available commands.`, + }, + ]); + return true; + }, + [availableModels, currentModel, currentProvider], + ); + const handleSubmit = useCallback( (value: string) => { if (!value.trim() || isStreaming) return; + + setInput(''); + + // Handle slash commands first + if (handleSlashCommand(value)) return; + if (!socketRef.current?.connected) { setMessages((msgs) => [ ...msgs, @@ -98,14 +296,15 @@ export function TuiApp({ } setMessages((msgs) => [...msgs, { role: 'user', content: value }]); - setInput(''); socketRef.current.emit('message', { conversationId, content: value, + provider: currentProvider, + modelId: currentModel, }); }, - [conversationId, isStreaming], + [conversationId, isStreaming, currentModel, currentProvider, handleSlashCommand], ); useInput((ch, key) => { @@ -114,6 +313,12 @@ export function TuiApp({ } }); + const modelLabel = currentModel + ? currentProvider + ? `${currentProvider}/${currentModel}` + : currentModel + : null; + return ( @@ -123,15 +328,29 @@ export function TuiApp({ {connected ? `connected` : 'connecting...'} {conversationId && | {conversationId.slice(0, 8)}} + {modelLabel && ( + <> + | + {modelLabel} + + )} {messages.map((msg, i) => ( - - {msg.role === 'user' ? '> ' : ' '} - - {msg.content} + {msg.role === 'system' ? ( + + {msg.content} + + ) : ( + <> + + {msg.role === 'user' ? '> ' : ' '} + + {msg.content} + + )} ))} @@ -162,7 +381,7 @@ export function TuiApp({ value={input} onChange={setInput} onSubmit={handleSubmit} - placeholder={isStreaming ? 'waiting...' : 'type a message'} + placeholder={isStreaming ? 'waiting...' : 'type a message or /help'} /> diff --git a/packages/cli/src/tui/gateway-api.ts b/packages/cli/src/tui/gateway-api.ts new file mode 100644 index 0000000..d7d5693 --- /dev/null +++ b/packages/cli/src/tui/gateway-api.ts @@ -0,0 +1,62 @@ +/** + * Minimal gateway REST API client for the TUI. + */ + +export interface ModelInfo { + id: string; + provider: string; + name: string; +} + +export interface ProviderInfo { + id: string; + name: string; + available: boolean; + models: ModelInfo[]; +} + +/** + * Fetch the list of available models from the gateway. + * Returns an empty array on network or auth errors so the TUI can still function. + */ +export async function fetchAvailableModels( + gatewayUrl: string, + sessionCookie?: string, +): Promise { + try { + const res = await fetch(`${gatewayUrl}/api/providers/models`, { + headers: { + ...(sessionCookie ? { Cookie: sessionCookie } : {}), + Origin: gatewayUrl, + }, + }); + if (!res.ok) return []; + const data = (await res.json()) as ModelInfo[]; + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +/** + * Fetch the list of providers (with their models) from the gateway. + * Returns an empty array on network or auth errors. + */ +export async function fetchProviders( + gatewayUrl: string, + sessionCookie?: string, +): Promise { + try { + const res = await fetch(`${gatewayUrl}/api/providers`, { + headers: { + ...(sessionCookie ? { Cookie: sessionCookie } : {}), + Origin: gatewayUrl, + }, + }); + if (!res.ok) return []; + const data = (await res.json()) as ProviderInfo[]; + return Array.isArray(data) ? data : []; + } catch { + return []; + } +}