feat(tui): add streaming, scroll, and model switching to fullscreen App

This commit is contained in:
William Valentin
2026-02-05 10:56:27 -08:00
parent 0490d2fe39
commit 747a7f44a2
+155 -34
View File
@@ -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<Message[]>(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<TokenUsage>({ 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 (
<Box flexDirection="column" height="100%">
<StatusBar
sessionId={session.id}
messageCount={messages.length}
model={model}
model={currentModel}
tokenUsage={{ input: tokenUsage.inputTokens, output: tokenUsage.outputTokens }}
isStreaming={isStreaming}
/>
<MessageList
messages={messages}
scrollOffset={scrollOffset}
streamingContent={isStreaming ? streamingContent : undefined}
/>
<MessageList messages={messages} />
<InputBar
value={input}
onChange={setInput}
onSubmit={handleSubmit}
isLoading={isLoading}
placeholder="Type a message... (Esc to exit)"
isLoading={isStreaming}
placeholder={isStreaming ? 'Flynn is typing... (Esc to cancel)' : 'Type a message... (Esc=exit, /help)'}
/>
</Box>
);