Files
flynn/src/frontends/tui/commands.ts
T

450 lines
14 KiB
TypeScript

export type Command =
| { type: 'quit' }
| { type: 'reset' }
| { type: 'help' }
| { type: 'multiline' }
| { type: 'status' }
| { type: 'tools' }
| { type: 'research'; task: string }
| { type: 'council'; task: string }
| { type: 'fullscreen' }
| { type: 'compact' }
| { type: 'usage' }
| { type: 'context' }
| { type: 'verbose' }
| { type: 'model'; name?: string; providerModel?: string }
| { type: 'backend'; provider?: string }
| { type: 'runtime'; input?: string }
| { type: 'login'; provider?: string }
| { type: 'transfer'; target: string }
| { type: 'pair'; action?: 'generate' | 'list' | 'revoke'; args?: string }
| { type: 'queue'; action?: 'show' | 'set' | 'reset'; args?: string }
| { type: 'elevate'; args?: string }
| { type: 'message'; content: string };
export function isToolInventoryQuery(input: string): boolean {
const normalized = input.trim().toLowerCase();
if (!normalized) {
return false;
}
if (
normalized.includes('available tools')
|| normalized.includes('what tools')
|| normalized.includes('which tools')
|| normalized.includes('tool list')
|| normalized.includes('list tools')
|| normalized.includes('what can you do')
) {
return true;
}
return (
/\b(?:show|list|check)\s+(?:me\s+)?(?:your\s+)?(?:available\s+|new\s+)?tools?\b/.test(normalized)
|| /\b(?:what|which)\s+tools?\b/.test(normalized)
|| /\btools?\s+(?:do\s+you\s+have|are\s+available)\b/.test(normalized)
|| /\b(?:show|list|what\s+are)\s+(?:your\s+)?capabilities\b/.test(normalized)
);
}
export function parseCommand(input: string): Command | null {
const trimmed = input.trim();
if (!trimmed) {return null;}
// Quit
if (trimmed === '/quit' || trimmed === '/exit') {
return { type: 'quit' };
}
// Reset
if (trimmed === '/reset' || trimmed === '/clear' || trimmed === '/new') {
return { type: 'reset' };
}
// Help
if (trimmed === '/help' || trimmed === '/?') {
return { type: 'help' };
}
// Multiline paste mode
if (trimmed === '/paste' || trimmed === '/multiline') {
return { type: 'multiline' };
}
// Status
if (trimmed === '/status') {
return { type: 'status' };
}
// Tools
if (trimmed === '/tools') {
return { type: 'tools' };
}
// Research
if (trimmed.startsWith('/research ')) {
const task = trimmed.slice('/research '.length).trim();
return { type: 'research', task };
}
if (trimmed === '/research') {
return { type: 'research', task: '' };
}
// Council
if (trimmed.startsWith('/council ')) {
const task = trimmed.slice('/council '.length).trim();
return { type: 'council', task };
}
if (trimmed === '/council') {
return { type: 'council', task: '' };
}
// Natural-language council shortcut for common flows.
const councilShortcut = trimmed.match(/^(?:yes\s+)?run\s+(?:the\s+)?council(?:\s+on)?(?:\s+(.+))?$/i);
if (councilShortcut) {
return { type: 'council', task: (councilShortcut[1] ?? '').trim() };
}
// Fullscreen
if (trimmed === '/fullscreen' || trimmed === '/fs') {
return { type: 'fullscreen' };
}
// Compact
if (trimmed === '/compact') {
return { type: 'compact' };
}
// Usage
if (trimmed === '/usage') {
return { type: 'usage' };
}
// Context
if (trimmed === '/context') {
return { type: 'context' };
}
// Verbose
if (trimmed === '/verbose') {
return { type: 'verbose' };
}
// Model (with optional argument)
if (trimmed === '/model') {
return { type: 'model' };
}
if (trimmed.startsWith('/model ')) {
const args = trimmed.slice('/model '.length).trim();
const parts = args.split(/\s+/);
// /model <tier> <provider/model> - change tier's provider/model
if (parts.length === 2 && parts[1].includes('/')) {
return { type: 'model', name: parts[0], providerModel: parts[1] };
}
// /model <name> - single word (backward compatibility)
const name = parts[0];
return { type: 'model', name };
}
// Backend (with optional argument)
if (trimmed === '/backend') {
return { type: 'backend' };
}
if (trimmed.startsWith('/backend ')) {
const provider = trimmed.slice('/backend '.length).trim();
return { type: 'backend', provider };
}
// Runtime backend mode control (daemon/channel command; reserved in TUI)
if (trimmed === '/runtime') {
return { type: 'runtime' };
}
if (trimmed.startsWith('/runtime ')) {
const input = trimmed.slice('/runtime '.length).trim();
return { type: 'runtime', input };
}
// Transfer
if (trimmed === '/transfer') {
return { type: 'transfer', target: '' };
}
if (trimmed.startsWith('/transfer ')) {
const target = trimmed.slice('/transfer '.length).trim();
return { type: 'transfer', target };
}
// Login
if (trimmed === '/login') {
return { type: 'login' };
}
if (trimmed.startsWith('/login ')) {
const provider = trimmed.slice('/login '.length).trim();
return { type: 'login', provider: provider || undefined };
}
// Pair
if (trimmed === '/pair' || trimmed === '/pair list') {
return { type: 'pair', action: 'list' };
}
if (trimmed === '/pair generate' || trimmed.startsWith('/pair generate ')) {
const label = trimmed.slice('/pair generate'.length).trim() || undefined;
return { type: 'pair', action: 'generate', args: label };
}
if (trimmed.startsWith('/pair revoke ')) {
const args = trimmed.slice('/pair revoke '.length).trim();
return { type: 'pair', action: 'revoke', args };
}
// Queue
if (trimmed === '/queue' || trimmed === '/queue show') {
return { type: 'queue', action: 'show' };
}
if (trimmed === '/queue reset') {
return { type: 'queue', action: 'reset' };
}
if (trimmed.startsWith('/queue set ')) {
const args = trimmed.slice('/queue set '.length).trim();
return { type: 'queue', action: 'set', args };
}
if (trimmed.startsWith('/queue ')) {
const args = trimmed.slice('/queue '.length).trim();
return { type: 'queue', action: 'set', args };
}
// Elevate
if (trimmed === '/elevate') {
return { type: 'elevate' };
}
if (trimmed.startsWith('/elevate ')) {
const args = trimmed.slice('/elevate '.length).trim();
return { type: 'elevate', args };
}
// Regular message
return { type: 'message', content: trimmed };
}
export function getHelpText(): string {
return `
Commands:
/help, /? Show this help
/paste, /multiline Enter multiline mode (finish with single '.' line)
/tools Show available tools in this session
/model [name] Show or switch model tier (local, default, fast, complex)
/model <tier> <p/m> Change tier's provider/model (e.g. /model default anthropic/claude-sonnet-4)
/backend [provider] Show or switch local backend (ollama, llamacpp)
/runtime [args] Runtime backend mode control (daemon/channel sessions)
/research <task> Delegate a task to the configured research agent
/council <task> Run the councils pipeline for a task
/council preflight Check council tier routing, endpoint/auth mode, and probe latency
/login [provider] Authenticate with GitHub, OpenAI, Anthropic, or Z.AI
/pair List pending pairing codes and approved senders
/pair generate [label] Generate a new DM pairing code
/pair revoke <ch> <id> Revoke an approved sender
/queue Show queue policy for this session
/queue set <k> <v> Set queue override (mode/cap/overflow/debounce_ms/summarize_overflow)
/queue reset Clear queue overrides for this session
/approvals List pending guarded actions for this session
/approve [id] Approve latest pending action (or specific id)
/deny [id] [reason] Deny latest pending action (or specific id)
/elevate [args] Show or manage elevated mode
/reset, /clear, /new Clear conversation history
/compact Compact conversation history
/usage Show token usage and estimated cost
/context Show estimated context-window usage
/verbose Toggle verbose mode (show raw streaming and tool output)
/status Show session info and token usage
/fullscreen, /fs Switch to fullscreen mode
/transfer <dest> Transfer session to another frontend (telegram|tui)
/quit, /exit Exit TUI
`.trim();
}
import { MODEL_PROVIDERS } from '../../config/index.js';
export type ModelAlias = 'local' | 'default' | 'fast' | 'complex' | 'opus' | 'sonnet' | 'haiku' | 'ollama';
// List of all slash commands for autocompletion
export const SLASH_COMMANDS = [
'/help',
'/paste',
'/multiline',
'/tools',
'/model',
'/backend',
'/runtime',
'/research',
'/council',
'/reset',
'/clear',
'/new',
'/compact',
'/usage',
'/context',
'/verbose',
'/status',
'/fullscreen',
'/fs',
'/login',
'/pair',
'/queue',
'/approvals',
'/approve',
'/deny',
'/elevate',
'/transfer',
'/quit',
'/exit',
];
// Command descriptions for tooltips
export const COMMAND_TOOLTIPS: Record<string, string> = {
'/help': 'Show available commands',
'/paste': 'Compose a multiline message; finish with a single "." line',
'/multiline': 'Compose a multiline message; finish with a single "." line',
'/tools': 'Show authoritative runtime tool list for this session',
'/model': 'Show or switch model (local, default, fast, complex)',
'/backend': 'Show or switch local backend (ollama, llamacpp)',
'/runtime': 'Runtime backend mode control (daemon/channel command; not local TUI backend switch)',
'/research': 'Delegate a task to the configured research agent',
'/council': 'Run the councils pipeline for a task; use "/council preflight" for route/auth checks',
'/reset': 'Clear conversation history',
'/clear': 'Clear conversation history',
'/new': 'Start a new conversation',
'/compact': 'Compact conversation history to save context space',
'/usage': 'Show token usage and estimated cost',
'/context': 'Show estimated context-window usage',
'/verbose': 'Toggle verbose mode (show raw streaming and tool output)',
'/status': 'Show session info and token usage',
'/fullscreen': 'Switch to fullscreen mode',
'/fs': 'Switch to fullscreen mode',
'/login': 'Authenticate with GitHub/OpenAI/Anthropic (OAuth/token or API key) or Z.AI (API key store)',
'/pair': 'Generate/list/revoke DM pairing codes',
'/queue': 'Show or update per-session queue policy',
'/approvals': 'List pending guarded actions for this session',
'/approve': 'Approve latest pending action (or specific id)',
'/deny': 'Deny latest pending action (or specific id and reason)',
'/elevate': 'Show or manage elevated mode',
'/transfer': 'Transfer session to another frontend (telegram|tui)',
'/quit': 'Exit TUI',
'/exit': 'Exit TUI',
};
// Model aliases for /model command autocompletion
export const MODEL_ALIASES = ['local', 'default', 'fast', 'complex', 'opus', 'sonnet', 'haiku', 'ollama'];
// Provider names for /model <tier> <provider/model> syntax — derived from config schema
export const PROVIDER_NAMES: readonly string[] = MODEL_PROVIDERS;
// Model alias descriptions
export const MODEL_TOOLTIPS: Record<string, string> = {
local: 'Local model (Ollama/llama.cpp)',
default: 'Default model tier',
fast: 'Fast/lightweight model tier',
complex: 'Complex reasoning model tier',
opus: 'Alias for complex tier',
sonnet: 'Alias for default tier',
haiku: 'Alias for fast tier',
ollama: 'Alias for local tier',
};
export function getCommandCompletions(partial: string): string[] {
const trimmed = partial.trim();
if (trimmed.startsWith('/council ')) {
const suffix = trimmed.slice('/council '.length).toLowerCase();
if ('preflight'.startsWith(suffix)) {
return ['/council preflight'];
}
return [];
}
// Complete /model <tier> <provider/model>
if (trimmed.startsWith('/model ')) {
const args = trimmed.slice('/model '.length).trim();
const parts = args.split(/\s+/);
if (parts.length === 1) {
// Single word - suggest model aliases
const modelPartial = parts[0].toLowerCase();
return MODEL_ALIASES
.filter(alias => alias.startsWith(modelPartial))
.map(alias => `/model ${alias}`);
} else if (parts.length === 2) {
// Two words - suggest provider prefixes
const providerPartial = parts[1].toLowerCase();
return PROVIDER_NAMES
.filter(provider => provider.startsWith(providerPartial))
.map(provider => `/model ${parts[0]} ${provider}`);
}
}
// 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 args = trimmed.slice('/model '.length).trim();
const parts = args.split(/\s+/);
if (parts.length === 1) {
// Single word - model tier or provider
const modelArg = parts[0].toLowerCase();
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';
} else if (parts.length === 2) {
// Two words - tier + provider
const providerPartial = parts[1].toLowerCase();
const matches = PROVIDER_NAMES.filter(p => p.startsWith(providerPartial));
if (matches.length === 1) {
return `Enter provider/model (e.g. ${matches[0]}/...)`;
}
return 'Enter provider/model (e.g. anthropic/claude-sonnet-4)';
}
}
// 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<string, 'local' | 'default' | 'fast' | 'complex'> = {
local: 'local',
ollama: 'local',
default: 'default',
sonnet: 'default',
fast: 'fast',
haiku: 'fast',
complex: 'complex',
opus: 'complex',
};
return map[alias.toLowerCase()] ?? 'default';
}