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
+99 -15
View File
@@ -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');
}
}