feat(tui): add streaming, scroll, and model switching to fullscreen App
This commit is contained in:
@@ -1,14 +1,17 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
import { Box, useApp, useInput } from 'ink';
|
import { Box, useApp, useInput } from 'ink';
|
||||||
import { StatusBar } from './StatusBar.js';
|
import { StatusBar } from './StatusBar.js';
|
||||||
import { MessageList } from './MessageList.js';
|
import { MessageList } from './MessageList.js';
|
||||||
import { InputBar } from './InputBar.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';
|
import type { ManagedSession } from '../../../session/index.js';
|
||||||
|
|
||||||
export interface AppProps {
|
export interface AppProps {
|
||||||
session: ManagedSession;
|
session: ManagedSession;
|
||||||
modelClient: ModelClient;
|
modelClient: ModelClient;
|
||||||
|
modelRouter?: ModelRouter;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
model: string;
|
model: string;
|
||||||
onExit?: () => void;
|
onExit?: () => void;
|
||||||
@@ -17,6 +20,7 @@ export interface AppProps {
|
|||||||
export function App({
|
export function App({
|
||||||
session,
|
session,
|
||||||
modelClient,
|
modelClient,
|
||||||
|
modelRouter,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
model,
|
model,
|
||||||
onExit,
|
onExit,
|
||||||
@@ -24,49 +28,159 @@ export function App({
|
|||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [messages, setMessages] = useState<Message[]>(session.getHistory());
|
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) => {
|
useInput((inputChar, key) => {
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
|
if (isStreaming) {
|
||||||
|
abortRef.current = true;
|
||||||
|
} else {
|
||||||
onExit?.();
|
onExit?.();
|
||||||
exit();
|
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 handleSubmit = useCallback(async (value: string) => {
|
||||||
const trimmed = value.trim();
|
const command = parseCommand(value);
|
||||||
if (!trimmed || isLoading) return;
|
if (!command) return;
|
||||||
|
|
||||||
|
setInput('');
|
||||||
|
|
||||||
// Handle commands
|
// Handle commands
|
||||||
if (trimmed === '/quit' || trimmed === '/exit') {
|
switch (command.type) {
|
||||||
|
case 'quit':
|
||||||
onExit?.();
|
onExit?.();
|
||||||
exit();
|
exit();
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmed === '/reset' || trimmed === '/clear') {
|
case 'reset':
|
||||||
session.clear();
|
session.clear();
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setInput('');
|
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;
|
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;
|
||||||
|
|
||||||
// Regular message
|
case 'fullscreen':
|
||||||
const userMessage: Message = { role: 'user', content: trimmed };
|
// 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);
|
session.addMessage(userMessage);
|
||||||
setMessages(prev => [...prev, userMessage]);
|
setMessages(prev => [...prev, userMessage]);
|
||||||
setInput('');
|
setScrollOffset(0); // Auto-scroll to bottom
|
||||||
setIsLoading(true);
|
|
||||||
|
// Stream response
|
||||||
|
setIsStreaming(true);
|
||||||
|
setStreamingContent('');
|
||||||
|
abortRef.current = false;
|
||||||
|
|
||||||
try {
|
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({
|
const response = await modelClient.chat({
|
||||||
messages: session.getHistory(),
|
messages: session.getHistory(),
|
||||||
system: systemPrompt,
|
system: systemPrompt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setTokenUsage(prev => ({
|
||||||
|
inputTokens: prev.inputTokens + response.usage.inputTokens,
|
||||||
|
outputTokens: prev.outputTokens + response.usage.outputTokens,
|
||||||
|
}));
|
||||||
|
|
||||||
const assistantMessage: Message = { role: 'assistant', content: response.content };
|
const assistantMessage: Message = { role: 'assistant', content: response.content };
|
||||||
session.addMessage(assistantMessage);
|
session.addMessage(assistantMessage);
|
||||||
setMessages(prev => [...prev, assistantMessage]);
|
setMessages(prev => [...prev, assistantMessage]);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage: Message = {
|
const errorMessage: Message = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
@@ -74,24 +188,31 @@ export function App({
|
|||||||
};
|
};
|
||||||
setMessages(prev => [...prev, errorMessage]);
|
setMessages(prev => [...prev, errorMessage]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsStreaming(false);
|
||||||
|
setStreamingContent('');
|
||||||
}
|
}
|
||||||
}, [isLoading, session, modelClient, systemPrompt, exit, onExit]);
|
}, [isStreaming, session, modelClient, modelRouter, systemPrompt, exit, onExit, messages.length, tokenUsage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" height="100%">
|
<Box flexDirection="column" height="100%">
|
||||||
<StatusBar
|
<StatusBar
|
||||||
sessionId={session.id}
|
sessionId={session.id}
|
||||||
messageCount={messages.length}
|
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
|
<InputBar
|
||||||
value={input}
|
value={input}
|
||||||
onChange={setInput}
|
onChange={setInput}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={isLoading}
|
isLoading={isStreaming}
|
||||||
placeholder="Type a message... (Esc to exit)"
|
placeholder={isStreaming ? 'Flynn is typing... (Esc to cancel)' : 'Type a message... (Esc=exit, /help)'}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user