517 lines
17 KiB
TypeScript
517 lines
17 KiB
TypeScript
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 type { NativeAgent } from '../../backends/native/agent.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';
|
|
import { createClientFromConfig } from '../../daemon/index.js';
|
|
import { loginGitHub } from '../../auth/index.js';
|
|
import type { PairingManager } from '../../channels/pairing.js';
|
|
import { getColoredBanner } from './banner.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 `${colors.orange}flynn...${colors.reset} `;
|
|
}
|
|
return `${colors.orange}${colors.bold}flynn>${colors.reset} `;
|
|
}
|
|
|
|
export interface MinimalTuiConfig {
|
|
session: ManagedSession;
|
|
modelClient: ModelClient;
|
|
modelRouter?: ModelRouter;
|
|
systemPrompt: string;
|
|
agent?: NativeAgent;
|
|
onFullscreen?: () => void;
|
|
onTransfer?: (target: string) => void;
|
|
localProviders?: Record<string, ModelConfig>;
|
|
currentLocalProvider?: string;
|
|
pairingManager?: PairingManager;
|
|
}
|
|
|
|
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];
|
|
},
|
|
});
|
|
|
|
// 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(getColoredBanner());
|
|
console.log(`\n${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) {
|
|
continue;
|
|
}
|
|
|
|
await this.handleCommand(command);
|
|
}
|
|
}
|
|
|
|
private prompt(promptText: string): Promise<string> {
|
|
return new Promise((resolve) => {
|
|
if (!this.rl) {
|
|
resolve('');
|
|
return;
|
|
}
|
|
const onClose = () => resolve('');
|
|
this.rl.once('close', onClose);
|
|
this.rl.question(promptText, (answer) => {
|
|
this.rl?.removeListener('close', onClose);
|
|
resolve(answer);
|
|
});
|
|
});
|
|
}
|
|
|
|
private async handleCommand(command: Command): Promise<void> {
|
|
switch (command.type) {
|
|
case 'quit':
|
|
this.stop();
|
|
break;
|
|
|
|
case 'reset':
|
|
this.config.session.clear();
|
|
this.totalUsage = { inputTokens: 0, outputTokens: 0 };
|
|
console.log(`${colors.gray}Session cleared.${colors.reset}\n`);
|
|
break;
|
|
|
|
case 'help':
|
|
console.log(getHelpText() + '\n');
|
|
break;
|
|
|
|
case 'status':
|
|
this.printStatus();
|
|
break;
|
|
|
|
case 'fullscreen':
|
|
this.config.onFullscreen?.();
|
|
break;
|
|
|
|
case 'model':
|
|
this.handleModelCommand(command.name, command.providerModel);
|
|
break;
|
|
|
|
case 'backend':
|
|
this.handleBackendCommand(command.provider);
|
|
break;
|
|
|
|
case 'login':
|
|
await this.handleLoginCommand(command.provider);
|
|
break;
|
|
|
|
case 'pair':
|
|
this.handlePairCommand(command.action, command.args);
|
|
break;
|
|
|
|
case 'transfer':
|
|
this.config.onTransfer?.(command.target);
|
|
break;
|
|
|
|
case 'message':
|
|
await this.handleMessage(command.content);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private handleModelCommand(name?: string, providerModel?: string): void {
|
|
const router = this.config.modelRouter;
|
|
if (!router) {
|
|
console.log(`${colors.gray}Model switching not available.${colors.reset}\n`);
|
|
return;
|
|
}
|
|
|
|
// /model <tier> <provider/model> — change a tier's provider and model
|
|
if (name && providerModel) {
|
|
const tier = resolveModelAlias(name);
|
|
const slashIdx = providerModel.indexOf('/');
|
|
if (slashIdx === -1) {
|
|
console.log(`${colors.gray}Invalid format. Use provider/model (e.g. anthropic/claude-sonnet-4)${colors.reset}\n`);
|
|
return;
|
|
}
|
|
const provider = providerModel.slice(0, slashIdx);
|
|
const model = providerModel.slice(slashIdx + 1);
|
|
|
|
try {
|
|
const client = createClientFromConfig({ provider: provider as 'anthropic', model });
|
|
router.setClient(tier, client, providerModel);
|
|
console.log(`${colors.gray}Set ${tier} to:${colors.reset} ${providerModel}\n`);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.log(`${colors.gray}Failed to create client:${colors.reset} ${message}\n`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// /model — show all tiers with labels
|
|
if (!name) {
|
|
const current = router.getTier();
|
|
const available = router.getAvailableTiers();
|
|
const labels = router.getAllLabels();
|
|
console.log(`${colors.gray}Active tier:${colors.reset} ${current}`);
|
|
for (const tier of available) {
|
|
const label = labels[tier] ?? 'unknown';
|
|
const marker = tier === current ? ' ←' : '';
|
|
console.log(` ${tier}: ${label}${marker}`);
|
|
}
|
|
console.log();
|
|
return;
|
|
}
|
|
|
|
// /model <tier> — switch active tier
|
|
const tier = resolveModelAlias(name);
|
|
if (router.setTier(tier)) {
|
|
// Also update the agent tier so chatWithRouter uses the correct client
|
|
if (this.config.agent) {
|
|
this.config.agent.setModelTier(tier);
|
|
}
|
|
console.log(`${colors.gray}Switched to model:${colors.reset} ${tier}\n`);
|
|
} else {
|
|
console.log(`${colors.gray}Model not available:${colors.reset} ${name}\n`);
|
|
}
|
|
}
|
|
|
|
private handleBackendCommand(provider?: string): void {
|
|
const router = this.config.modelRouter;
|
|
if (!router) {
|
|
console.log('Backend switching not available.\n');
|
|
return;
|
|
}
|
|
|
|
if (!provider) {
|
|
const current = router.getLocalProviderName() ?? this.config.currentLocalProvider ?? 'unknown';
|
|
const available = this.getAvailableBackends();
|
|
console.log(`Current local backend: ${current}`);
|
|
console.log(`Available: ${available.join(', ')}\n`);
|
|
return;
|
|
}
|
|
|
|
const providerConfig = this.config.localProviders?.[provider];
|
|
if (!providerConfig) {
|
|
const available = this.getAvailableBackends();
|
|
console.log(`Backend '${provider}' not configured.`);
|
|
console.log(`Available: ${available.join(', ')}\n`);
|
|
return;
|
|
}
|
|
|
|
const client = this.createLocalClient(providerConfig);
|
|
if (!client) {
|
|
console.log(`Failed to create client for '${provider}'.\n`);
|
|
return;
|
|
}
|
|
|
|
router.setLocalClient(client, provider);
|
|
console.log(`Switched to backend: ${provider}\n`);
|
|
}
|
|
|
|
private async handleLoginCommand(provider?: string): Promise<void> {
|
|
const target = provider ?? 'github';
|
|
if (target !== 'github') {
|
|
console.log(`${colors.gray}Unknown login provider:${colors.reset} ${target}. Only 'github' is supported.\n`);
|
|
return;
|
|
}
|
|
|
|
console.log(`${colors.gray}Starting GitHub OAuth device flow...${colors.reset}`);
|
|
|
|
try {
|
|
await loginGitHub((userCode, verificationUri) => {
|
|
console.log('');
|
|
console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`);
|
|
console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`);
|
|
console.log('');
|
|
console.log(`${colors.gray}Waiting for authorization...${colors.reset}`);
|
|
});
|
|
|
|
console.log(`${colors.gray}GitHub authentication successful! Token stored.${colors.reset}\n`);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.log(`${colors.gray}GitHub login failed:${colors.reset} ${message}\n`);
|
|
}
|
|
}
|
|
|
|
private handlePairCommand(action?: 'generate' | 'list' | 'revoke', args?: string): void {
|
|
const pm = this.config.pairingManager;
|
|
if (!pm) {
|
|
console.log(`${colors.gray}Pairing not enabled. Set pairing.enabled: true in config.${colors.reset}\n`);
|
|
return;
|
|
}
|
|
|
|
switch (action) {
|
|
case 'generate': {
|
|
const code = pm.generateCode(args);
|
|
const pending = pm.listPendingCodes().find(p => p.code === code);
|
|
const expiresIn = pending ? Math.round((pending.expiresAt - Date.now()) / 1000) : '?';
|
|
console.log(`${colors.bold}Pairing code: ${code}${colors.reset}`);
|
|
console.log(`${colors.gray}Expires in ${expiresIn}s${args ? ` (label: ${args})` : ''}${colors.reset}\n`);
|
|
break;
|
|
}
|
|
|
|
case 'revoke': {
|
|
if (!args) {
|
|
console.log(`${colors.gray}Usage: /pair revoke <channel> <senderId>${colors.reset}\n`);
|
|
return;
|
|
}
|
|
const parts = args.split(/\s+/);
|
|
if (parts.length < 2) {
|
|
console.log(`${colors.gray}Usage: /pair revoke <channel> <senderId>${colors.reset}\n`);
|
|
return;
|
|
}
|
|
const [channel, senderId] = parts;
|
|
const revoked = pm.revokeApproval(channel, senderId);
|
|
if (revoked) {
|
|
console.log(`${colors.bold}Revoked approval for ${channel}:${senderId}${colors.reset}\n`);
|
|
} else {
|
|
console.log(`${colors.gray}No approval found for ${channel}:${senderId}${colors.reset}\n`);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'list':
|
|
default: {
|
|
const pending = pm.listPendingCodes();
|
|
const approved = pm.listApproved();
|
|
|
|
if (pending.length === 0 && approved.length === 0) {
|
|
console.log(`${colors.gray}No pending codes or approved senders.${colors.reset}\n`);
|
|
return;
|
|
}
|
|
|
|
if (pending.length > 0) {
|
|
console.log(`${colors.bold}Pending codes:${colors.reset}`);
|
|
for (const p of pending) {
|
|
const expiresIn = Math.round((p.expiresAt - Date.now()) / 1000);
|
|
console.log(` ${p.code} expires in ${expiresIn}s${p.label ? ` (${p.label})` : ''}`);
|
|
}
|
|
}
|
|
|
|
if (approved.length > 0) {
|
|
console.log(`${colors.bold}Approved senders:${colors.reset}`);
|
|
for (const a of approved) {
|
|
const date = new Date(a.approvedAt).toISOString().slice(0, 16).replace('T', ' ');
|
|
console.log(` ${a.channel}:${a.senderId} since ${date} (code: ${a.codeUsed})`);
|
|
}
|
|
}
|
|
console.log('');
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private getAvailableBackends(): string[] {
|
|
const backends: string[] = [];
|
|
if (this.config.currentLocalProvider) {
|
|
backends.push(this.config.currentLocalProvider);
|
|
}
|
|
if (this.config.localProviders) {
|
|
backends.push(...Object.keys(this.config.localProviders));
|
|
}
|
|
return [...new Set(backends)];
|
|
}
|
|
|
|
private createLocalClient(config: ModelConfig): ModelClient | null {
|
|
if (config.provider === 'ollama') {
|
|
return new OllamaClient({
|
|
model: config.model,
|
|
host: config.endpoint,
|
|
});
|
|
}
|
|
if (config.provider === 'llamacpp') {
|
|
return new LlamaCppClient({
|
|
endpoint: config.endpoint ?? 'http://localhost:8080',
|
|
model: config.model,
|
|
authToken: config.auth_token,
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private printStatus(): void {
|
|
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> {
|
|
// Print Flynn label before response
|
|
process.stdout.write(`\n${colors.orange}${colors.bold}Flynn:${colors.reset}\n`);
|
|
|
|
try {
|
|
// Use agent if available (supports tool loop)
|
|
if (this.config.agent) {
|
|
const response = await this.config.agent.process(content);
|
|
const rendered = renderMarkdown(response);
|
|
console.log(rendered);
|
|
console.log();
|
|
return;
|
|
}
|
|
|
|
// Fallback: direct model client (no tool support)
|
|
this.config.session.addMessage({ role: 'user', content });
|
|
|
|
// Try streaming if available
|
|
if (this.config.modelClient.chatStream) {
|
|
let fullContent = '';
|
|
|
|
for await (const event of this.config.modelClient.chatStream({
|
|
messages: this.config.session.getHistory(),
|
|
system: this.config.systemPrompt,
|
|
})) {
|
|
if (event.type === 'content' && event.content) {
|
|
process.stdout.write(event.content);
|
|
fullContent += event.content;
|
|
}
|
|
if (event.type === 'fallback_warning' && event.fallbackReason) {
|
|
console.warn(`\n⚠ Using fallback model`);
|
|
}
|
|
if (event.type === 'done' && event.usage) {
|
|
this.totalUsage.inputTokens += event.usage.inputTokens;
|
|
this.totalUsage.outputTokens += event.usage.outputTokens;
|
|
}
|
|
if (event.type === 'error') {
|
|
throw event.error ?? new Error('Stream error');
|
|
}
|
|
}
|
|
|
|
console.log('\n');
|
|
|
|
this.config.session.addMessage({ role: 'assistant', content: fullContent });
|
|
} else {
|
|
// Fallback to non-streaming
|
|
const response = await this.config.modelClient.chat({
|
|
messages: this.config.session.getHistory(),
|
|
system: this.config.systemPrompt,
|
|
});
|
|
|
|
const rendered = renderMarkdown(response.content);
|
|
console.log(rendered);
|
|
console.log();
|
|
|
|
this.totalUsage.inputTokens += response.usage.inputTokens;
|
|
this.totalUsage.outputTokens += response.usage.outputTokens;
|
|
|
|
this.config.session.addMessage({ role: 'assistant', content: response.content });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error instanceof Error ? error.message : error);
|
|
console.log();
|
|
}
|
|
}
|
|
|
|
stop(preserveStdin = false): void {
|
|
this.running = false;
|
|
if (this.rl) {
|
|
if (preserveStdin) {
|
|
// Remove readline listeners but don't close stdin
|
|
this.rl.removeAllListeners();
|
|
process.stdin.removeAllListeners('keypress');
|
|
// Pause stdin so readline releases it
|
|
process.stdin.pause();
|
|
}
|
|
this.rl.close();
|
|
this.rl = null;
|
|
}
|
|
// Clean up keypress listener
|
|
process.stdin.removeAllListeners('keypress');
|
|
}
|
|
}
|