feat: enhance TUI with colors, command hints, and improved rendering
This commit is contained in:
@@ -19,7 +19,7 @@ export function parseCommand(input: string): Command | null {
|
||||
}
|
||||
|
||||
// Reset
|
||||
if (trimmed === '/reset' || trimmed === '/clear') {
|
||||
if (trimmed === '/reset' || trimmed === '/clear' || trimmed === '/new') {
|
||||
return { type: 'reset' };
|
||||
}
|
||||
|
||||
@@ -69,19 +69,117 @@ export function parseCommand(input: string): Command | null {
|
||||
export function getHelpText(): string {
|
||||
return `
|
||||
Commands:
|
||||
/help, /? Show this help
|
||||
/model [name] Show or switch model (local, default, fast, complex)
|
||||
/help, /? Show this help
|
||||
/model [name] Show or switch model (local, default, fast, complex)
|
||||
/backend [provider] Show or switch local backend (ollama, llamacpp)
|
||||
/reset, /clear Clear conversation history
|
||||
/status Show session info and token usage
|
||||
/fullscreen, /fs Switch to fullscreen mode
|
||||
/transfer <dest> Transfer session to another frontend
|
||||
/quit, /exit Exit TUI
|
||||
/reset, /clear, /new Clear conversation history
|
||||
/status Show session info and token usage
|
||||
/fullscreen, /fs Switch to fullscreen mode
|
||||
/transfer <dest> Transfer session to another frontend
|
||||
/quit, /exit Exit TUI
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export type ModelAlias = 'local' | 'default' | 'fast' | 'complex' | 'opus' | 'sonnet' | 'ollama';
|
||||
|
||||
// List of all slash commands for autocompletion
|
||||
export const SLASH_COMMANDS = [
|
||||
'/help',
|
||||
'/model',
|
||||
'/backend',
|
||||
'/reset',
|
||||
'/clear',
|
||||
'/new',
|
||||
'/status',
|
||||
'/fullscreen',
|
||||
'/fs',
|
||||
'/transfer',
|
||||
'/quit',
|
||||
'/exit',
|
||||
];
|
||||
|
||||
// Command descriptions for tooltips
|
||||
export const COMMAND_TOOLTIPS: Record<string, string> = {
|
||||
'/help': 'Show available commands',
|
||||
'/model': 'Show or switch model (local, default, fast, complex)',
|
||||
'/backend': 'Show or switch local backend (ollama, llamacpp)',
|
||||
'/reset': 'Clear conversation history',
|
||||
'/clear': 'Clear conversation history',
|
||||
'/new': 'Start a new conversation',
|
||||
'/status': 'Show session info and token usage',
|
||||
'/fullscreen': 'Switch to fullscreen mode',
|
||||
'/fs': 'Switch to fullscreen mode',
|
||||
'/transfer': 'Transfer session to another frontend',
|
||||
'/quit': 'Exit TUI',
|
||||
'/exit': 'Exit TUI',
|
||||
};
|
||||
|
||||
// Model aliases for /model command autocompletion
|
||||
export const MODEL_ALIASES = ['local', 'default', 'fast', 'complex', 'opus', 'sonnet', 'ollama'];
|
||||
|
||||
// Model alias descriptions
|
||||
export const MODEL_TOOLTIPS: Record<string, string> = {
|
||||
local: 'Local Ollama model',
|
||||
default: 'Default model (Opus)',
|
||||
fast: 'Fast model (Sonnet)',
|
||||
complex: 'Complex reasoning model',
|
||||
opus: 'Claude Opus',
|
||||
sonnet: 'Claude Sonnet',
|
||||
ollama: 'Local Ollama model',
|
||||
};
|
||||
|
||||
export function getCommandCompletions(partial: string): string[] {
|
||||
const trimmed = partial.trim();
|
||||
|
||||
// Complete /model arguments
|
||||
if (trimmed.startsWith('/model ')) {
|
||||
const modelPartial = trimmed.slice('/model '.length).toLowerCase();
|
||||
return MODEL_ALIASES
|
||||
.filter(alias => alias.startsWith(modelPartial))
|
||||
.map(alias => `/model ${alias}`);
|
||||
}
|
||||
|
||||
// Complete slash commands
|
||||
if (trimmed.startsWith('/')) {
|
||||
return SLASH_COMMANDS.filter(cmd => cmd.startsWith(trimmed.toLowerCase()));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getCommandTooltip(partial: string): string | null {
|
||||
const trimmed = partial.trim().toLowerCase();
|
||||
|
||||
// Tooltip for /model arguments
|
||||
if (trimmed.startsWith('/model ')) {
|
||||
const modelArg = trimmed.slice('/model '.length).trim();
|
||||
if (modelArg && MODEL_TOOLTIPS[modelArg]) {
|
||||
return MODEL_TOOLTIPS[modelArg];
|
||||
}
|
||||
// Show tooltip for partial match
|
||||
const matches = MODEL_ALIASES.filter(a => a.startsWith(modelArg));
|
||||
if (matches.length === 1 && MODEL_TOOLTIPS[matches[0]]) {
|
||||
return MODEL_TOOLTIPS[matches[0]];
|
||||
}
|
||||
return 'Choose: local, default, fast, complex';
|
||||
}
|
||||
|
||||
// Exact match tooltip
|
||||
if (COMMAND_TOOLTIPS[trimmed]) {
|
||||
return COMMAND_TOOLTIPS[trimmed];
|
||||
}
|
||||
|
||||
// Partial match - show tooltip if only one command matches
|
||||
if (trimmed.startsWith('/')) {
|
||||
const matches = SLASH_COMMANDS.filter(cmd => cmd.startsWith(trimmed));
|
||||
if (matches.length === 1 && COMMAND_TOOLTIPS[matches[0]]) {
|
||||
return COMMAND_TOOLTIPS[matches[0]];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveModelAlias(alias: string): 'local' | 'default' | 'fast' | 'complex' {
|
||||
const map: Record<string, 'local' | 'default' | 'fast' | 'complex'> = {
|
||||
local: 'local',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,70 +1,105 @@
|
||||
import { marked, Renderer, type Tokens } from 'marked';
|
||||
import { marked, type Tokens, type RendererObject } from 'marked';
|
||||
import { highlight } from 'cli-highlight';
|
||||
|
||||
// Create a custom renderer extending the base Renderer
|
||||
class TerminalRenderer extends Renderer {
|
||||
// Helper to convert HTML to terminal-friendly text
|
||||
function convertHtmlToTerminal(text: string): string {
|
||||
return text
|
||||
// Line breaks
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
// Block elements to newlines
|
||||
.replace(/<\/(?:p|div|section|article)>/gi, '\n')
|
||||
.replace(/<(?:p|div|section|article)[^>]*>/gi, '')
|
||||
// Inline formatting - bold
|
||||
.replace(/<(?:b|strong)[^>]*>(.*?)<\/(?:b|strong)>/gi, '\x1b[1m$1\x1b[0m')
|
||||
// Inline formatting - italic
|
||||
.replace(/<(?:i|em)[^>]*>(.*?)<\/(?:i|em)>/gi, '\x1b[3m$1\x1b[0m')
|
||||
// Inline formatting - code
|
||||
.replace(/<code[^>]*>(.*?)<\/code>/gi, '\x1b[36m$1\x1b[0m')
|
||||
// Links - show text with URL
|
||||
.replace(/<a[^>]*href=["']([^"']*)["'][^>]*>(.*?)<\/a>/gi, '\x1b[4m$2\x1b[0m (\x1b[34m$1\x1b[0m)')
|
||||
// Headings
|
||||
.replace(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, '\x1b[1m\x1b[4m$1\x1b[0m\n')
|
||||
// Lists
|
||||
.replace(/<li[^>]*>(.*?)<\/li>/gi, ' • $1\n')
|
||||
.replace(/<\/?(?:ul|ol)[^>]*>/gi, '')
|
||||
// Blockquotes
|
||||
.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gi, ' │ $1\n')
|
||||
// Horizontal rules
|
||||
.replace(/<hr\s*\/?>/gi, '─'.repeat(40) + '\n')
|
||||
// Strip any remaining HTML tags
|
||||
.replace(/<[^>]+>/g, '')
|
||||
// Decode common HTML entities
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ');
|
||||
}
|
||||
|
||||
// Custom renderer as an object for marked.use()
|
||||
const terminalRenderer: RendererObject = {
|
||||
code({ text, lang }: Tokens.Code): string {
|
||||
try {
|
||||
return '\n' + highlight(text, { language: lang || 'plaintext' }) + '\n';
|
||||
} catch {
|
||||
return '\n' + text + '\n';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
codespan({ text }: Tokens.Codespan): string {
|
||||
return `\x1b[36m${text}\x1b[0m`; // Cyan for inline code
|
||||
}
|
||||
},
|
||||
|
||||
strong({ tokens }: Tokens.Strong): string {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
strong({ text }: Tokens.Strong): string {
|
||||
return `\x1b[1m${text}\x1b[0m`; // Bold
|
||||
}
|
||||
},
|
||||
|
||||
em({ tokens }: Tokens.Em): string {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
em({ text }: Tokens.Em): string {
|
||||
return `\x1b[3m${text}\x1b[0m`; // Italic
|
||||
}
|
||||
},
|
||||
|
||||
paragraph({ tokens }: Tokens.Paragraph): string {
|
||||
return this.parser.parseInline(tokens) + '\n';
|
||||
}
|
||||
paragraph({ text }: Tokens.Paragraph): string {
|
||||
return text + '\n';
|
||||
},
|
||||
|
||||
list({ items, ordered }: Tokens.List): string {
|
||||
let body = '';
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const bullet = ordered ? `${i + 1}. ` : ' • ';
|
||||
body += bullet + this.parser.parseInline(item.tokens) + '\n';
|
||||
body += bullet + item.text + '\n';
|
||||
}
|
||||
return body;
|
||||
}
|
||||
},
|
||||
|
||||
listitem({ tokens }: Tokens.ListItem): string {
|
||||
return this.parser.parseInline(tokens);
|
||||
}
|
||||
listitem({ text }: Tokens.ListItem): string {
|
||||
return text;
|
||||
},
|
||||
|
||||
heading({ tokens }: Tokens.Heading): string {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
heading({ text }: Tokens.Heading): string {
|
||||
return `\x1b[1m\x1b[4m${text}\x1b[0m\n`; // Bold + underline
|
||||
}
|
||||
},
|
||||
|
||||
link({ tokens, href }: Tokens.Link): string {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
link({ text, href }: Tokens.Link): string {
|
||||
return `\x1b[4m${text}\x1b[0m (\x1b[34m${href}\x1b[0m)`; // Underline text, blue URL
|
||||
}
|
||||
},
|
||||
|
||||
blockquote({ tokens }: Tokens.Blockquote): string {
|
||||
const text = this.parser.parse(tokens);
|
||||
blockquote({ text }: Tokens.Blockquote): string {
|
||||
return text.split('\n').map((line: string) => ` │ ${line}`).join('\n') + '\n';
|
||||
}
|
||||
},
|
||||
|
||||
hr(): string {
|
||||
return '─'.repeat(40) + '\n';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
html({ text }: Tokens.HTML | Tokens.Tag): string {
|
||||
return convertHtmlToTerminal(text);
|
||||
},
|
||||
};
|
||||
|
||||
// Configure marked with our renderer
|
||||
marked.use({ renderer: new TerminalRenderer() });
|
||||
marked.use({ renderer: terminalRenderer });
|
||||
|
||||
export function renderMarkdown(text: string): string {
|
||||
try {
|
||||
|
||||
@@ -6,12 +6,12 @@ import { MinimalTui } from './minimal.js';
|
||||
describe('formatPrompt', () => {
|
||||
it('formats default prompt', () => {
|
||||
const prompt = formatPrompt('default');
|
||||
expect(prompt).toBe('flynn> ');
|
||||
expect(prompt).toContain('flynn>');
|
||||
});
|
||||
|
||||
it('formats thinking prompt', () => {
|
||||
const prompt = formatPrompt('thinking');
|
||||
expect(prompt).toContain('...');
|
||||
expect(prompt).toContain('flynn...');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,18 +2,29 @@ import * as readline from 'node:readline';
|
||||
import type { ManagedSession } from '../../session/index.js';
|
||||
import type { ModelClient, TokenUsage } from '../../models/types.js';
|
||||
import type { ModelRouter, ModelTier } from '../../models/router.js';
|
||||
import { parseCommand, getHelpText, resolveModelAlias, type Command } from './commands.js';
|
||||
import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions, getCommandTooltip, type Command } from './commands.js';
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
import type { ModelConfig } from '../../config/schema.js';
|
||||
import { OllamaClient, LlamaCppClient } from '../../models/index.js';
|
||||
|
||||
export { parseCommand, type Command };
|
||||
|
||||
// ANSI color codes
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
blue: '\x1b[34m',
|
||||
orange: '\x1b[38;5;208m',
|
||||
gray: '\x1b[90m',
|
||||
bgDark: '\x1b[48;5;234m',
|
||||
};
|
||||
|
||||
export function formatPrompt(state: 'default' | 'thinking'): string {
|
||||
if (state === 'thinking') {
|
||||
return 'flynn... ';
|
||||
return `${colors.orange}flynn...${colors.reset} `;
|
||||
}
|
||||
return 'flynn> ';
|
||||
return `${colors.orange}${colors.bold}flynn>${colors.reset} `;
|
||||
}
|
||||
|
||||
export interface MinimalTuiConfig {
|
||||
@@ -31,26 +42,95 @@ export class MinimalTui {
|
||||
private rl: readline.Interface | null = null;
|
||||
private running = false;
|
||||
private totalUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 };
|
||||
private currentHint = '';
|
||||
private lastLine = '';
|
||||
|
||||
constructor(private config: MinimalTuiConfig) {}
|
||||
|
||||
private showHint(line: string): void {
|
||||
if (!line.startsWith('/')) {
|
||||
this.clearHint();
|
||||
return;
|
||||
}
|
||||
|
||||
const completions = getCommandCompletions(line);
|
||||
const tooltip = getCommandTooltip(line);
|
||||
|
||||
let hint = '';
|
||||
|
||||
if (completions.length === 1 && completions[0] !== line) {
|
||||
// Show the remaining part of the completion as a hint
|
||||
hint = completions[0].slice(line.length);
|
||||
}
|
||||
|
||||
// Add tooltip if available
|
||||
if (tooltip) {
|
||||
hint += ` ${colors.gray}— ${tooltip}${colors.reset}`;
|
||||
} else if (completions.length > 1) {
|
||||
hint += ` ${colors.gray}[${completions.length} options, Tab to complete]${colors.reset}`;
|
||||
}
|
||||
|
||||
if (hint && hint !== this.currentHint) {
|
||||
this.clearHint();
|
||||
this.currentHint = hint;
|
||||
// Save cursor, write dim hint, restore cursor
|
||||
process.stdout.write(`\x1b[s${colors.dim}${hint}${colors.reset}\x1b[u`);
|
||||
} else if (!hint) {
|
||||
this.clearHint();
|
||||
}
|
||||
}
|
||||
|
||||
private clearHint(): void {
|
||||
if (this.currentHint) {
|
||||
// Clear from cursor to end of line
|
||||
process.stdout.write('\x1b[K');
|
||||
this.currentHint = '';
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.running = true;
|
||||
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
completer: (line: string) => {
|
||||
const completions = getCommandCompletions(line);
|
||||
return [completions, line];
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Flynn TUI (minimal mode)');
|
||||
console.log('Type /help for commands, /fullscreen for panel mode\n');
|
||||
// Listen for line changes to show hints
|
||||
process.stdin.on('keypress', () => {
|
||||
// Small delay to let readline update the line
|
||||
setImmediate(() => {
|
||||
if (this.rl) {
|
||||
const line = (this.rl as readline.Interface & { line?: string }).line || '';
|
||||
if (line !== this.lastLine) {
|
||||
this.lastLine = line;
|
||||
this.showHint(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Enable keypress events
|
||||
if (process.stdin.isTTY) {
|
||||
readline.emitKeypressEvents(process.stdin);
|
||||
}
|
||||
|
||||
console.log(`${colors.orange}${colors.bold}Flynn TUI${colors.reset} ${colors.dim}(minimal mode)${colors.reset}`);
|
||||
console.log(`${colors.gray}Type /help for commands, /fullscreen for panel mode${colors.reset}\n`);
|
||||
|
||||
await this.promptLoop();
|
||||
}
|
||||
|
||||
private async promptLoop(): Promise<void> {
|
||||
while (this.running && this.rl) {
|
||||
this.lastLine = '';
|
||||
this.currentHint = '';
|
||||
const input = await this.prompt(formatPrompt('default'));
|
||||
this.clearHint();
|
||||
const command = parseCommand(input);
|
||||
|
||||
if (!command) {
|
||||
@@ -81,7 +161,7 @@ export class MinimalTui {
|
||||
case 'reset':
|
||||
this.config.session.clear();
|
||||
this.totalUsage = { inputTokens: 0, outputTokens: 0 };
|
||||
console.log('Session cleared.\n');
|
||||
console.log(`${colors.gray}Session cleared.${colors.reset}\n`);
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
@@ -117,23 +197,23 @@ export class MinimalTui {
|
||||
private handleModelCommand(name?: string): void {
|
||||
const router = this.config.modelRouter;
|
||||
if (!router) {
|
||||
console.log('Model switching not available.\n');
|
||||
console.log(`${colors.gray}Model switching not available.${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
const current = router.getTier();
|
||||
const available = router.getAvailableTiers();
|
||||
console.log(`Current model: ${current}`);
|
||||
console.log(`Available: ${available.join(', ')}\n`);
|
||||
console.log(`${colors.gray}Current model:${colors.reset} ${current}`);
|
||||
console.log(`${colors.gray}Available:${colors.reset} ${available.join(', ')}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tier = resolveModelAlias(name);
|
||||
if (router.setTier(tier)) {
|
||||
console.log(`Switched to model: ${tier}\n`);
|
||||
console.log(`${colors.gray}Switched to model:${colors.reset} ${tier}\n`);
|
||||
} else {
|
||||
console.log(`Model not available: ${name}\n`);
|
||||
console.log(`${colors.gray}Model not available:${colors.reset} ${name}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +271,7 @@ export class MinimalTui {
|
||||
if (config.provider === 'llamacpp') {
|
||||
return new LlamaCppClient({
|
||||
endpoint: config.endpoint ?? 'http://localhost:8080',
|
||||
model: config.model,
|
||||
authToken: config.auth_token,
|
||||
});
|
||||
}
|
||||
@@ -198,15 +279,16 @@ export class MinimalTui {
|
||||
}
|
||||
|
||||
private printStatus(): void {
|
||||
console.log(`Session: ${this.config.session.id}`);
|
||||
console.log(`Messages: ${this.config.session.getHistory().length}`);
|
||||
console.log(`Tokens: ${this.totalUsage.inputTokens} in / ${this.totalUsage.outputTokens} out\n`);
|
||||
console.log(`${colors.gray}Session:${colors.reset} ${this.config.session.id}`);
|
||||
console.log(`${colors.gray}Messages:${colors.reset} ${this.config.session.getHistory().length}`);
|
||||
console.log(`${colors.gray}Tokens:${colors.reset} ${this.totalUsage.inputTokens} in / ${this.totalUsage.outputTokens} out\n`);
|
||||
}
|
||||
|
||||
private async handleMessage(content: string): Promise<void> {
|
||||
this.config.session.addMessage({ role: 'user', content });
|
||||
|
||||
process.stdout.write('\n');
|
||||
// Print Flynn label before response
|
||||
process.stdout.write(`\n${colors.orange}${colors.bold}Flynn:${colors.reset}\n`);
|
||||
|
||||
try {
|
||||
// Try streaming if available
|
||||
@@ -268,5 +350,7 @@ export class MinimalTui {
|
||||
this.rl.close();
|
||||
this.rl = null;
|
||||
}
|
||||
// Clean up keypress listener
|
||||
process.stdin.removeAllListeners('keypress');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user