diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 4174322..832a187 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -1,16 +1,19 @@ -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'; +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { Box, useApp, useInput } from 'ink'; +import { TopBar } from './components/top-bar.js'; +import { BottomBar } from './components/bottom-bar.js'; +import { MessageList } from './components/message-list.js'; +import { InputBar } from './components/input-bar.js'; +import { Sidebar } from './components/sidebar.js'; +import { SearchBar } from './components/search-bar.js'; +import { useSocket } from './hooks/use-socket.js'; +import { useGitInfo } from './hooks/use-git-info.js'; +import { useViewport } from './hooks/use-viewport.js'; +import { useAppMode } from './hooks/use-app-mode.js'; +import { useConversations } from './hooks/use-conversations.js'; +import { useSearch } from './hooks/use-search.js'; -interface Message { - role: 'user' | 'assistant' | 'system'; - content: string; -} - -interface TuiAppProps { +export interface TuiAppProps { gatewayUrl: string; conversationId?: string; sessionCookie?: string; @@ -18,375 +21,222 @@ interface TuiAppProps { 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, + conversationId, sessionCookie, - initialModel, - initialProvider, + initialModel: _initialModel, + initialProvider: _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(''); + const gitInfo = useGitInfo(); + const appMode = useAppMode(); - // Model/provider state - const [currentModel, setCurrentModel] = useState(initialModel); - const [currentProvider, setCurrentProvider] = useState(initialProvider); - const [availableModels, setAvailableModels] = useState([]); + const socket = useSocket({ + gatewayUrl, + sessionCookie, + initialConversationId: conversationId, + }); - const socketRef = useRef(null); - const currentStreamTextRef = useRef(''); + const conversations = useConversations({ gatewayUrl, sessionCookie }); - // Fetch available models on mount + const viewport = useViewport({ totalItems: socket.messages.length }); + + const search = useSearch(socket.messages); + + // Scroll to current match when it changes + const currentMatch = search.matches[search.currentMatchIndex]; 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]); + if (currentMatch && appMode.mode === 'search') { + viewport.scrollTo(currentMatch.messageIndex); + } + }, [currentMatch, appMode.mode, viewport]); - useEffect(() => { - const socket = io(`${gatewayUrl}/chat`, { - transports: ['websocket'], - extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined, - }); + // Compute highlighted message indices for MessageList + const highlightedMessageIndices = useMemo(() => { + if (search.matches.length === 0) return undefined; + return new Set(search.matches.map((m) => m.messageIndex)); + }, [search.matches]); - socketRef.current = socket; + const currentHighlightIndex = currentMatch?.messageIndex; - 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}.`, - }, - ]); - }); + const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0); - 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; + const handleSwitchConversation = useCallback( + (id: string) => { + socket.switchConversation(id); + appMode.setMode('chat'); }, - [availableModels, currentModel, currentProvider], + [socket, appMode], ); - 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, + const handleDeleteConversation = useCallback( + (id: string) => { + void conversations.deleteConversation(id).then((ok) => { + if (ok && id === socket.conversationId) { + socket.clearMessages(); + } }); }, - [conversationId, isStreaming, currentModel, currentProvider, handleSlashCommand], + [conversations, socket], ); useInput((ch, key) => { if (key.ctrl && ch === 'c') { exit(); } + // Ctrl+L: toggle sidebar (refresh on open) + if (key.ctrl && ch === 'l') { + const willOpen = !appMode.sidebarOpen; + appMode.toggleSidebar(); + if (willOpen) { + void conversations.refresh(); + } + } + // Ctrl+N: create new conversation and switch to it + if (key.ctrl && ch === 'n') { + void conversations.createConversation().then((conv) => { + if (conv) { + socket.switchConversation(conv.id); + appMode.setMode('chat'); + } + }); + } + // Ctrl+K: toggle search mode + if (key.ctrl && ch === 'k') { + if (appMode.mode === 'search') { + search.clear(); + appMode.setMode('chat'); + } else { + appMode.setMode('search'); + } + } + // Page Up / Page Down: scroll message history (only in chat mode) + if (appMode.mode === 'chat') { + if (key.pageUp) { + viewport.scrollBy(-viewport.viewportSize); + } + if (key.pageDown) { + viewport.scrollBy(viewport.viewportSize); + } + } + // Ctrl+T: cycle thinking level + if (key.ctrl && ch === 't') { + const levels = socket.availableThinkingLevels; + if (levels.length > 0) { + const currentIdx = levels.indexOf(socket.thinkingLevel); + const nextIdx = (currentIdx + 1) % levels.length; + const next = levels[nextIdx]; + if (next) { + socket.setThinkingLevel(next); + } + } + } + // Escape: return to chat from sidebar/search; in chat, scroll to bottom + if (key.escape) { + if (appMode.mode === 'search') { + search.clear(); + appMode.setMode('chat'); + } else if (appMode.mode === 'sidebar') { + appMode.setMode('chat'); + } else if (appMode.mode === 'chat') { + viewport.scrollToBottom(); + } + } }); - const modelLabel = currentModel - ? currentProvider - ? `${currentProvider}/${currentModel}` - : currentModel - : null; + const inputPlaceholder = + appMode.mode === 'sidebar' + ? 'focus is on sidebar… press Esc to return' + : appMode.mode === 'search' + ? 'search mode… press Esc to return' + : undefined; + + const isSearchMode = appMode.mode === 'search'; + + const messageArea = ( + + + + {isSearchMode && ( + { + search.clear(); + appMode.setMode('chat'); + }} + focused={isSearchMode} + /> + )} + + + + ); 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} - - )} - - ))} + {appMode.sidebarOpen ? ( + + + {messageArea} + + ) : ( + {messageArea} + )} - {isStreaming && currentStreamText && ( - - - {' '} - - {currentStreamText} - - )} - - {isStreaming && !currentStreamText && ( - - - - - thinking... - - )} - - - - - {'> '} - - - + ); }