feat: add Ink-based fullscreen TUI components

This commit is contained in:
William Valentin
2026-02-05 00:39:53 -08:00
parent c0deeb5cf0
commit 53a8bd97eb
6 changed files with 197 additions and 1 deletions
+98
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+4
View File
@@ -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
View File
@@ -13,7 +13,8 @@
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
"sourceMap": true,
"jsx": "react-jsx"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]