Files
flynn/src/frontends/tui/minimal.ts
T
2026-02-15 20:06:35 -08:00

839 lines
29 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, ModelProvider } from '../../config/schema.js';
import { MODEL_PROVIDERS } from '../../config/schema.js';
import { OllamaClient, LlamaCppClient } from '../../models/index.js';
import { createClientFromConfig } from '../../daemon/index.js';
import {
loadStoredAnthropicAuth,
loadStoredAnthropicAuthToken,
loadStoredOpenAIApiKey,
loadStoredOpenAIAuth,
loadStoredZaiAuth,
loginGitHub,
loginOpenAI,
storeAnthropicAuth,
storeAnthropicAuthToken,
storeOpenAIApiKey,
storeZaiAuth,
} from '../../auth/index.js';
import type { PairingManager } from '../../channels/pairing.js';
import { getColoredBanner } from './banner.js';
import type { HookEngine } from '../../hooks/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 `${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>;
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
currentLocalProvider?: string;
pairingManager?: PairingManager;
hookEngine?: HookEngine;
}
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;
if (this.config.agent && this.config.modelRouter) {
this.config.agent.setModelTier(this.config.modelRouter.getTier());
}
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
completer: (line: string) => {
const completions = getCommandCompletions(line);
return [completions, line];
},
});
// In minimal TUI we can prompt inline for tool confirmations.
// This avoids deadlocks when hooks are configured to require confirmation
// (e.g. shell.exec) and the tool loop is awaiting a decision.
if (this.config.hookEngine) {
this.config.hookEngine.setInteractiveConfirmer(async (pending) => {
const tool = pending.tool;
const args = pending.args;
const argsStr = Object.keys(args).length > 0 ? ` ${JSON.stringify(args)}` : '';
console.log(`\n${colors.bold}Confirmation required${colors.reset}`);
console.log(`${colors.gray}${tool}${colors.reset}${argsStr}`);
const answer = (await this.prompt(`${colors.orange}${colors.bold}Approve?${colors.reset} ${colors.gray}(y/N)${colors.reset} `))
.trim()
.toLowerCase();
const approved = answer === 'y' || answer === 'yes';
console.log(approved ? `${colors.gray}Approved.${colors.reset}\n` : `${colors.gray}Denied.${colors.reset}\n`);
return approved ? { approved: true } : { approved: false, reason: 'Denied by user' };
});
}
// 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':
await 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);
if (!MODEL_PROVIDERS.includes(provider as ModelProvider)) {
console.log(`${colors.gray}Unknown provider "${provider}". Known providers: ${MODEL_PROVIDERS.join(', ')}${colors.reset}\n`);
return;
}
try {
const providerType = provider as ModelProvider;
const template = this.config.modelProviderConfigs?.[providerType];
const client = createClientFromConfig({
...(template ?? {}),
provider: providerType,
model,
});
router.setClient(tier, client, providerModel);
router.setTierStrict(tier, true);
router.setTier(tier);
if (this.config.agent) {
this.config.agent.setModelTier(tier);
}
console.log(`${colors.gray}Set ${tier} to:${colors.reset} ${providerModel}`);
console.log(`${colors.gray}Switched to model:${colors.reset} ${tier}`);
console.log(`${colors.gray}Fallbacks for ${tier} disabled (strict tier mode).${colors.reset}\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 async handleBackendCommand(provider?: string): Promise<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;
}
// Stop current daemon if running
const currentBackend = router.getLocalProviderName();
if (currentBackend && currentBackend !== provider) {
console.log(`${colors.gray}Stopping ${currentBackend}...${colors.reset}`);
await this.stopBackend(currentBackend);
}
// Start new daemon
console.log(`${colors.gray}Starting ${provider}...${colors.reset}`);
await this.startBackend(provider, providerConfig);
const client = this.createLocalClient(providerConfig);
if (!client) {
console.log(`Failed to create client for '${provider}'.\n`);
return;
}
router.setLocalClient(client, provider);
console.log(`${colors.gray}Switched to backend: ${provider}${colors.reset}\n`);
}
private async stopBackend(provider: string): Promise<void> {
try {
const { exec } = await import('child_process');
let serviceName: string;
switch (provider) {
case 'ollama':
serviceName = 'ollama.service';
break;
case 'llamacpp':
serviceName = 'llama-server.service';
break;
default:
return;
}
await new Promise<void>((resolve, reject) => {
exec(`systemctl --user stop ${serviceName}`, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
} catch (error) {
// Service might not exist or already stopped, ignore
console.log(`${colors.gray}Note: ${provider} service not managed by systemd${colors.reset}\n`);
}
}
private async startBackend(provider: string, config: ModelConfig): Promise<void> {
try {
const { exec } = await import('child_process');
let serviceName: string;
switch (provider) {
case 'ollama':
serviceName = 'ollama.service';
break;
case 'llamacpp':
serviceName = 'llama-server.service';
break;
default:
return;
}
await new Promise<void>((resolve, reject) => {
exec(`systemctl --user start ${serviceName}`, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
// Wait briefly for daemon to start
await new Promise(resolve => setTimeout(resolve, 500));
} catch (error) {
console.log(`${colors.gray}Warning: Failed to start ${provider} via systemd: ${error instanceof Error ? error.message : String(error)}${colors.reset}\n`);
}
}
private async handleLoginCommand(provider?: string): Promise<void> {
const target = provider ?? 'github';
const promptHidden = async (question: string): Promise<string> => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
const rlAny = rl as any;
rlAny.stdoutMuted = true;
rlAny._writeToOutput = (s: string) => {
if (!rlAny.stdoutMuted) {
process.stdout.write(s);
return;
}
if (s.includes('\n')) {
process.stdout.write('\n');
} else {
process.stdout.write('*');
}
};
const answer = await new Promise<string>((resolve) => rl.question(question, resolve));
rlAny.stdoutMuted = false;
rl.close();
process.stdout.write('\n');
return answer.trim();
};
if (!this.rl) {
console.log(`${colors.gray}TUI not ready for login prompt. Use the CLI auth commands instead.${colors.reset}\n`);
return;
}
if (target === 'github') {
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`);
}
return;
}
if (target === 'openai') {
console.log(`${colors.gray}OpenAI login:${colors.reset}`);
console.log(`${colors.gray} 1) OAuth device flow 2) Paste API key${colors.reset}`);
const choice = (await this.prompt(`${colors.orange}Choose [1-2] (default 1):${colors.reset} `)).trim();
// 2) API key
if (choice === '2') {
const existing = loadStoredOpenAIApiKey();
if (existing) {
console.log(`${colors.gray}OpenAI API key already exists.${colors.reset}`);
console.log(`${colors.gray}Delete ~/.config/flynn/auth.json openai.api_key entry to re-authenticate.${colors.reset}\n`);
return;
}
console.log(`${colors.gray}OpenAI uses API keys for standard API access.${colors.reset}`);
console.log(`${colors.gray}Create a key at:${colors.reset} https://platform.openai.com/api-keys`);
console.log('');
try {
this.rl.pause();
const apiKey = await promptHidden('Enter OpenAI API key: ');
storeOpenAIApiKey(apiKey);
console.log('');
console.log(`${colors.gray}OpenAI API key stored in ~/.config/flynn/auth.json${colors.reset}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`${colors.gray}OpenAI API key storage failed:${colors.reset} ${message}\n`);
} finally {
this.rl.resume();
}
return;
}
// 1) OAuth device flow (default)
const existing = loadStoredOpenAIAuth();
if (existing) {
console.log(`${colors.gray}OpenAI OAuth token already exists.${colors.reset}`);
console.log(`${colors.gray}Delete ~/.config/flynn/auth.json openai.oauth entry (or legacy openai entry) to re-authenticate.${colors.reset}\n`);
return;
}
console.log(`${colors.gray}Starting OpenAI OAuth device flow...${colors.reset}`);
try {
await loginOpenAI((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}OpenAI authentication successful! Token stored.${colors.reset}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`${colors.gray}OpenAI login failed:${colors.reset} ${message}\n`);
}
return;
}
if (target === 'anthropic') {
console.log(`${colors.gray}Anthropic login:${colors.reset}`);
console.log(`${colors.gray} 1) Paste API key 2) Paste auth token${colors.reset}`);
const choice = (await this.prompt(`${colors.orange}Choose [1-2] (default 1):${colors.reset} `)).trim();
const existing = loadStoredAnthropicAuth();
const hasApiKey = Boolean(existing?.api_key);
const hasToken = Boolean(loadStoredAnthropicAuthToken());
// 2) Auth token
if (choice === '2') {
if (hasToken) {
console.log(`${colors.gray}Anthropic auth token already exists.${colors.reset}`);
console.log(`${colors.gray}Delete ~/.config/flynn/auth.json anthropic.auth_token entry to re-authenticate.${colors.reset}\n`);
return;
}
console.log(`${colors.gray}Anthropic supports token-style auth (provider-specific).${colors.reset}`);
console.log('');
try {
this.rl.pause();
const token = await promptHidden('Enter Anthropic auth token: ');
storeAnthropicAuthToken(token);
console.log('');
console.log(`${colors.gray}Anthropic auth token stored in ~/.config/flynn/auth.json${colors.reset}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`${colors.gray}Anthropic auth failed:${colors.reset} ${message}\n`);
} finally {
this.rl.resume();
}
return;
}
// 1) API key (default)
if (hasApiKey) {
console.log(`${colors.gray}Anthropic API key already exists.${colors.reset}`);
console.log(`${colors.gray}Delete ~/.config/flynn/auth.json anthropic.api_key entry to re-authenticate.${colors.reset}\n`);
return;
}
console.log(`${colors.gray}Anthropic uses API keys for authentication.${colors.reset}`);
console.log(`${colors.gray}Create a key at:${colors.reset} https://console.anthropic.com/settings/keys`);
console.log('');
try {
this.rl.pause();
const apiKey = await promptHidden('Enter Anthropic API key: ');
storeAnthropicAuth(apiKey);
console.log('');
console.log(`${colors.gray}Anthropic API key stored in ~/.config/flynn/auth.json${colors.reset}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`${colors.gray}Anthropic auth failed:${colors.reset} ${message}\n`);
} finally {
this.rl.resume();
}
return;
}
if (target === 'zai' || target === 'zhipuai') {
const existing = loadStoredZaiAuth();
if (existing) {
console.log(`${colors.gray}Z.AI credential already exists.${colors.reset}`);
console.log(`${colors.gray}Delete ~/.config/flynn/auth.json zai/zhipuai entry to re-authenticate.${colors.reset}\n`);
return;
}
console.log(`${colors.gray}Z.AI uses API keys (HTTP Bearer), not an OAuth device flow.${colors.reset}`);
console.log(`${colors.gray}Create a key at:${colors.reset} https://z.ai/manage-apikey/apikey-list`);
console.log(`${colors.gray}Choose mode: 1) API 2) Coding Plan${colors.reset}`);
const choice = (await this.prompt(`${colors.orange}Select [1-2] (default 1):${colors.reset} `)).trim().toLowerCase();
const mode = (choice === '2' || choice === 'plan') ? 'plan' : 'api';
console.log('');
try {
this.rl.pause();
const apiKey = await promptHidden('Enter Z.AI API key: ');
storeZaiAuth(apiKey);
console.log('');
console.log(`${colors.gray}Z.AI credential stored in ~/.config/flynn/auth.json${colors.reset}`);
if (mode === 'plan') {
console.log(`${colors.gray}Mode: Coding Plan${colors.reset}`);
console.log(`${colors.gray}Set endpoint to https://api.z.ai/api/coding/paas/v4${colors.reset}\n`);
} else {
console.log(`${colors.gray}Mode: API${colors.reset}`);
console.log(`${colors.gray}Set endpoint to https://api.z.ai/api/paas/v4${colors.reset}\n`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`${colors.gray}Z.AI auth failed:${colors.reset} ${message}\n`);
} finally {
this.rl.resume();
}
return;
}
console.log(`${colors.gray}Unknown login provider:${colors.reset} ${target}. Supported: github, openai, anthropic, zai\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');
}
}