import React, { useState, useCallback, useEffect, useRef } from 'react'; 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' | 'system'; content: string; } 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([]); const [input, setInput] = useState(''); const [isStreaming, setIsStreaming] = useState(false); 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); const currentStreamTextRef = useRef(''); // 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'], extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined, }); socketRef.current = socket; socket.on('connect', () => setConnected(true)); socket.on('disconnect', () => { setConnected(false); setIsStreaming(false); setCurrentStreamText(''); }); socket.on('connect_error', (err: Error) => { setMessages((msgs) => [ ...msgs, { role: 'assistant', content: `Connection failed: ${err.message}. Check that the gateway is running at ${gatewayUrl}.`, }, ]); }); socket.on('message:ack', (data: { conversationId: string }) => { setConversationId(data.conversationId); }); socket.on('agent:start', () => { setIsStreaming(true); currentStreamTextRef.current = ''; setCurrentStreamText(''); }); socket.on('agent:text', (data: { text: string }) => { currentStreamTextRef.current += data.text; setCurrentStreamText(currentStreamTextRef.current); }); socket.on('agent:end', () => { const finalText = currentStreamTextRef.current; currentStreamTextRef.current = ''; setCurrentStreamText(''); if (finalText) { setMessages((msgs) => [...msgs, { role: 'assistant', content: finalText }]); } setIsStreaming(false); }); socket.on('error', (data: { error: string }) => { setMessages((msgs) => [...msgs, { role: 'assistant', content: `Error: ${data.error}` }]); setIsStreaming(false); }); return () => { socket.disconnect(); }; }, [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, { role: 'assistant', content: 'Not connected to gateway. Message not sent.' }, ]); return; } setMessages((msgs) => [...msgs, { role: 'user', content: value }]); socketRef.current.emit('message', { conversationId, content: value, provider: currentProvider, modelId: currentModel, }); }, [conversationId, isStreaming, currentModel, currentProvider, handleSlashCommand], ); useInput((ch, key) => { if (key.ctrl && ch === 'c') { exit(); } }); const modelLabel = currentModel ? currentProvider ? `${currentProvider}/${currentModel}` : currentModel : null; return ( Mosaic {connected ? `connected` : 'connecting...'} {conversationId && | {conversationId.slice(0, 8)}} {modelLabel && ( <> | {modelLabel} )} {messages.map((msg, i) => ( {msg.role === 'system' ? ( {msg.content} ) : ( <> {msg.role === 'user' ? '> ' : ' '} {msg.content} )} ))} {isStreaming && currentStreamText && ( {' '} {currentStreamText} )} {isStreaming && !currentStreamText && ( thinking... )} {'> '} ); }