diff --git a/src/frontends/tui/components/App.tsx b/src/frontends/tui/components/App.tsx index 02f5dde..2511964 100644 --- a/src/frontends/tui/components/App.tsx +++ b/src/frontends/tui/components/App.tsx @@ -1,14 +1,17 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import { Box, useApp, useInput } from 'ink'; import { StatusBar } from './StatusBar.js'; import { MessageList } from './MessageList.js'; import { InputBar } from './InputBar.js'; -import type { Message, ModelClient } from '../../../models/types.js'; +import { parseCommand, getHelpText, resolveModelAlias } from '../commands.js'; +import type { Message, ModelClient, TokenUsage } from '../../../models/types.js'; +import type { ModelRouter } from '../../../models/router.js'; import type { ManagedSession } from '../../../session/index.js'; export interface AppProps { session: ManagedSession; modelClient: ModelClient; + modelRouter?: ModelRouter; systemPrompt: string; model: string; onExit?: () => void; @@ -17,6 +20,7 @@ export interface AppProps { export function App({ session, modelClient, + modelRouter, systemPrompt, model, onExit, @@ -24,49 +28,159 @@ export function App({ const { exit } = useApp(); const [input, setInput] = useState(''); const [messages, setMessages] = useState(session.getHistory()); - const [isLoading, setIsLoading] = useState(false); + const [isStreaming, setIsStreaming] = useState(false); + const [streamingContent, setStreamingContent] = useState(''); + const [scrollOffset, setScrollOffset] = useState(0); + const [tokenUsage, setTokenUsage] = useState({ inputTokens: 0, outputTokens: 0 }); + const [currentModel, setCurrentModel] = useState(model); + const abortRef = useRef(false); useInput((inputChar, key) => { if (key.escape) { - onExit?.(); - exit(); + if (isStreaming) { + abortRef.current = true; + } else { + onExit?.(); + exit(); + } + } + + // Scroll handling + if (key.upArrow && scrollOffset > 0) { + setScrollOffset(prev => Math.max(0, prev - 1)); + } + if (key.downArrow) { + setScrollOffset(prev => Math.min(messages.length - 1, prev + 1)); + } + if (key.pageUp) { + setScrollOffset(prev => Math.max(0, prev - 10)); + } + if (key.pageDown) { + setScrollOffset(prev => Math.min(messages.length - 1, prev + 10)); } }); const handleSubmit = useCallback(async (value: string) => { - const trimmed = value.trim(); - if (!trimmed || isLoading) return; + const command = parseCommand(value); + if (!command) return; + + setInput(''); // Handle commands - if (trimmed === '/quit' || trimmed === '/exit') { - onExit?.(); - exit(); - return; + switch (command.type) { + case 'quit': + onExit?.(); + exit(); + return; + + case 'reset': + session.clear(); + setMessages([]); + setTokenUsage({ inputTokens: 0, outputTokens: 0 }); + setScrollOffset(0); + return; + + case 'help': + // Show help as system message + setMessages(prev => [...prev, { role: 'assistant', content: getHelpText() }]); + return; + + case 'status': + const status = `Session: ${session.id}\nMessages: ${messages.length}\nTokens: ${tokenUsage.inputTokens} in / ${tokenUsage.outputTokens} out`; + setMessages(prev => [...prev, { role: 'assistant', content: status }]); + return; + + case 'model': + if (!modelRouter) { + setMessages(prev => [...prev, { role: 'assistant', content: 'Model switching not available.' }]); + return; + } + if (!command.name) { + const info = `Current: ${modelRouter.getTier()}\nAvailable: ${modelRouter.getAvailableTiers().join(', ')}`; + setMessages(prev => [...prev, { role: 'assistant', content: info }]); + return; + } + const tier = resolveModelAlias(command.name); + if (modelRouter.setTier(tier)) { + setCurrentModel(tier); + setMessages(prev => [...prev, { role: 'assistant', content: `Switched to model: ${tier}` }]); + } else { + setMessages(prev => [...prev, { role: 'assistant', content: `Model not available: ${command.name}` }]); + } + return; + + case 'fullscreen': + // Already in fullscreen + return; + + case 'transfer': + setMessages(prev => [...prev, { role: 'assistant', content: `Transfer not supported in fullscreen mode.` }]); + return; + + case 'message': + break; // Continue to message handling } - if (trimmed === '/reset' || trimmed === '/clear') { - session.clear(); - setMessages([]); - setInput(''); - return; - } + if (command.type !== 'message' || isStreaming) return; - // Regular message - const userMessage: Message = { role: 'user', content: trimmed }; + // Add user message + const userMessage: Message = { role: 'user', content: command.content }; session.addMessage(userMessage); setMessages(prev => [...prev, userMessage]); - setInput(''); - setIsLoading(true); + setScrollOffset(0); // Auto-scroll to bottom + + // Stream response + setIsStreaming(true); + setStreamingContent(''); + abortRef.current = false; try { - const response = await modelClient.chat({ - messages: session.getHistory(), - system: systemPrompt, - }); + if (modelClient.chatStream) { + let fullContent = ''; - const assistantMessage: Message = { role: 'assistant', content: response.content }; - session.addMessage(assistantMessage); - setMessages(prev => [...prev, assistantMessage]); + for await (const event of modelClient.chatStream({ + messages: session.getHistory(), + system: systemPrompt, + })) { + if (abortRef.current) { + fullContent += '\n\n[interrupted]'; + break; + } + + if (event.type === 'content' && event.content) { + fullContent += event.content; + setStreamingContent(fullContent); + } + if (event.type === 'done' && event.usage) { + setTokenUsage(prev => ({ + inputTokens: prev.inputTokens + event.usage!.inputTokens, + outputTokens: prev.outputTokens + event.usage!.outputTokens, + })); + } + if (event.type === 'error') { + throw event.error ?? new Error('Stream error'); + } + } + + const assistantMessage: Message = { role: 'assistant', content: fullContent }; + session.addMessage(assistantMessage); + setMessages(prev => [...prev, assistantMessage]); + } else { + // Fallback to non-streaming + const response = await modelClient.chat({ + messages: session.getHistory(), + system: systemPrompt, + }); + + setTokenUsage(prev => ({ + inputTokens: prev.inputTokens + response.usage.inputTokens, + outputTokens: prev.outputTokens + response.usage.outputTokens, + })); + + const assistantMessage: Message = { role: 'assistant', content: response.content }; + session.addMessage(assistantMessage); + setMessages(prev => [...prev, assistantMessage]); + } } catch (error) { const errorMessage: Message = { role: 'assistant', @@ -74,24 +188,31 @@ export function App({ }; setMessages(prev => [...prev, errorMessage]); } finally { - setIsLoading(false); + setIsStreaming(false); + setStreamingContent(''); } - }, [isLoading, session, modelClient, systemPrompt, exit, onExit]); + }, [isStreaming, session, modelClient, modelRouter, systemPrompt, exit, onExit, messages.length, tokenUsage]); return ( + - );