feat: add Ink-based fullscreen TUI components
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { useState, useCallback } 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 type { ManagedSession } from '../../../session/index.js';
|
||||||
|
|
||||||
|
export interface AppProps {
|
||||||
|
session: ManagedSession;
|
||||||
|
modelClient: ModelClient;
|
||||||
|
systemPrompt: string;
|
||||||
|
model: string;
|
||||||
|
onExit?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App({
|
||||||
|
session,
|
||||||
|
modelClient,
|
||||||
|
systemPrompt,
|
||||||
|
model,
|
||||||
|
onExit,
|
||||||
|
}: AppProps): React.ReactElement {
|
||||||
|
const { exit } = useApp();
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [messages, setMessages] = useState<Message[]>(session.getHistory());
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useInput((inputChar, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
onExit?.();
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async (value: string) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || isLoading) return;
|
||||||
|
|
||||||
|
// Handle commands
|
||||||
|
if (trimmed === '/quit' || trimmed === '/exit') {
|
||||||
|
onExit?.();
|
||||||
|
exit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === '/reset' || trimmed === '/clear') {
|
||||||
|
session.clear();
|
||||||
|
setMessages([]);
|
||||||
|
setInput('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular message
|
||||||
|
const userMessage: Message = { role: 'user', content: trimmed };
|
||||||
|
session.addMessage(userMessage);
|
||||||
|
setMessages(prev => [...prev, userMessage]);
|
||||||
|
setInput('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await modelClient.chat({
|
||||||
|
messages: session.getHistory(),
|
||||||
|
system: systemPrompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [isLoading, session, modelClient, systemPrompt, exit, onExit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" height="100%">
|
||||||
|
<StatusBar
|
||||||
|
sessionId={session.id}
|
||||||
|
messageCount={messages.length}
|
||||||
|
model={model}
|
||||||
|
/>
|
||||||
|
<MessageList messages={messages} />
|
||||||
|
<InputBar
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isLoading={isLoading}
|
||||||
|
placeholder="Type a message... (Esc to exit)"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import TextInput from 'ink-text-input';
|
||||||
|
|
||||||
|
export interface InputBarProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSubmit: (value: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InputBar({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
isLoading = false,
|
||||||
|
placeholder = 'Type a message...',
|
||||||
|
}: InputBarProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box borderStyle="single" borderColor="blue" paddingX={1}>
|
||||||
|
<Text color="blue">{'> '}</Text>
|
||||||
|
{isLoading ? (
|
||||||
|
<Text color="gray">Thinking...</Text>
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import type { Message } from '../../../models/types.js';
|
||||||
|
|
||||||
|
export interface MessageListProps {
|
||||||
|
messages: Message[];
|
||||||
|
maxHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList({ messages, maxHeight = 20 }: MessageListProps): React.ReactElement {
|
||||||
|
// Show only recent messages that fit
|
||||||
|
const visibleMessages = messages.slice(-maxHeight);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
||||||
|
{visibleMessages.length === 0 ? (
|
||||||
|
<Text color="gray">No messages yet. Start typing to chat with Flynn.</Text>
|
||||||
|
) : (
|
||||||
|
visibleMessages.map((message, index) => (
|
||||||
|
<Box key={index} marginBottom={1}>
|
||||||
|
<Text color={message.role === 'user' ? 'blue' : 'green'}>
|
||||||
|
{message.role === 'user' ? 'You: ' : 'Flynn: '}
|
||||||
|
</Text>
|
||||||
|
<Text wrap="wrap">{message.content}</Text>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
|
||||||
|
export interface StatusBarProps {
|
||||||
|
sessionId: string;
|
||||||
|
messageCount: number;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBar({ sessionId, messageCount, model }: StatusBarProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||||
|
<Box flexGrow={1}>
|
||||||
|
<Text color="cyan">Flynn</Text>
|
||||||
|
<Text color="gray"> | </Text>
|
||||||
|
<Text color="gray">Session: </Text>
|
||||||
|
<Text>{sessionId}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color="gray">Messages: </Text>
|
||||||
|
<Text>{messageCount}</Text>
|
||||||
|
<Text color="gray"> | </Text>
|
||||||
|
<Text color="gray">Model: </Text>
|
||||||
|
<Text color="green">{model}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { App, type AppProps } from './App.js';
|
||||||
|
export { StatusBar, type StatusBarProps } from './StatusBar.js';
|
||||||
|
export { MessageList, type MessageListProps } from './MessageList.js';
|
||||||
|
export { InputBar, type InputBarProps } from './InputBar.js';
|
||||||
+2
-1
@@ -13,7 +13,8 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"sourceMap": true
|
"sourceMap": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
Reference in New Issue
Block a user