feat: enhance TUI with colors, command hints, and improved rendering
This commit is contained in:
@@ -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