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 { 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; } export function App({ session, modelClient, modelRouter, systemPrompt, model, onExit, }: AppProps): React.ReactElement { const { exit } = useApp(); const [input, setInput] = useState(''); const [messages, setMessages] = useState(session.getHistory()); 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) { 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 command = parseCommand(value); if (!command) return; setInput(''); // Handle commands 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 (command.type !== 'message' || isStreaming) return; // Add user message const userMessage: Message = { role: 'user', content: command.content }; session.addMessage(userMessage); setMessages(prev => [...prev, userMessage]); setScrollOffset(0); // Auto-scroll to bottom // Stream response setIsStreaming(true); setStreamingContent(''); abortRef.current = false; try { if (modelClient.chatStream) { let fullContent = ''; 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', content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, }; setMessages(prev => [...prev, errorMessage]); } finally { setIsStreaming(false); setStreamingContent(''); } }, [isStreaming, session, modelClient, modelRouter, systemPrompt, exit, onExit, messages.length, tokenUsage]); return ( ); }