/** * tui-repl.ts — Custom REPL sin Ink/React. * * Reemplaza ink-app.tsx con un loop de eventos basado en EventEmitter. * El estado es un objeto mutable; `scheduleRedraw()` hace debounce de 16ms * para no redibujar más de ~60fps. El resultado es idéntico en features * a ink-app.tsx pero con control total de stdin → rueda del ratón funciona. */ import os from 'node:os' import path from 'node:path' import { TuiInput, type KeyName } from './tui-input.js' import { drawFrame, SIDEBAR_W, CHROME_ROWS, lastSidebarHeaderRows } from './tui-draw.js' import { cycleMode, type Mode } from './mode.js' import { handleCommand, type CommandContext } from './commands.js' import { taskSnapshot } from '../tools/tasks.js' import { snapshotBackground } from '../tools/background.js' import { snapshotMonitors } from '../tools/monitor.js' import { runCommitteeTui } from './committee.js' import { recordRouterFeedback } from '../state/router-feedback.js' import { classifyPromptIntentAsync, recordIntentObservation, learnedIntentsSummary } from '../state/router-intents.js' import { expandFileMentions } from './file-mentions.js' import { isAllowedBySession, allowToolForSession, allowPatternForSession, suggestPattern } from '../tools/session-perms.js' import { setUserQuestioner } from '../tools/executor.js' import { loadCustomCommands, installBuiltinSkills, type CustomCommand, expandCustomCommand } from './custom-commands.js' import { getAliasKeys, resolveModelAlias } from './model-picker.js' import type { SqAgent } from '../agent/agent.js' import type { SqConfig } from '../config.js' import type { McpManager } from '../mcp/manager.js' import type { TaskItem } from '../tools/tasks.js' // ── Types shared with tui-draw.ts ───────────────────────────────────────────── export type OutputLineKind = | 'user_header' | 'user_body' | 'agent_header' | 'agent_body' | 'agent_code_fence_open' | 'agent_code' | 'agent_code_fence_close' | 'tool_start' | 'diff_remove' | 'diff_add' | 'task_item' | 'turn_end' | 'error' | 'info' | 'thinking' export interface OutputLine { id: number kind: OutputLineKind text: string lang?: string blockIndex?: number } export interface ModalPermission { toolName: string detail: string preview?: string patternSuggestion: string | null idx: number resolve: (r: { approved: boolean; explanation?: string }) => void } export interface ModalModelPicker { options: Array<{ alias: string; label: string }> idx: number } export interface ModalMcp { idx: number /** true mientras hay un reconnect en curso para ese server */ connecting: Set } export interface ModalQuestion { question: string options: Array<{ label: string; description?: string }> multi: boolean idx: number checks: Set resolve: (answer: string) => void /** When user selects "Other…", we enter free-text mode */ otherMode: boolean otherText: string } export interface AppState { // Output lines: OutputLine[] filteredLines: OutputLine[] // computed from lines (task_items removed, thinking grouped) scrollOffset: number // Input input: string cursorPos: number history: string[] histIdx: number savedInput: string suggestions: string[] suggestionIdx: number escPending: boolean // Processing isProcessing: boolean pendingQueue: string[] // Agent turn state inCodeBlock: boolean codeBlockCounter: number streamTokens: number thinkingStart: number // UI state mode: Mode cost: number ctxPct: number // 5-hour subscription quota % (status bar display) contextWindowPct: number // actual model context window usage % (auto-compact) model: string sidebarVisible: boolean thinkingCollapsed: boolean tasklistCollapsed: boolean showHelp: boolean // Session projectName: string cwd: string sessionFiles: Map taskPanelItems: TaskItem[] sidebarTasks: TaskItem[] mcpManager: McpManager | null sidebarScrollOffset: number authStatus: { anthropic: boolean; openai: boolean; google: boolean } routerEnabled: boolean routerLearning: boolean fiveHourResetAt: number // unix ms — 0 = unknown sidebarCollapsed: { providers: boolean; mcp: boolean; tasks: boolean; files: boolean; processes: boolean } bgShells: Array<{ id: string; command: string; status: string; exitCode: number | null; ageS: number }> activeMonitors: Array<{ id: string; description: string; command: string; ageS: number }> /** Images waiting to be sent with the next prompt (from Ctrl+V or @image.png). */ pendingAttachments: Array<{ base64: string; mediaType: string; label: string }> // Modals permissionRequest: ModalPermission | null pendingQuestion: ModalQuestion | null modelPicker: ModalModelPicker | null mcpPicker: ModalMcp | null } // ── Constants ───────────────────────────────────────────────────────────────── const SLASH_COMMANDS = [ '/help', '/status', '/cost', '/context', '/export', '/usage', '/history', '/env', '/perf', '/feedback', '/release-notes', '/model', '/compact', '/clear', '/login', '/repeat', '/cancel', '/tasklist', '/router', '/mcp', '/committee', '/paste', '/sidebar', '/exit', '/quit', ] const CURATED_MODELS = [ { alias: 'opus', label: 'Claude Opus 4.7 — máxima calidad' }, { alias: 'sonnet', label: 'Claude Sonnet 4.6 — equilibrio coste/calidad' }, { alias: 'haiku', label: 'Claude Haiku 4.5 — rápido y barato' }, { alias: 'gpt-5.4', label: 'GPT-5.4 — flagship OpenAI' }, { alias: 'gpt-5.4-mini', label: 'GPT-5.4 mini — rápido y barato' }, { alias: 'gpt-5.3-codex', label: 'Codex 5.3 — especializado código' }, { alias: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro — contexto 1M' }, { alias: 'gemini-2.5-flash',label: 'Gemini 2.5 Flash — rápido' }, ] let _lineId = 0 function mkLine(kind: OutputLineKind, text: string, extras?: { lang?: string; blockIndex?: number }): OutputLine { return { id: ++_lineId, kind, text, ...extras } } // ── Helpers ─────────────────────────────────────────────────────────────────── function wrapText(text: string, maxW: number): string[] { if (maxW <= 0 || text.length <= maxW) return [text] const result: string[] = [] let rem = text while (rem.length > maxW) { let cut = rem.lastIndexOf(' ', maxW) if (cut <= 0) cut = maxW result.push(rem.slice(0, cut)) rem = rem.slice(cut).replace(/^ /, '') } result.push(rem) return result } function makeBodyLines(kind: OutputLineKind, text: string, w: number): OutputLine[] { const maxW = Math.max(20, w - 3) return text.split('\n').flatMap(p => wrapText(p, maxW).map(l => mkLine(kind, l))) } function toolIcon(name: string): string { const icons: Record = { Read: '▸', Write: '✎', Edit: '±', Bash: '$', BashOutput: '↻', KillShell: '✗', Glob: '*', Grep: '⌕', WebFetch: '⤓', WebSearch: '⊕', TaskCreate: '+', TaskList: '≡', TaskGet: '?', TaskUpdate: '⟳', NotebookEdit: '▤', AskUserQuestion: '?', Task: '⤳', } return icons[name] ?? '◆' } function toolDetail(name: string, input: unknown): string { if (!input || typeof input !== 'object') return '' const inp = input as Record if (['Read','Write','Glob','Grep'].includes(name)) { const p = (inp.file_path ?? inp.path ?? inp.pattern ?? '') as string return p.replace(/\\/g, '/').split('/').slice(-2).join('/').slice(0, 60) } if (name === 'Edit') return String(inp.file_path ?? '').replace(/\\/g, '/').split('/').slice(-2).join('/').slice(0, 60) if (name === 'Bash') return String(inp.command ?? '').slice(0, 60) if (['WebFetch','WebSearch'].includes(name)) return String(inp.url ?? inp.query ?? '').slice(0, 60) if (['TaskCreate','TaskUpdate'].includes(name)) return String(inp.subject ?? '').slice(0, 50) return '' } function buildDiffLines(name: string, input: unknown): OutputLine[] { if (!input || typeof input !== 'object') return [] const inp = input as Record const lines: OutputLine[] = [] if (name === 'Edit') { for (const l of String(inp.old_string ?? '').split('\n').slice(0, 20)) lines.push(mkLine('diff_remove', `- ${l}`)) for (const l of String(inp.new_string ?? '').split('\n').slice(0, 20)) lines.push(mkLine('diff_add', `+ ${l}`)) } else if (name === 'Write') { for (const l of String(inp.content ?? '').split('\n').slice(0, 40)) lines.push(mkLine('diff_add', `+ ${l}`)) } return lines.slice(0, 40) } function matchCodeFence(line: string): { lang: string | null } | null { const m = /^\s*```\s*([^\s`]*)\s*$/.exec(line) if (!m) return null return { lang: m[1] || null } } function recomputeFiltered(state: AppState): void { const result: OutputLine[] = [] let thinkBuf: OutputLine[] = [] for (const l of state.lines) { if (l.kind === 'task_item') continue if (l.kind === 'thinking') { thinkBuf.push(l); continue } if (thinkBuf.length > 0) { if (state.thinkingCollapsed) result.push(mkLine('info', ` ▸ thinking (${thinkBuf.length} lines) · Ctrl+O to expand`)) else result.push(...thinkBuf) thinkBuf = [] } result.push(l) } if (thinkBuf.length > 0) { if (state.thinkingCollapsed) result.push(mkLine('info', ` ▸ thinking (${thinkBuf.length} lines) · Ctrl+O to expand`)) else result.push(...thinkBuf) } state.filteredLines = result } // ── Main entry ──────────────────────────────────────────────────────────────── export interface TuiReplOpts { agent: SqAgent config: SqConfig cwd: string projectName: string version: string authStatus: { anthropic: boolean; openai: boolean; google: boolean } customCommands?: CustomCommand[] mcpManager?: McpManager resumedInfo?: { sessionId: string; turns: number } } export async function runTuiRepl(opts: TuiReplOpts): Promise { const { agent, config, cwd, projectName, version, authStatus, customCommands = [], mcpManager = null, resumedInfo } = opts // ── State ───────────────────────────────────────────────────────────── const state: AppState = { lines: [], filteredLines: [], scrollOffset: 0, input: '', cursorPos: 0, history: [], histIdx: -1, savedInput: '', suggestions: [], suggestionIdx: 0, escPending: false, isProcessing: false, pendingQueue: [], inCodeBlock: false, codeBlockCounter: 0, streamTokens: 0, thinkingStart: 0, mode: 'default', cost: 0, ctxPct: 0, contextWindowPct: 0, model: agent.getCurrentModel(), sidebarVisible: (process.stdout.columns || 80) >= 120, thinkingCollapsed: true, tasklistCollapsed: false, showHelp: false, projectName, cwd, sessionFiles: new Map(), taskPanelItems: [], sidebarTasks: [], mcpManager, sidebarScrollOffset: 0, authStatus, routerEnabled: config.router?.enabled ?? false, routerLearning: config.router?.learn ?? false, fiveHourResetAt: 0, sidebarCollapsed: { providers: false, mcp: false, tasks: false, files: false, processes: false }, bgShells: [], activeMonitors: [], pendingAttachments: [], permissionRequest: null, pendingQuestion: null, modelPicker: null, mcpPicker: null, } // ── Redraw scheduling ───────────────────────────────────────────────── let _redrawTimer: ReturnType | null = null function scheduleRedraw(): void { if (_redrawTimer) return _redrawTimer = setTimeout(() => { _redrawTimer = null recomputeFiltered(state) drawFrame(state) }, 16) } function addLines(...newLines: OutputLine[]): void { state.lines.push(...newLines) if (state.scrollOffset === 0) { // live mode — keep scroll pinned to bottom } scheduleRedraw() } // ── Alt screen ─────────────────────────────────────────────────────── process.stdout.write('\x1b[?1049h') // enter alternate screen process.stdout.write('\x1b[2J') // clear process.stdout.write('\x1b[H') // cursor top-left // ── Banner as initial output lines ─────────────────────────────────── const GR='\x1b[38;5;245m',R='\x1b[0m',D='\x1b[2m',BOLD='\x1b[1m',CYAN='\x1b[38;5;73m' const dot = (ok: boolean) => ok ? '●' : '○' const bannerLines: OutputLine[] = [ mkLine('info', ''), mkLine('info', `${GR} ███████╗ ██████╗ ██╗ ██╗███████╗███████╗███████╗██████╗${R}`), mkLine('info', `${GR} ██╔════╝██╔═══██╗██║ ██║██╔════╝██╔════╝╚══███╔╝██╔══██╗${R}`), mkLine('info', `${GR} ███████╗██║ ██║██║ ██║█████╗ █████╗ ███╔╝ ██████╔╝${R}`), mkLine('info', `${GR} ╚════██║██║▄▄ ██║██║ ██║██╔══╝ ██╔══╝ ███╔╝ ██╔══██╗${R}`), mkLine('info', `${GR} ███████║╚██████╔╝╚██████╔╝███████╗███████╗███████╗██║ ██║${R}`), mkLine('info', `${GR} ╚══════╝ ╚══▀▀═╝ ╚═════╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝${R}`), mkLine('info', `${D} · ${R}${BOLD}${CYAN}C O D E${R}${D} ·${R}`), mkLine('info', ''), mkLine('info', ` ${D}squeezr-code v${version} · All for One${R}`), mkLine('info', ` ${D}auth anthropic ${dot(authStatus.anthropic)} openai ${dot(authStatus.openai)} google ${dot(authStatus.google)}${R}`), mkLine('info', ` ${D}cwd ${cwd}${R}`), mkLine('info', ` ${D}tip /help · @file for context · @model to override · Shift+Tab cycle mode${R}`), mkLine('info', ''), ] if (resumedInfo) { bannerLines.push(mkLine('info', ` ${D}resumed session ${resumedInfo.sessionId.slice(0, 13)} (${resumedInfo.turns} turns)${R}`)) bannerLines.push(mkLine('info', '')) } state.lines.push(...bannerLines) // ── Sidebar poll (tasks + git every 300ms) ──────────────────────────── const sidebarTimer = setInterval(() => { state.sidebarTasks = taskSnapshot() state.bgShells = snapshotBackground() state.activeMonitors = snapshotMonitors() scheduleRedraw() }, 300) // ── Task panel debounce ──────────────────────────────────────────────── let _taskTimer: ReturnType | null = null function updateTaskPanel(): void { if (_taskTimer) clearTimeout(_taskTimer) _taskTimer = setTimeout(() => { state.taskPanelItems = taskSnapshot() scheduleRedraw() }, 100) } // ── Permission picker (bridge for tools) ───────────────────────────── async function askPermissionTui( toolName: string, input: Record, ): Promise<{ approved: boolean; explanation?: string }> { if (isAllowedBySession(toolName, input)) return { approved: true } const detail = (input.file_path as string | undefined) || (input.notebook_path as string | undefined) || (typeof input.command === 'string' ? (input.command as string).slice(0, 80) : '') || toolName const patternSuggestion = suggestPattern(toolName, input) return new Promise(resolve => { state.permissionRequest = { toolName, detail, patternSuggestion, idx: 0, resolve } scheduleRedraw() }) } // ── AskUserQuestion bridge ──────────────────────────────────────────── setUserQuestioner((question, options, multi) => { return new Promise(resolve => { state.pendingQuestion = { question, options, multi, idx: 0, checks: new Set(), resolve, otherMode: false, otherText: '' } scheduleRedraw() }) }) // ── processTurn ─────────────────────────────────────────────────────── async function processTurn(text: string): Promise { state.isProcessing = true state.scrollOffset = 0 // go live // Wrap text at mainW (not full terminal) so it doesn't bleed under the sidebar const cols = process.stdout.columns || 80 const w = state.sidebarVisible ? Math.max(40, cols - SIDEBAR_W) : cols addLines(mkLine('user_header', ''), ...makeBodyLines('user_body', text, w)) state.history.push(text) state.histIdx = -1 state.savedInput = '' // @file mentions — also extracts image attachments from @path.png const expansion = expandFileMentions(text, cwd) let basePrompt = expansion.prompt if (expansion.filesIncluded.length > 0) addLines(mkLine('info', ` ▸ @file: ${expansion.filesIncluded.join(', ')}`)) if (expansion.filesNotFound.length > 0) addLines(mkLine('info', ` ✖ not found: ${expansion.filesNotFound.join(', ')}`)) // Merge: images from @path.png + clipboard (Ctrl+V) → attachments const allAttachments = [ ...state.pendingAttachments.map(a => ({ base64: a.base64, mediaType: a.mediaType })), ...expansion.imageAttachments.map(a => ({ base64: a.base64, mediaType: a.mediaType })), ] state.pendingAttachments = [] // clear after capturing // @model override let promptToSend = basePrompt let routerModel: string | undefined const atOverride = basePrompt.match(/^@(\S+)\s+(.+)$/s) if (atOverride) { const resolved = resolveModelAlias(atOverride[1]) if (resolved) { routerModel = resolved promptToSend = atOverride[2] addLines(mkLine('info', ` ▸ @override: ${atOverride[1]}`)) } } // Auto-router (checks sq.toml rules → learned intents → heuristics) let routerChoice: string | undefined if (!routerModel && config.router?.enabled) { const { classifyPromptForRouter } = await import('./repl.js') const picked = classifyPromptForRouter(promptToSend, authStatus, config) if (picked) { routerChoice = picked routerModel = resolveModelAlias(picked) addLines(mkLine('info', ` ▸ router: ${picked}`)) } } // Learning: if user used @override AND router had a suggestion, record feedback if (atOverride && routerChoice) { const overrideAlias = atOverride[1].toLowerCase().replace(/[^a-z]/g, '') const normalised = overrideAlias.includes('opus') ? 'opus' : overrideAlias.includes('haiku') ? 'haiku' : overrideAlias.includes('sonnet') ? 'sonnet' : null if (normalised && normalised !== routerChoice) { recordRouterFeedback(promptToSend, normalised as 'opus' | 'sonnet' | 'haiku', routerChoice as 'opus' | 'sonnet' | 'haiku') addLines(mkLine('info', ` ▸ router learned: "${normalised}" for this type of prompt`)) } } let hasAgentHeader = false let textBuffer = '' state.inCodeBlock = false state.codeBlockCounter = 0 state.streamTokens = 0 state.thinkingStart = Date.now() function routeLine(rawLine: string): OutputLine[] { const fence = matchCodeFence(rawLine) if (fence) { if (state.inCodeBlock) { state.inCodeBlock = false; return [mkLine('agent_code_fence_close', '', { blockIndex: state.codeBlockCounter })] } state.codeBlockCounter++; state.inCodeBlock = true return [mkLine('agent_code_fence_open', '', { lang: fence.lang ?? undefined, blockIndex: state.codeBlockCounter })] } if (state.inCodeBlock) return [mkLine('agent_code', rawLine, { blockIndex: state.codeBlockCounter })] return makeBodyLines('agent_body', rawLine, w) } function flushText(): void { if (!textBuffer) return addLines(...routeLine(textBuffer)) textBuffer = '' } try { // Build environment context so the model knows its own situation const envContext: import('../api/types.js').AgentEnvContext = { permissionsMode: state.mode as 'default' | 'accept-edits' | 'plan' | 'bypass' | 'auto' | 'yolo', ctxPct: state.contextWindowPct, // actual context window %, not subscription quota fiveHourResetAt: state.fiveHourResetAt, costUsd: state.cost, sessionTurns: state.history.length, activeShells: state.bgShells, activeMonitors: state.activeMonitors, mcpServers: state.mcpManager?.list().map(s => ({ name: s.name, status: s.status, toolCount: s.toolCount })) ?? [], routerReason: routerChoice ? `intent classified, router chose ${routerChoice}` : undefined, } for await (const event of agent.send(promptToSend, { cwd, model: routerModel, askPermission: askPermissionTui, envContext, attachments: allAttachments.length > 0 ? allAttachments : undefined, })) { if (event.type === 'text' && event.text) { if (!hasAgentHeader) { addLines(mkLine('agent_header', '')); hasAgentHeader = true } textBuffer += event.text if (event.text.length > 0) { state.streamTokens += Math.ceil(event.text.length / 4); scheduleRedraw() } if (textBuffer.includes('\n')) { const parts = textBuffer.split('\n') const incomplete = parts.pop()! addLines(...parts.flatMap(routeLine)) textBuffer = incomplete } } else if (event.type === 'thinking' && event.text) { if (!hasAgentHeader) { addLines(mkLine('agent_header', '')); hasAgentHeader = true } flushText() addLines(...event.text.split('\n').slice(0, 10).map(l => mkLine('thinking', ` ${l}`))) } else if (event.type === 'tool_start' && event.tool) { flushText() const icon = toolIcon(event.tool.name) const detail = toolDetail(event.tool.name, event.tool.input) addLines(mkLine('tool_start', `├─ ${icon} ${event.tool.name}${detail ? ' ' + detail : ''}`)) if (['Edit','Write'].includes(event.tool.name)) { addLines(...buildDiffLines(event.tool.name, event.tool.input)) } if (['Write','Edit','NotebookEdit'].includes(event.tool.name)) { const inp = event.tool.input as Record const fp = (inp.file_path as string | undefined) || (inp.notebook_path as string | undefined) if (fp && !state.sessionFiles.has(fp)) { state.sessionFiles.set(fp, event.tool.name === 'Write' ? 'created' : 'modified') } } if (['TaskCreate','TaskUpdate'].includes(event.tool.name)) updateTaskPanel() } else if (event.type === 'cost' && event.cost) { state.cost += event.cost.usd state.model = event.cost.model if (event.usage?.outputTokens != null) state.streamTokens = event.usage.outputTokens scheduleRedraw() } else if (event.type === 'subscription' && event.subscription) { const sub = event.subscription const m = agent.getCurrentModel() const frac = /sonnet/.test(m) && sub.fiveHourSonnet > 0 ? sub.fiveHourSonnet : /opus/.test(m) && sub.fiveHourOpus > 0 ? sub.fiveHourOpus : /haiku/.test(m) && sub.fiveHourHaiku > 0 ? sub.fiveHourHaiku : sub.fiveHour state.ctxPct = Math.min(100, Math.round(frac * 100)) if (sub.fiveHourResetAt) state.fiveHourResetAt = sub.fiveHourResetAt scheduleRedraw() } else if (event.type === 'error' && event.error) { flushText(); addLines(mkLine('error', event.error)) } else if (event.type === 'done') { break } } } catch (err) { flushText() addLines(mkLine('error', err instanceof Error ? err.message : String(err))) } finally { if (textBuffer) { addLines(...routeLine(textBuffer)); textBuffer = '' } addLines(mkLine('turn_end', '')) // Update real context window % from brain (tokens used / model context limit) state.contextWindowPct = Math.min(100, Math.round(agent.getBrainState().contextPercent)) // Auto-compact: use context WINDOW % (not subscription quota %) const autoThreshold = config.transplant?.auto_threshold ?? 95 if (state.contextWindowPct >= autoThreshold && agent.getConversationHistory().length > 4) { addLines(mkLine('info', ` ▸ context window at ${state.contextWindowPct}% — compacting…`)) try { for await (const _ev of agent.compact()) { /* silent */ } addLines(mkLine('info', ' ✓ context compacted')) } catch (e) { addLines(mkLine('info', ` ✖ compact failed: ${e instanceof Error ? e.message : String(e)}`)) } } state.isProcessing = false scheduleRedraw() // Background intent classification (Option C) — zero latency impact // Fire-and-forget: classifies prompt with haiku AFTER the turn is done, // then records the observation so future routing improves. if (config.router?.learn && authStatus.anthropic) { const capturedModel = state.model // model used for this turn const capturedPrompt = promptToSend const wasOverride = !!routerChoice && capturedModel !== resolveModelAlias(routerChoice) void (async () => { try { const { AuthManager } = await import('../auth/manager.js') const bgAuth = new AuthManager() await bgAuth.init() const intent = await classifyPromptIntentAsync(capturedPrompt, bgAuth) if (intent !== 'other') { recordIntentObservation(intent, capturedModel, wasOverride) } } catch { /* best-effort, never fails the user */ } })() } // Process queue if (state.pendingQueue.length > 0) { const [next, ...rest] = state.pendingQueue state.pendingQueue = rest setTimeout(() => void processTurn(next), 0) } } } // ── Slash command handler ───────────────────────────────────────────── // ── Clipboard image paste (used by /paste and Ctrl+V) ──────────────────── async function pasteClipboardImage(): Promise { try { const { readClipboardImage } = await import('./clipboard-image.js') addLines(mkLine('info', ' Reading clipboard…')) scheduleRedraw() const img = readClipboardImage({ debug: false }) if (img) { state.pendingAttachments = [...state.pendingAttachments, { base64: img.base64, mediaType: img.mediaType, label: 'clipboard' }] addLines(mkLine('info', ` ✓ Image attached (${img.mediaType}) — type your prompt and press Enter`)) } else { addLines(mkLine('info', ` ○ No image found in clipboard. Copy a screenshot first.`)) } } catch (e) { addLines(mkLine('error', ` Clipboard read failed: ${e instanceof Error ? e.message : String(e)}`)) } scheduleRedraw() } // ── Router command handler ──────────────────────────────────────────────── function handleRouterCommand(arg: string): void { const enabled = config.router?.enabled ?? false const learn = config.router?.learn ?? false // ── /router or /router show ────────────────────────────────────── if (arg === '' || arg === 'show' || arg === 'status') { addLines( mkLine('info', ` router: ${enabled ? '● ON' : '○ OFF'} — /router on | off`), mkLine('info', ` learning: ${learn ? '● ON' : '○ OFF'} — /router learn on | off`), mkLine('info', ''), mkLine('info', ' Priority: sq.toml rules → learned intents → keyword feedback → heuristics'), mkLine('info', ''), mkLine('info', ' Commands:'), mkLine('info', ' /router on | off enable/disable routing'), mkLine('info', ' /router learn on | off enable/disable background LLM learning'), mkLine('info', ' /router rules show learned intent preferences'), mkLine('info', ' /router reset clear all learned preferences'), ) return } // ── /router rules ────────────────────────────────────────────────── if (arg === 'rules') { const summary = learnedIntentsSummary() addLines( mkLine('info', ' Learned intent preferences:'), ...summary.split('\n').map(l => mkLine('info', l)), mkLine('info', ''), mkLine('info', ' sq.toml explicit rules (highest priority):'), ...(Object.entries(config.router?.rules ?? {}).length === 0 ? [mkLine('info', ' none — add [router.rules] to sq.toml')] : Object.entries(config.router?.rules ?? {}).map(([k, v]) => mkLine('info', ` "${k}" → ${v}`))), ) return } // ── /router reset ────────────────────────────────────────────────── if (arg === 'reset') { import('node:fs').then(({ default: fs_ }) => { import('node:path').then(({ default: path_ }) => { import('node:os').then(({ default: os_ }) => { const base = path_.join(os_.homedir(), '.squeezr-code') let removed = 0 for (const f of ['router-intents.json', 'router-feedback.json']) { try { fs_.unlinkSync(path_.join(base, f)); removed++ } catch { /* ok */ } } addLines(mkLine('info', ` ✓ Router preferences reset (${removed} file${removed !== 1 ? 's' : ''} removed)`)) scheduleRedraw() }) }) }) return } // ── /router on | off ─────────────────────────────────────────────── if (arg === 'on' || arg === 'off') { const val = arg === 'on' persistRouterConfig({ enabled: val }) state.routerEnabled = val addLines(mkLine('info', ` ✓ router ${val ? 'enabled' : 'disabled'} (saved to config.toml)`)) scheduleRedraw() return } // ── /router learn on | off ───────────────────────────────────────── if (arg === 'learn on' || arg === 'learn off') { const newLearn = arg === 'learn on' persistRouterConfig({ learn: newLearn }) state.routerLearning = newLearn addLines( mkLine('info', ` ✓ router learning ${newLearn ? 'enabled' : 'disabled'} (saved to config.toml)`), ...(newLearn ? [mkLine('info', ' Haiku will classify each prompt in background after your turn.')] : []), ) scheduleRedraw() return } addLines(mkLine('error', ` Unknown router sub-command: "${arg}". Try /router show`)) } /** Write router config fields to ~/.squeezr-code/config.toml */ function persistRouterConfig(fields: Partial<{ enabled: boolean; learn: boolean }>): void { config.router = config.router || { enabled: false, rules: {}, learn: false } if (fields.enabled !== undefined) config.router.enabled = fields.enabled if (fields.learn !== undefined) config.router.learn = fields.learn const snap = { enabled: config.router.enabled, learn: config.router.learn ?? false } void (async () => { try { const { default: fs_ } = await import('node:fs') const { default: path_ } = await import('node:path') const { default: os_ } = await import('node:os') const cfgPath = path_.join(os_.homedir(), '.squeezr-code', 'config.toml') let content = '' try { content = fs_.existsSync(cfgPath) ? fs_.readFileSync(cfgPath, 'utf-8') : '' } catch { /* new */ } const section = `[router]\nenabled = ${snap.enabled}\nlearn = ${snap.learn}\n` if (/^\[router\]/m.test(content)) { content = content.replace(/\[router\][^\[]*/m, section) } else { content = content.trimEnd() + (content ? '\n\n' : '') + section } fs_.mkdirSync(path_.dirname(cfgPath), { recursive: true }) fs_.writeFileSync(cfgPath, content) } catch { /* best-effort */ } })() } async function handleSlash(trimmed: string): Promise { if (trimmed === '/help') { state.showHelp = true; scheduleRedraw(); return } // /paste — read image from clipboard (reliable alternative to Ctrl+V) if (trimmed === '/paste' || trimmed.startsWith('/paste ')) { void pasteClipboardImage() return } if (trimmed === '/skills' || trimmed === '/skills list') { if (customCommands.length === 0) { addLines(mkLine('info', ' No skills installed in ~/.squeezr-code/commands/')); return } addLines(...customCommands.map(c => mkLine('info', ` /${c.name.padEnd(16)} ${c.description}`))) return } // Custom skill if (customCommands.length > 0) { const m = trimmed.match(/^\/(\S+)\s*(.*)$/s) if (m) { const skill = customCommands.find(c => c.name === m[1]) if (skill) { addLines(mkLine('info', ` ▸ skill: /${skill.name}`)); void processTurn(expandCustomCommand(skill, m[2])); return } } } const ctx: CommandContext = { brain: { getState: () => ({ contextPercent: state.ctxPct, model: state.model, turnCount: 0, totalInputTokens: 0, totalOutputTokens: 0, subscriptions: { anthropic: null, openai: null, google: null } }), reset: () => {} }, model: state.model, setModel: (m: string) => { state.model = m; agent.setModel(m); scheduleRedraw() }, costByModel: () => agent.getCostByModel() as Map, history: () => agent.getConversationHistory(), systemPrompt: () => agent.getLastSystemPrompt() ?? '', sessionId: () => '', } const result = handleCommand(trimmed, ctx) if (!result) return if (result.output) addLines(...result.output.split('\n').map(l => mkLine('info', l))) if (result.exit) { cleanup(); process.exit(0) } switch (result.action) { case 'pick-model': state.modelPicker = { options: CURATED_MODELS, idx: 0 } break case 'committee': { const cmtPrompt = (result as { committeePrompt?: string }).committeePrompt?.trim() || '' if (!cmtPrompt) { addLines(mkLine('error', ' Usage: /committee ')); break } state.isProcessing = true; scheduleRedraw() void runCommitteeTui({ prompt: cmtPrompt, authStatus, config, cwd, addLines, mkLine, scheduleRedraw, withJudge: true, }).then(() => { state.isProcessing = false; scheduleRedraw() }) break } case 'mcp': if (!state.mcpManager || state.mcpManager.list().length === 0) { addLines(mkLine('info', ' No MCP servers configured in sq.toml')) } else { state.mcpPicker = { idx: 0, connecting: new Set() } } break case 'compact': addLines(mkLine('info', ' ▸ compacting history…')) void (async () => { try { for await (const _ of agent.compact()) {} ; addLines(mkLine('info', ' ✓ history compacted')) } catch (e) { addLines(mkLine('error', `compact failed: ${e instanceof Error ? e.message : String(e)}`)) } scheduleRedraw() })() break case 'login': addLines(mkLine('info', ` Open another terminal and run: sq login ${(result as { loginProvider?: string }).loginProvider || 'anthropic'}`)) break case 'repeat': { const hist = agent.getConversationHistory() const lastUser = [...hist].reverse().find(m => m.role === 'user') if (lastUser && typeof lastUser.content === 'string') void processTurn(lastUser.content) else addLines(mkLine('info', ' No previous message to repeat.')) break } case 'cancel': state.pendingQueue = [] addLines(mkLine('info', ' Cola vaciada.')) break case 'sidebar': state.sidebarVisible = !state.sidebarVisible addLines(mkLine('info', ` sidebar ${state.sidebarVisible ? 'on' : 'off'}`)) break case 'tasklist': { const tasks = taskSnapshot() const arg = (result as { tasklistArg?: string }).tasklistArg || '' if (arg === 'clean') { state.taskPanelItems = []; addLines(mkLine('info', ' Tasks limpiadas.')) } else if (tasks.length === 0) addLines(mkLine('info', ' No tasks in this session.')) else addLines(...tasks.map(t => { const icon = t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '⋯' : '○'; return mkLine('info', ` ${icon} #${t.id} ${t.subject} [${t.status}]`) })) break } case 'router': { const arg = ((result as { routerArg?: string }).routerArg || 'show').toLowerCase().trim() handleRouterCommand(arg) break } } scheduleRedraw() } // ── handleSubmit ────────────────────────────────────────────────────── async function handleSubmit(text: string): Promise { const trimmed = text.trim() if (!trimmed) return state.histIdx = -1; state.savedInput = '' if (state.isProcessing) { state.pendingQueue.push(trimmed) addLines(mkLine('info', ` queued (${state.pendingQueue.length} pending)`)) return } if (trimmed.startsWith('/')) return handleSlash(trimmed) void processTurn(trimmed) } // ── Resize handler ──────────────────────────────────────────────────── process.stdout.on('resize', () => { const newCols = process.stdout.columns || 80 // Hide sidebar if terminal becomes too narrow if (newCols < 80) state.sidebarVisible = false // Clear screen to avoid artifacts from the previous frame dimensions process.stdout.write('\x1b[2J\x1b[H') scheduleRedraw() }) // ── Input handler ───────────────────────────────────────────────────── const tuiInput = new TuiInput() // Track scrollbar drag state let _draggingScrollbar: 'main' | 'sidebar' | null = null function scrollbarJump(col: number, row: number): boolean { const tcols = process.stdout.columns || 80 const trows = process.stdout.rows || 24 const mainW = state.sidebarVisible ? Math.max(40, tcols - SIDEBAR_W) : tcols const sbColMain = mainW // main scrollbar column const sbColSide = tcols // sidebar scrollbar column // ── Main scrollbar ──────────────────────────────────────────────── if (col === sbColMain) { const chromeH = CHROME_ROWS + (state.isProcessing ? 1 : 0) + (!state.tasklistCollapsed && state.taskPanelItems.length > 0 ? Math.min(state.taskPanelItems.length, 7) + 1 : 0) const outputH = Math.max(2, trows - chromeH) const total = state.filteredLines.length if (total <= outputH) return false // no scroll needed const maxS = total - outputH // row 1..outputH → offset maxS..0 (top=maxS, bottom=0) const ratio = (row - 1) / Math.max(1, outputH - 1) state.scrollOffset = Math.round(maxS * (1 - ratio)) _draggingScrollbar = 'main' scheduleRedraw() return true } // ── Sidebar scrollbar ───────────────────────────────────────────── if (state.sidebarVisible && col === sbColSide) { const { countSidebarLines } = await_sidebarCount() const total = countSidebarLines(state) if (total <= trows) return false const maxS = Math.max(0, total - trows) const ratio = (row - 1) / Math.max(1, trows - 1) state.sidebarScrollOffset = Math.round(maxS * ratio) _draggingScrollbar = 'sidebar' scheduleRedraw() return true } return false } // Lazy import helper to avoid circular deps function await_sidebarCount(): { countSidebarLines: (s: typeof state) => number } { // countSidebarLines is exported from tui-draw but we can't import it async here, // so we inline the same logic return { countSidebarLines: (s) => { const tasks = s.sidebarTasks const mcpList = s.mcpManager?.list() ?? [] const files = Array.from(s.sessionFiles.entries()).slice(-5) let n = 3 + 4 + 1 // model+context+sep + providers(4) + sep if (tasks.length > 0) n += Math.min(tasks.length, 5) + (tasks.length > 5 ? 1 : 0) + 2 if (mcpList.length > 0) n += Math.min(mcpList.length, 4) + (mcpList.length > 4 ? 1 : 0) + 2 if (files.length > 0) n += files.length + (s.sessionFiles.size > 5 ? 1 : 0) + 2 return n + 1 // git } } } tuiInput.on('click', (col: number, row: number) => { // Check scrollbar click first if (scrollbarJump(col, row)) return // Sidebar section collapse if (!state.sidebarVisible) return const tcols = process.stdout.columns || 80 const mainW = Math.max(40, tcols - SIDEBAR_W) if (col <= mainW) return for (const [section, headerRow] of lastSidebarHeaderRows) { if (row === headerRow) { const sc = state.sidebarCollapsed as Record sc[section] = !sc[section] scheduleRedraw() return } } }) tuiInput.on('drag', (col: number, row: number) => { if (_draggingScrollbar) scrollbarJump(col, row) }) tuiInput.on('release', () => { _draggingScrollbar = null }) tuiInput.on('wheel', (dir: string, mouseCol: number) => { const tcols = process.stdout.columns || 80 const mainW = state.sidebarVisible ? Math.max(40, tcols - SIDEBAR_W) : tcols const step = 3 // Si el ratón está sobre el sidebar → scroll del sidebar if (state.sidebarVisible && mouseCol > mainW) { const sidebarTotal = (() => { const tasks = state.sidebarTasks const mcpList = state.mcpManager?.list() ?? [] const files = Array.from(state.sessionFiles.entries()).slice(-5) let n = 3 if (tasks.length > 0) n += Math.min(tasks.length, 5) + (tasks.length > 5 ? 1 : 0) + 1 if (mcpList.length > 0) n += Math.min(mcpList.length, 4) + (mcpList.length > 4 ? 1 : 0) + 1 if (files.length > 0) n += files.length + (state.sessionFiles.size > 5 ? 1 : 0) + 1 return n + 1 })() const trows = process.stdout.rows || 24 const maxSidebarScroll = Math.max(0, sidebarTotal - trows) if (dir === 'up') { state.sidebarScrollOffset = Math.min(maxSidebarScroll, state.sidebarScrollOffset + step) } else { state.sidebarScrollOffset = Math.max(0, state.sidebarScrollOffset - step) } } else { // Scroll del output principal const outputH = Math.max(2, (process.stdout.rows || 24) - CHROME_ROWS) if (dir === 'up') { const maxS = Math.max(0, state.filteredLines.length - outputH) state.scrollOffset = Math.min(maxS, state.scrollOffset + step) } else { state.scrollOffset = Math.max(0, state.scrollOffset - step) } } scheduleRedraw() }) tuiInput.on('key', (key: KeyName, char: string) => { // ── Help overlay ─────────────────────────────────────────────────── if (state.showHelp) { if (key === 'escape' || key === 'enter') { state.showHelp = false; scheduleRedraw() } return } // ── MCP picker ──────────────────────────────────────────────────── if (state.mcpPicker && state.mcpManager) { const p = state.mcpPicker const servers = state.mcpManager.list() if (key === 'up') { p.idx = Math.max(0, p.idx - 1); scheduleRedraw() } else if (key === 'down') { p.idx = Math.min(servers.length - 1, p.idx + 1); scheduleRedraw() } else if (key === 'escape') { state.mcpPicker = null; scheduleRedraw() } else { const srv = servers[p.idx] if (!srv) return const doAction = (action: 'connect' | 'disconnect' | 'restart') => { if (action === 'disconnect') { state.mcpManager!.disconnect(srv.name) addLines(mkLine('info', ` ✖ mcp ${srv.name} disconnected`)) scheduleRedraw() } else { p.connecting.add(srv.name) scheduleRedraw() void state.mcpManager![action](srv.name).then(ok => { p.connecting.delete(srv.name) addLines(mkLine('info', ok ? ` ✓ mcp ${srv.name} connected (${state.mcpManager!.list().find(s => s.name === srv.name)?.toolCount ?? 0} tools)` : ` ✖ mcp ${srv.name} failed to connect`)) scheduleRedraw() }) } } // Enter / r → toggle o reconnect; d → disconnect; c → connect if (key === 'enter' || char === 'r') { const cur = state.mcpManager.list().find(s => s.name === srv.name) doAction(cur?.status === 'connected' ? 'restart' : 'connect') } else if (char === 'd') { doAction('disconnect') } else if (char === 'c') { doAction('connect') } } return } // ── Model picker ─────────────────────────────────────────────────── if (state.modelPicker) { const p = state.modelPicker if (key === 'up') { p.idx = Math.max(0, p.idx - 1); scheduleRedraw() } else if (key === 'down') { p.idx = Math.min(p.options.length - 1, p.idx + 1); scheduleRedraw() } else if (key === 'enter') { const sel = p.options[p.idx] const resolved = resolveModelAlias(sel.alias) if (resolved) { state.model = resolved; agent.setModel(resolved) } addLines(mkLine('info', ` ✓ modelo → ${sel.alias}`)) state.modelPicker = null; scheduleRedraw() } else if (key === 'escape') { state.modelPicker = null; scheduleRedraw() } return } // ── AskUserQuestion picker ───────────────────────────────────────── if (state.pendingQuestion) { const q = state.pendingQuestion const OPTS_WITH_OTHER = [...q.options, { label: 'Other…', description: 'Type a custom answer' }] const totalOpts = OPTS_WITH_OTHER.length const close = (answer: string) => { addLines(mkLine('info', `? ${q.question}`), mkLine('info', `→ ${answer || '(no answer)'}`)) state.pendingQuestion = null; scheduleRedraw() q.resolve(answer) } // ── "Other" free-text mode ──────────────────────────────────── if (q.otherMode) { if (key === 'enter') { close(q.otherText || '') } else if (key === 'escape') { q.otherMode = false; q.otherText = ''; scheduleRedraw() } else if (key === 'backspace' || key === 'delete') { q.otherText = q.otherText.slice(0, -1); scheduleRedraw() } else if (key === 'ctrl-w') { const t = q.otherText.trimEnd() q.otherText = t.slice(0, Math.max(0, t.lastIndexOf(' ') + 1)); scheduleRedraw() } else if (key === 'ctrl-a') { q.otherText = ''; scheduleRedraw() } else if (key === 'char' && char) { q.otherText += char; scheduleRedraw() } return } // ── Normal picker mode ──────────────────────────────────────── if (key === 'up') { q.idx = Math.max(0, q.idx - 1); scheduleRedraw() } else if (key === 'down') { q.idx = Math.min(totalOpts - 1, q.idx + 1); scheduleRedraw() } else if (key === 'tab' && q.multi) { if (q.idx < q.options.length) { // can't multi-check "Other" if (q.checks.has(q.idx)) q.checks.delete(q.idx); else q.checks.add(q.idx) } scheduleRedraw() } else if (char === ' ' && q.multi && q.idx < q.options.length) { if (q.checks.has(q.idx)) q.checks.delete(q.idx); else q.checks.add(q.idx); scheduleRedraw() } else if (key === 'enter') { if (q.idx === q.options.length) { // "Other…" selected → enter free-text mode q.otherMode = true; q.otherText = ''; scheduleRedraw() } else if (q.multi) { const picked = q.checks.size > 0 ? Array.from(q.checks).sort((a,b)=>a-b) : [q.idx] close(picked.map(i => q.options[i].label).join(', ')) } else { close(q.options[q.idx].label) } } else if (key === 'escape') { close('') } else if (char && /^[1-9]$/.test(char)) { const n = parseInt(char) - 1 if (n < q.options.length) { q.idx = n if (!q.multi) close(q.options[n].label) } else if (n === q.options.length) { q.otherMode = true; q.otherText = ''; scheduleRedraw() } } return } // ── Permission picker ────────────────────────────────────────────── if (state.permissionRequest) { const req = state.permissionRequest const opts = buildPermOpts(req) const apply = (id: string) => { state.permissionRequest = null if (id === 'session') allowToolForSession(req.toolName) if (id === 'pattern' && req.patternSuggestion) allowPatternForSession(req.toolName, req.patternSuggestion) req.resolve({ approved: id !== 'deny', explanation: id === 'deny' ? 'denied by user' : undefined }) scheduleRedraw() } if (key === 'up') { req.idx = (req.idx - 1 + opts.length) % opts.length; scheduleRedraw() } else if (key === 'down') { req.idx = (req.idx + 1) % opts.length; scheduleRedraw() } else if (key === 'enter') apply(permOptId(opts, req.idx)) else if (key === 'escape') apply('deny') else if (char === 'y') apply('once') else if (char === 'a') apply('session') else if (char === 'n') apply('deny') else if (char === '1') apply('once') else if (char === '2') apply('session') else if (char === '3') apply(req.patternSuggestion ? 'pattern' : 'deny') else if (char === '4') apply('deny') return } // ── Suggestions autocomplete ─────────────────────────────────────── if (key === 'tab' && state.suggestions.length > 0) { const sel = state.suggestions[state.suggestionIdx] if (sel.startsWith('/')) { state.input = sel + ' ' } else { const atIdx = state.input.lastIndexOf('@') state.input = atIdx >= 0 ? state.input.slice(0, atIdx + 1) + sel + ' ' : sel + ' ' } state.cursorPos = state.input.length state.suggestions = [] scheduleRedraw(); return } // ── Escape ───────────────────────────────────────────────────────── if (key === 'escape') { if (state.suggestions.length > 0) { state.suggestions = []; scheduleRedraw(); return } if (state.escPending) { state.escPending = false; state.input = ''; state.cursorPos = 0; state.suggestions = []; scheduleRedraw(); return } state.escPending = true setTimeout(() => { state.escPending = false; scheduleRedraw() }, 1500) scheduleRedraw(); return } if (state.escPending) { state.escPending = false } // ── Ctrl+C ───────────────────────────────────────────────────────── if (key === 'ctrl-c') { if (state.isProcessing) { agent.abortCurrent(); addLines(mkLine('info', 'Aborting…')) } else { cleanup(); process.exit(0) } return } // ── Scroll ───────────────────────────────────────────────────────── if (key === 'ctrl-u' || key === 'pageup') { const outputH = Math.max(2, (process.stdout.rows || 24) - CHROME_ROWS) const half = Math.max(1, Math.floor(outputH / 2)) const maxS = Math.max(0, state.filteredLines.length - outputH) state.scrollOffset = Math.min(maxS, state.scrollOffset + half) scheduleRedraw(); return } if (key === 'ctrl-d' || key === 'pagedown') { const outputH = Math.max(2, (process.stdout.rows || 24) - CHROME_ROWS) const half = Math.max(1, Math.floor(outputH / 2)) state.scrollOffset = Math.max(0, state.scrollOffset - half) scheduleRedraw(); return } // ── Alt+V / Ctrl+V — paste image from clipboard ────────────────── // Ctrl+V is intercepted by Windows Terminal before reaching us. // Alt+V (\x1bv) passes through reliably on all terminals. if (key === 'alt-v' || key === 'ctrl-v') { void pasteClipboardImage() return } // ── Toggles ──────────────────────────────────────────────────────── if (key === 'ctrl-r') { const val = !state.routerEnabled state.routerEnabled = val persistRouterConfig({ enabled: val }) addLines(mkLine('info', ` router ${val ? 'ON' : 'OFF'}`)) scheduleRedraw(); return } if (key === 'ctrl-b') { state.sidebarVisible = !state.sidebarVisible; scheduleRedraw(); return } if (key === 'ctrl-t') { state.tasklistCollapsed = !state.tasklistCollapsed; scheduleRedraw(); return } if (key === 'ctrl-o') { state.thinkingCollapsed = !state.thinkingCollapsed; recomputeFiltered(state); scheduleRedraw(); return } // ── Shift+Tab: cycle mode ────────────────────────────────────────── if (key === 'shift-tab') { const next = cycleMode(state.mode) state.mode = next addLines(mkLine('info', `mode → ${next}`)) scheduleRedraw(); return } // ── Enter ────────────────────────────────────────────────────────── if (key === 'enter') { const trimmed = state.input.trim() state.input = ''; state.cursorPos = 0; state.suggestions = [] if (trimmed) void handleSubmit(trimmed) scheduleRedraw(); return } // ── Suggestions navigation (↑/↓ — mouse wheel sends these too) ──── // NOTE: ↑/↓ are NOT used for history because the mouse wheel sends // the same escape sequences (\x1b[A / \x1b[B) and would accidentally // navigate history. Use Ctrl+P / Ctrl+N for history (readline standard). if (key === 'up') { if (state.suggestions.length > 0) { state.suggestionIdx = Math.max(0, state.suggestionIdx - 1); scheduleRedraw() } return // swallow — don't let mouse wheel do history navigation } if (key === 'down') { if (state.suggestions.length > 0) { state.suggestionIdx = Math.min(state.suggestions.length - 1, state.suggestionIdx + 1); scheduleRedraw() } return // swallow } // ── History navigation (Ctrl+P = prev, Ctrl+N = next) ───────────── if (key === 'ctrl-p') { if (state.history.length === 0) return const newIdx = state.histIdx < 0 ? state.history.length - 1 : Math.max(0, state.histIdx - 1) if (state.histIdx < 0) state.savedInput = state.input state.histIdx = newIdx; state.input = state.history[newIdx]; state.cursorPos = state.input.length scheduleRedraw(); return } if (key === 'ctrl-n') { if (state.histIdx < 0) return if (state.histIdx >= state.history.length - 1) { state.histIdx = -1; state.input = state.savedInput; state.cursorPos = state.input.length } else { state.histIdx++; state.input = state.history[state.histIdx]; state.cursorPos = state.input.length } scheduleRedraw(); return } // ── Cursor movement ──────────────────────────────────────────────── if (key === 'left') { state.cursorPos = Math.max(0, state.cursorPos - 1); scheduleRedraw(); return } if (key === 'right') { state.cursorPos = Math.min(state.input.length, state.cursorPos + 1); scheduleRedraw(); return } if (key === 'ctrl-a') { state.cursorPos = 0; state.input = ''; state.suggestions = []; scheduleRedraw(); return } if (key === 'ctrl-e') { state.cursorPos = state.input.length; scheduleRedraw(); return } // ── Backspace / Delete ───────────────────────────────────────────── if (key === 'backspace' || key === 'delete') { if (state.cursorPos > 0) { state.input = state.input.slice(0, state.cursorPos - 1) + state.input.slice(state.cursorPos) state.cursorPos-- } updateSuggestions(); scheduleRedraw(); return } // ── Ctrl+W: delete word ──────────────────────────────────────────── if (key === 'ctrl-w') { const before = state.input.slice(0, state.cursorPos) const trimmed = before.trimEnd() const lastSpace = trimmed.lastIndexOf(' ') const newBefore = lastSpace >= 0 ? trimmed.slice(0, lastSpace + 1) : '' state.input = newBefore + state.input.slice(state.cursorPos) state.cursorPos = newBefore.length state.suggestions = []; scheduleRedraw(); return } // ── Regular char ─────────────────────────────────────────────────── if (key === 'char' && char) { state.input = state.input.slice(0, state.cursorPos) + char + state.input.slice(state.cursorPos) state.cursorPos++ updateSuggestions(); scheduleRedraw(); return } }) function updateSuggestions(): void { const v = state.input if (v.startsWith('/')) { const all = [...SLASH_COMMANDS, ...customCommands.map(c => `/${c.name}`)] state.suggestions = all.filter(c => c.startsWith(v)).slice(0, 6) state.suggestionIdx = 0 } else { const atMatch = /@([^\s]*)$/.exec(v) if (atMatch) { state.suggestions = getAliasKeys().filter(a => a.startsWith(atMatch[1])).slice(0, 6) state.suggestionIdx = 0 } else { state.suggestions = [] } } } function permOptId(opts: Array<{ label: string; danger?: boolean }>, idx: number): string { const safeIdx = Math.min(idx, opts.length - 1) if (safeIdx === 0) return 'once' if (safeIdx === 1) return 'session' if (safeIdx === opts.length - 1) return 'deny' return 'pattern' } function buildPermOpts(req: ModalPermission): Array<{ label: string; hint?: string; danger?: boolean }> { const opts: Array<{ label: string; hint?: string; danger?: boolean }> = [ { label: 'Yes', hint: 'allow just this call' }, { label: `Yes, don't ask again for ${req.toolName} this session`, hint: 'until sq closes' }, ] if (req.patternSuggestion) opts.push({ label: `Yes, don't ask again for ${req.toolName} matching ${req.patternSuggestion}`, hint: 'pattern' }) opts.push({ label: 'No, and tell the model what to do instead', hint: 'denies + explanation', danger: true }) return opts } // ── Cleanup ──────────────────────────────────────────────────────────── let _cleanedUp = false function cleanup(): void { if (_cleanedUp) return // idempotente — no ejecutar dos veces _cleanedUp = true clearInterval(sidebarTimer) if (_redrawTimer) clearTimeout(_redrawTimer) if (_taskTimer) clearTimeout(_taskTimer) tuiInput.stop() setUserQuestioner(null) const rows = process.stdout.rows || 24 process.stdout.write( '\x1b[?25h' // show cursor (por si quedó oculto) + '\x1b[0m' // reset atributos ANSI (colores, bold, etc.) + `\x1b[${rows};1H` // cursor a la última fila — el shell prompt aparece abajo + '\r\n' + '\x1b[?1049l' // salir del alt screen (restaura buffer anterior) ) } process.on('exit', cleanup) process.on('SIGINT', () => { cleanup(); process.exit(0) }) process.on('SIGTERM', () => { cleanup(); process.exit(0) }) // ── Start ────────────────────────────────────────────────────────────── tuiInput.start() scheduleRedraw() // Block until process exits await new Promise(() => {}) }