From c1f64d6deddcce9795bc4f3d19623d48a11d010a Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 5 Feb 2026 15:51:29 -0800 Subject: [PATCH] feat: enhance TUI with colors, command hints, and improved rendering --- src/frontends/tui/commands.ts | 114 +++++++++++++++++-- src/frontends/tui/components/App.tsx | 86 +++++++++----- src/frontends/tui/components/InputBar.tsx | 56 ++++++--- src/frontends/tui/components/MessageList.tsx | 104 +++++++++++++---- src/frontends/tui/components/StatusBar.tsx | 6 +- src/frontends/tui/markdown.ts | 97 +++++++++++----- src/frontends/tui/minimal.test.ts | 4 +- src/frontends/tui/minimal.ts | 114 ++++++++++++++++--- 8 files changed, 459 insertions(+), 122 deletions(-) diff --git a/src/frontends/tui/commands.ts b/src/frontends/tui/commands.ts index 7602f4e..61b9adf 100644 --- a/src/frontends/tui/commands.ts +++ b/src/frontends/tui/commands.ts @@ -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 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 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 = { + '/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 = { + 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 = { local: 'local', diff --git a/src/frontends/tui/components/App.tsx b/src/frontends/tui/components/App.tsx index 2511964..6dfb02f 100644 --- a/src/frontends/tui/components/App.tsx +++ b/src/frontends/tui/components/App.tsx @@ -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 ( - + ); } diff --git a/src/frontends/tui/components/InputBar.tsx b/src/frontends/tui/components/InputBar.tsx index 44c0238..33d79cc 100644 --- a/src/frontends/tui/components/InputBar.tsx +++ b/src/frontends/tui/components/InputBar.tsx @@ -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 ( - - {'> '} - {isLoading ? ( - Thinking... - ) : ( - + + {/* Tooltip bar */} + {showTooltip && ( + + + {tooltip + ? `→ ${tooltip}` + : `${completions.length} commands (Tab)` + } + + )} + + {/* Input bar */} + + {'> '} + {isLoading ? ( + Thinking... + ) : ( + + )} + ); -} +}); diff --git a/src/frontends/tui/components/MessageList.tsx b/src/frontends/tui/components/MessageList.tsx index efcaf8a..ad2518d 100644 --- a/src/frontends/tui/components/MessageList.tsx +++ b/src/frontends/tui/components/MessageList.tsx @@ -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 ( + + + + {/* Author line */} + + + {isUser ? 'You' : 'Flynn'} + + | {timestampText} + + + {/* Content */} + + {message.role === 'assistant' + ? renderMarkdown(message.content) + : message.content} + + + + ); +}); + +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({ No messages yet. Start typing to chat with Flynn. ) : ( <> - {visibleMessages.map((message, index) => ( - - - {message.role === 'user' ? 'You:' : 'Flynn:'} - - - - {message.role === 'assistant' - ? renderMarkdown(message.content) - : message.content} - - - - ))} + + {(message, index) => ( + + )} + {streamingContent && ( - - Flynn: - + + + + + Flynn + {streamingContent} - | + )} @@ -55,4 +113,4 @@ export function MessageList({ )} ); -} +}); diff --git a/src/frontends/tui/components/StatusBar.tsx b/src/frontends/tui/components/StatusBar.tsx index 9e1369d..edf477a 100644 --- a/src/frontends/tui/components/StatusBar.tsx +++ b/src/frontends/tui/components/StatusBar.tsx @@ -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({ ); -} +}); diff --git a/src/frontends/tui/markdown.ts b/src/frontends/tui/markdown.ts index c879fd4..6a00d9d 100644 --- a/src/frontends/tui/markdown.ts +++ b/src/frontends/tui/markdown.ts @@ -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(//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>/gi, '\x1b[36m$1\x1b[0m') + // Links - show text with URL + .replace(/]*href=["']([^"']*)["'][^>]*>(.*?)<\/a>/gi, '\x1b[4m$2\x1b[0m (\x1b[34m$1\x1b[0m)') + // Headings + .replace(/]*>(.*?)<\/h[1-6]>/gi, '\x1b[1m\x1b[4m$1\x1b[0m\n') + // Lists + .replace(/]*>(.*?)<\/li>/gi, ' • $1\n') + .replace(/<\/?(?:ul|ol)[^>]*>/gi, '') + // Blockquotes + .replace(/]*>(.*?)<\/blockquote>/gi, ' │ $1\n') + // Horizontal rules + .replace(//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 { diff --git a/src/frontends/tui/minimal.test.ts b/src/frontends/tui/minimal.test.ts index ecdfe89..1830536 100644 --- a/src/frontends/tui/minimal.test.ts +++ b/src/frontends/tui/minimal.test.ts @@ -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...'); }); }); diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index be3626f..0d37380 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -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 { 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 { 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 { 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'); } }