feat: enhance TUI with colors, command hints, and improved rendering

This commit is contained in:
William Valentin
2026-02-05 15:51:29 -08:00
parent dbf1acd822
commit c1f64d6ded
8 changed files with 459 additions and 122 deletions
+60 -26
View File
@@ -3,7 +3,7 @@ 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 { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions } 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';
@@ -43,6 +43,21 @@ export function App({
onExit?.();
exit();
}
return;
}
// Tab completion for slash commands
if (key.tab && !isStreaming) {
const completions = getCommandCompletions(input);
if (completions.length > 0) {
setInput(completions[0]);
return;
}
}
// Only handle scroll keys, ignore regular typing
if (!key.upArrow && !key.downArrow && !key.pageUp && !key.pageDown && !key.tab) {
return;
}
// Scroll handling
@@ -80,42 +95,60 @@ export function App({
setScrollOffset(0);
return;
case 'help':
case 'help': {
// Show help as system message
setMessages(prev => [...prev, { role: 'assistant', content: getHelpText() }]);
const helpMsg: Message = { role: 'assistant', content: getHelpText() };
const helpWithTs = session.addMessage(helpMsg);
setMessages(prev => [...prev, helpWithTs]);
return;
}
case 'status':
case 'status': {
const status = `Session: ${session.id}\nMessages: ${messages.length}\nTokens: ${tokenUsage.inputTokens} in / ${tokenUsage.outputTokens} out`;
setMessages(prev => [...prev, { role: 'assistant', content: status }]);
const statusMsg: Message = { role: 'assistant', content: status };
const statusWithTs = session.addMessage(statusMsg);
setMessages(prev => [...prev, statusWithTs]);
return;
}
case 'model':
case 'model': {
if (!modelRouter) {
setMessages(prev => [...prev, { role: 'assistant', content: 'Model switching not available.' }]);
const errMsg: Message = { role: 'assistant', content: 'Model switching not available.' };
const errWithTs = session.addMessage(errMsg);
setMessages(prev => [...prev, errWithTs]);
return;
}
if (!command.name) {
const info = `Current: ${modelRouter.getTier()}\nAvailable: ${modelRouter.getAvailableTiers().join(', ')}`;
setMessages(prev => [...prev, { role: 'assistant', content: info }]);
const infoMsg: Message = { role: 'assistant', content: info };
const infoWithTs = session.addMessage(infoMsg);
setMessages(prev => [...prev, infoWithTs]);
return;
}
const tier = resolveModelAlias(command.name);
if (modelRouter.setTier(tier)) {
setCurrentModel(tier);
setMessages(prev => [...prev, { role: 'assistant', content: `Switched to model: ${tier}` }]);
const successMsg: Message = { role: 'assistant', content: `Switched to model: ${tier}` };
const successWithTs = session.addMessage(successMsg);
setMessages(prev => [...prev, successWithTs]);
} else {
setMessages(prev => [...prev, { role: 'assistant', content: `Model not available: ${command.name}` }]);
const failMsg: Message = { role: 'assistant', content: `Model not available: ${command.name}` };
const failWithTs = session.addMessage(failMsg);
setMessages(prev => [...prev, failWithTs]);
}
return;
}
case 'fullscreen':
// Already in fullscreen
return;
case 'transfer':
setMessages(prev => [...prev, { role: 'assistant', content: `Transfer not supported in fullscreen mode.` }]);
case 'transfer': {
const xferMsg: Message = { role: 'assistant', content: `Transfer not supported in fullscreen mode.` };
const xferWithTs = session.addMessage(xferMsg);
setMessages(prev => [...prev, xferWithTs]);
return;
}
case 'message':
break; // Continue to message handling
@@ -125,8 +158,8 @@ export function App({
// Add user message
const userMessage: Message = { role: 'user', content: command.content };
session.addMessage(userMessage);
setMessages(prev => [...prev, userMessage]);
const messageWithTimestamp = session.addMessage(userMessage);
setMessages(prev => [...prev, messageWithTimestamp]);
setScrollOffset(0); // Auto-scroll to bottom
// Stream response
@@ -163,8 +196,8 @@ export function App({
}
const assistantMessage: Message = { role: 'assistant', content: fullContent };
session.addMessage(assistantMessage);
setMessages(prev => [...prev, assistantMessage]);
const assistantWithTimestamp = session.addMessage(assistantMessage);
setMessages(prev => [...prev, assistantWithTimestamp]);
} else {
// Fallback to non-streaming
const response = await modelClient.chat({
@@ -178,15 +211,16 @@ export function App({
}));
const assistantMessage: Message = { role: 'assistant', content: response.content };
session.addMessage(assistantMessage);
setMessages(prev => [...prev, assistantMessage]);
const assistantWithTimestamp = session.addMessage(assistantMessage);
setMessages(prev => [...prev, assistantWithTimestamp]);
}
} catch (error) {
const errorMessage: Message = {
role: 'assistant',
content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
setMessages(prev => [...prev, errorMessage]);
const errorWithTimestamp = session.addMessage(errorMessage);
setMessages(prev => [...prev, errorWithTimestamp]);
} finally {
setIsStreaming(false);
setStreamingContent('');
@@ -195,13 +229,6 @@ export function App({
return (
<Box flexDirection="column" height="100%">
<StatusBar
sessionId={session.id}
messageCount={messages.length}
model={currentModel}
tokenUsage={{ input: tokenUsage.inputTokens, output: tokenUsage.outputTokens }}
isStreaming={isStreaming}
/>
<MessageList
messages={messages}
scrollOffset={scrollOffset}
@@ -214,6 +241,13 @@ export function App({
isLoading={isStreaming}
placeholder={isStreaming ? 'Flynn is typing... (Esc to cancel)' : 'Type a message... (Esc=exit, /help)'}
/>
<StatusBar
sessionId={session.id}
messageCount={messages.length}
model={currentModel}
tokenUsage={{ input: tokenUsage.inputTokens, output: tokenUsage.outputTokens }}
isStreaming={isStreaming}
/>
</Box>
);
}
+42 -14
View File
@@ -1,6 +1,7 @@
import React from 'react';
import React, { memo, useMemo } from 'react';
import { Box, Text } from 'ink';
import TextInput from 'ink-text-input';
import { getCommandCompletions, getCommandTooltip } from '../commands.js';
export interface InputBarProps {
value: string;
@@ -10,26 +11,53 @@ export interface InputBarProps {
placeholder?: string;
}
export function InputBar({
export const InputBar = memo(function InputBar({
value,
onChange,
onSubmit,
isLoading = false,
placeholder = 'Type a message...',
}: InputBarProps): React.ReactElement {
const completions = useMemo(() => {
if (!value.startsWith('/')) return [];
return getCommandCompletions(value);
}, [value]);
const tooltip = useMemo(() => {
if (!value.startsWith('/')) return null;
return getCommandTooltip(value);
}, [value]);
const showTooltip = value.startsWith('/') && (tooltip || completions.length > 1);
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 flexDirection="column">
{/* Tooltip bar */}
{showTooltip && (
<Box paddingX={2} height={1} justifyContent="center">
<Text color="gray">
{tooltip
? `${tooltip}`
: `${completions.length} commands (Tab)`
}
</Text>
</Box>
)}
{/* Input bar */}
<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>
</Box>
);
}
});
+81 -23
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { Box, Text } from 'ink';
import React, { memo } from 'react';
import { Box, Text, Static } from 'ink';
import type { Message } from '../../../models/types.js';
import { renderMarkdown } from '../markdown.js';
@@ -9,12 +9,71 @@ export interface MessageListProps {
streamingContent?: string;
}
export function MessageList({
// Helper to format timestamp in human-readable way
function formatTimestamp(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString([], { month: 'short', day: 'numeric' });
}
// Individual message component
const MessageItem = memo(function MessageItem({
message,
index
}: {
message: Message;
index: number;
}): React.ReactElement {
const isUser = message.role === 'user';
const accentColor = isUser ? 'blue' : '#ff8c00';
const timestampText = message.timestamp ? formatTimestamp(message.timestamp) : '';
return (
<Box
key={index}
marginBottom={1}
flexDirection="row"
>
<Box minWidth={1} backgroundColor={accentColor} />
<Box
flexDirection="column"
flexGrow={1}
paddingX={2}
paddingY={1}
>
{/* Author line */}
<Box marginBottom={1} justifyContent="space-between" flexDirection="row">
<Text color={accentColor} bold>
{isUser ? 'You' : 'Flynn'}
</Text>
<Text color="gray">| {timestampText}</Text>
</Box>
{/* Content */}
<Text wrap="wrap">
{message.role === 'assistant'
? renderMarkdown(message.content)
: message.content}
</Text>
</Box>
</Box>
);
});
export const MessageList = memo(function MessageList({
messages,
scrollOffset = 0,
streamingContent,
}: MessageListProps): React.ReactElement {
// Calculate visible area (approximate, Ink handles overflow)
const visibleMessages = messages.slice(scrollOffset);
return (
@@ -23,26 +82,25 @@ export function MessageList({
<Text color="gray">No messages yet. Start typing to chat with Flynn.</Text>
) : (
<>
{visibleMessages.map((message, index) => (
<Box key={`${scrollOffset + index}-${message.role}`} marginBottom={1} flexDirection="column">
<Text color={message.role === 'user' ? 'blue' : 'green'} bold>
{message.role === 'user' ? 'You:' : 'Flynn:'}
</Text>
<Box marginLeft={1}>
<Text wrap="wrap">
{message.role === 'assistant'
? renderMarkdown(message.content)
: message.content}
</Text>
</Box>
</Box>
))}
<Static items={visibleMessages}>
{(message, index) => (
<MessageItem key={index} message={message} index={index} />
)}
</Static>
{streamingContent && (
<Box marginBottom={1} flexDirection="column">
<Text color="green" bold>Flynn:</Text>
<Box marginLeft={1}>
<Box marginBottom={1} flexDirection="row">
<Box minWidth={1} backgroundColor="#ff8c00" />
<Box
flexDirection="column"
flexGrow={1}
paddingX={2}
paddingY={1}
>
<Box marginBottom={1}>
<Text color="#ff8c00" bold>Flynn</Text>
</Box>
<Text wrap="wrap">{streamingContent}</Text>
<Text color="yellow">|</Text>
<Text color="yellow"></Text>
</Box>
</Box>
)}
@@ -55,4 +113,4 @@ export function MessageList({
)}
</Box>
);
}
});
+3 -3
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { memo } from 'react';
import { Box, Text } from 'ink';
export interface StatusBarProps {
@@ -19,7 +19,7 @@ function formatTokens(n: number): string {
return String(n);
}
export function StatusBar({
export const StatusBar = memo(function StatusBar({
sessionId,
messageCount,
model,
@@ -59,4 +59,4 @@ export function StatusBar({
</Box>
</Box>
);
}
});