/** * tui-draw.ts — Rendering custom ANSI sin React/Ink. * * Estrategia: full-screen redraw por frame. * 1. Ocultar cursor (sin flicker visual) * 2. Posicionar cursor en cada fila explícitamente con \x1b[row;colH * 3. Escribir el contenido de esa fila (padded a ancho correcto) * 4. Mostrar cursor y posicionarlo en el input * * Todo el output pasa por `out()` que acumula en un buffer y hace * un único write() al final del frame — minimiza llamadas al OS. */ import { renderMdLine } from './markdown.js' import { getGitInfo } from './git-info.js' import { taskSnapshot } from '../tools/tasks.js' import type { McpManager } from '../mcp/manager.js' import type { AppState, OutputLine, ModalPermission, ModalModelPicker, ModalQuestion } from './tui-repl.js' // ── ANSI constants ──────────────────────────────────────────────────────────── const R = '\x1b[0m' const DIM = '\x1b[2m' const BOLD = '\x1b[1m' const GREEN = '\x1b[38;5;71m' // #6aaa6a const ORANGE = '\x1b[38;5;179m' // #c8a050 const RED = '\x1b[38;5;167m' // #c05050 const BLUE = '\x1b[38;5;74m' // #7a9ec2 const CYAN = '\x1b[38;5;73m' // #5f9ea0 const GRAY = '\x1b[90m' const BG_DARK = '\x1b[48;5;234m' // #1a1a1a code blocks const BG_HEADER = '\x1b[48;5;236m' // #303030 user messages const BG_DIFF_ADD = '\x1b[48;5;22m' const BG_DIFF_REM = '\x1b[48;5;52m' export const SIDEBAR_W = 36 export const CHROME_ROWS = 5 // topSep + status + mode + botSep + input // ── Tiny helpers ────────────────────────────────────────────────────────────── function pad(s: string, w: number): string { const vis = visLen(s) // longitud visible (sin ANSI codes) if (vis >= w) return s return s + ' '.repeat(w - vis) } /** Pad a plain string (no ANSI) to width w — faster, no regex needed */ function padPlain(s: string, w: number): string { return s.length >= w ? s : s + ' '.repeat(w - s.length) } /** Visible length ignoring ANSI escape codes */ function visLen(s: string): number { // Strip all ESC sequences (CSI, OSC, etc.) return s.replace(/\x1b(?:\[[0-9;]*[mJKHfABCDGsuhlr]|\][^\x07]*\x07)/g, '').length } function trunc(s: string, max: number): string { if (s.length <= max) return s return s.slice(0, max - 1) + '…' } function bar(pct: number, width = 10): string { const n = Math.max(0, Math.min(width, Math.round((Math.min(pct, 100) / 100) * width))) const color = pct >= 90 ? RED : pct >= 70 ? ORANGE : CYAN return `${color}${'█'.repeat(n)}${R}${DIM}${'░'.repeat(width - n)}${R}` } function providerOf(model: string): string { if (/claude-|haiku|sonnet|opus/.test(model)) return 'anthropic' if (/gpt-|^o3|^o4|^\d/.test(model)) return 'openai' if (/gemini-/.test(model)) return 'google' return 'unknown' } function providerColor(p: string): string { if (p === 'anthropic') return GREEN if (p === 'openai') return BLUE if (p === 'google') return ORANGE return GRAY } function shortModel(model: string): string { const m = /claude-(opus|sonnet|haiku)-(\d+)-(\d+)/.exec(model) if (m) return `${m[1]} ${m[2]}.${m[3]}` const g = /gemini-(\d+(?:\.\d+)?)-(pro|flash)/.exec(model) if (g) return `gemini ${g[1]} ${g[2]}` if (model.startsWith('gpt-5-codex')) return 'gpt-5-codex' if (model.startsWith('gpt-')) return model.slice(0, 10) return model.slice(0, 16) } /** Time remaining until a reset timestamp, e.g. "3h 22m", "47m", "5h" if unknown */ function formatResetIn(resetAtMs: number): string { if (!resetAtMs || resetAtMs <= 0) return '5h' // not yet known const ms = resetAtMs - Date.now() if (ms <= 0) return 'resetting' const h = Math.floor(ms / 3_600_000) const m = Math.floor((ms % 3_600_000) / 60_000) if (h > 0) return `${h}h ${m}m` return `${m}m` } function basename(p: string): string { return p.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? p } // ── Scrollbar ───────────────────────────────────────────────────────────────── /** * Dibuja una scrollbar vertical en la columna `col`. * offsetFromBottom=0 → thumb al fondo (live), offsetFromBottom=max → thumb arriba. */ export function drawScrollbar( startRow: number, height: number, col: number, totalLines: number, visibleLines: number, offsetFromBottom: number, ): void { if (totalLines <= visibleLines || height < 3) return // no hace falta barra const thumbH = Math.max(1, Math.round((visibleLines / totalLines) * height)) const maxScroll = totalLines - visibleLines // offsetFromBottom=0 → thumb al fondo; offset=max → thumb arriba const scrolled = Math.min(offsetFromBottom, maxScroll) const thumbTop = Math.round(((maxScroll - scrolled) / maxScroll) * (height - thumbH)) for (let i = 0; i < height; i++) { goto(startRow + i, col) if (i >= thumbTop && i < thumbTop + thumbH) { out(`${DIM}▐${R}`) // thumb — bloque medio } else { out(`\x1b[38;5;238m▐${R}`) // track — gris muy oscuro } } } /** * Clip a string to maxW VISIBLE characters, preserving ANSI escape codes. * Appends \x1b[0m (reset) if clipping happened so colours don't bleed. */ function clipVisible(s: string, maxW: number): string { let visible = 0 let i = 0 while (i < s.length) { if (s.charCodeAt(i) === 0x1b) { // Skip the full escape sequence (ends at a letter or specific chars) i++ if (i < s.length && s[i] === '[') { i++ // CSI sequence — ends at first byte in 0x40–0x7e while (i < s.length && (s.charCodeAt(i) < 0x40 || s.charCodeAt(i) > 0x7e)) i++ i++ // consume the final byte } else if (i < s.length && s[i] === ']') { // OSC — ends at BEL or ST while (i < s.length && s[i] !== '\x07' && s[i] !== '\x9c') i++ i++ } else { i++ // simple 2-char escape } continue } if (visible >= maxW) { return s.slice(0, i) + R // clip here, reset colours } visible++ i++ } return s } // ── Frame buffer ────────────────────────────────────────────────────────────── let _buf = '' function out(s: string): void { _buf += s } function goto(row: number, col = 1): void { out(`\x1b[${row};${col}H`) } function clearLine(): void { out('\x1b[2K') } function flush(): void { process.stdout.write(_buf); _buf = '' } // ── OutputLine rendering ────────────────────────────────────────────────────── function renderOutputLine(line: OutputLine, w: number): string { const { kind, text } = line switch (kind) { case 'user_header': // "you" in bright white bold, rest of row in dark background return `${BG_HEADER}${pad(` \x1b[97m${BOLD}you${R}${BG_HEADER}`, w)}${R}` case 'user_body': return `${BG_HEADER}${padPlain(` ${text}`, w)}${R}` case 'agent_header': return `${GREEN}${BOLD} Squeezr${R}` case 'agent_body': return ` ${renderMdLine(text)}` case 'agent_code_fence_open': { const lang = line.lang?.trim() || '' const blk = line.blockIndex ?? 0 const label = lang ? `${BLUE}${BOLD} ${lang} ${R}${BG_DARK}${DIM}· block #${blk}` : `${DIM} code ${R}${BG_DARK}${DIM}· block #${blk}` return `${BG_DARK}${pad(label, w)}${R}` } case 'agent_code': return `${BG_DARK}${pad(` ${text}`, w)}${R}` case 'agent_code_fence_close': return `${BG_DARK}${pad(' ', w)}${R}` case 'thinking': return `${DIM}${BLUE} ${text}${R}` case 'tool_start': return `${BLUE} ${text}${R}` case 'diff_remove': return `${BG_DIFF_REM}${pad(` ${text}`, w)}${R}` case 'diff_add': return `${BG_DIFF_ADD}${pad(` ${text}`, w)}${R}` case 'task_item': return `${DIM}${BLUE} ${text}${R}` case 'turn_end': return `${DIM} ╰──${R}` case 'error': return `${RED} ✖ ${text}${R}` case 'info': default: return `${DIM} · ${text}${R}` } } // ── Main draw entry ─────────────────────────────────────────────────────────── export function drawFrame(state: AppState): void { const trows = process.stdout.rows || 24 const tcols = process.stdout.columns || 80 const sidebar = state.sidebarVisible && tcols >= 80 const mainW = sidebar ? Math.max(40, tcols - SIDEBAR_W) : tcols const outputH = Math.max(2, trows - CHROME_ROWS - (state.isProcessing ? 1 : 0) - (state.taskPanelItems.length > 0 && !state.tasklistCollapsed ? Math.min(state.taskPanelItems.length, 7) + 1 : 0) - (state.permissionRequest ? 7 : 0) - (state.pendingQuestion ? state.pendingQuestion.options.length + 4 : 0) - (state.mcpPicker && state.mcpManager ? state.mcpManager.list().length * 2 + 2 : 0) - (state.showHelp ? 14 : 0) - (state.modelPicker ? state.modelPicker.options.length + 1 : 0) - (state.suggestions.length > 0 ? state.suggestions.length + 1 : 0) ) // Compute visible output lines const maxScroll = Math.max(0, state.filteredLines.length - outputH) const clampedOffset = Math.min(state.scrollOffset, maxScroll) const visLines = clampedOffset === 0 ? state.filteredLines.slice(-outputH) : state.filteredLines.slice(-(outputH + clampedOffset), -clampedOffset || undefined) const topPad = Math.max(0, outputH - visLines.length) out('\x1b[?25l') // hide cursor // ── Output area (rows 1..outputH, cols 1..mainW) ────────────────────── for (let r = 0; r < outputH; r++) { goto(r + 1, 1) clearLine() const lineIdx = r - topPad if (lineIdx >= 0 && lineIdx < visLines.length) { const rendered = renderOutputLine(visLines[lineIdx], mainW) out(clipVisible(rendered, mainW)) } } // ── Sidebar (rows 1..trows, cols mainW+1..tcols) ────────────────────── if (sidebar) { drawSidebar(state, trows, mainW + 1, tcols) // Sidebar scrollbar (columna derecha del terminal) const sidebarLines = countSidebarLines(state) drawScrollbar(1, trows, tcols, sidebarLines, trows, state.sidebarScrollOffset) } // ── Main scrollbar (columna derecha del área de output) ─────────────── { const sbCol = sidebar ? mainW : tcols drawScrollbar(1, outputH, sbCol, state.filteredLines.length, outputH, clampedOffset) } // ── Chrome: thinking, task panel, modals, status, mode, input ──────── let chromeCursor = outputH + 1 // Thinking line (only when processing) if (state.isProcessing) { goto(chromeCursor, 1) clearLine() const verb = THINKING_VERBS[Math.floor(Date.now() / 3000) % THINKING_VERBS.length] const elapsed = Math.floor((Date.now() - state.thinkingStart) / 1000) const m = Math.floor(elapsed / 60), s = elapsed % 60 const elapsedStr = m > 0 ? `${m}m ${s}s` : `${s}s` const tokStr = state.streamTokens > 0 ? ` · ↓ ${formatK(state.streamTokens)}` : '' out(`${ORANGE} ✦ ${verb}… ${R}${DIM}(${elapsedStr}${tokStr} · esc to cancel)${R}`) chromeCursor++ } // Task panel if (state.taskPanelItems.length > 0 && !state.tasklistCollapsed) { goto(chromeCursor, 1); clearLine() out(`${DIM}${'─'.repeat(mainW)}${R}`) chromeCursor++ goto(chromeCursor, 1); clearLine() out(`${DIM} Tasks (${state.taskPanelItems.length})${R}`) chromeCursor++ for (const t of state.taskPanelItems.slice(0, 7)) { goto(chromeCursor, 1); clearLine() const icon = t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '⋯' : '○' const col = t.status === 'completed' ? GREEN : t.status === 'in_progress' ? ORANGE : '' out(`${col} ${icon} ${R}${DIM}#${t.id} ${R}${t.status === 'completed' ? DIM : ''}${trunc(t.subject, mainW - 10)}${R}`) chromeCursor++ } if (state.taskPanelItems.length > 7) { goto(chromeCursor, 1); clearLine() out(`${DIM} … ${state.taskPanelItems.length - 7} more${R}`) chromeCursor++ } } // MCP picker if (state.mcpPicker && state.mcpManager) { chromeCursor = drawMcpPicker(state, chromeCursor, mainW) } // Help overlay if (state.showHelp) { chromeCursor = drawHelp(chromeCursor, mainW) } // Model picker if (state.modelPicker) { chromeCursor = drawModelPicker(state.modelPicker, chromeCursor, mainW) } // AskUserQuestion picker if (state.pendingQuestion) { chromeCursor = drawModalQuestion(state.pendingQuestion, chromeCursor, mainW) } // Permission picker if (state.permissionRequest) { chromeCursor = drawModalPermission(state.permissionRequest, chromeCursor, mainW) } // Separator top goto(chromeCursor, 1); clearLine() out(`${DIM}${'─'.repeat(mainW)}${R}`) chromeCursor++ // Status bar goto(chromeCursor, 1); clearLine() // ctxPct = 5-hour subscription quota %, resetLabel = time until it resets // contextWindowPct = actual model context window usage (tokens used / limit) const quotaPct = state.ctxPct const ctxWinPct = (state as { contextWindowPct?: number }).contextWindowPct ?? 0 const barStr = bar(quotaPct) const resetLabel = formatResetIn((state as { fiveHourResetAt?: number }).fiveHourResetAt ?? 0) out(`${GREEN}${state.projectName}${R}${DIM} · ${R}${barStr}${DIM} ${quotaPct}% ${resetLabel}`) // Also show context window % if it's significant (>10%) if (ctxWinPct >= 10) { const ctxColor = ctxWinPct >= 80 ? RED : ctxWinPct >= 60 ? ORANGE : DIM out(` · ${ctxColor}ctx ${ctxWinPct}%${R}`) } out(`${DIM} · $${state.cost.toFixed(2)} · ${R}${ORANGE}${shortModel(state.model)}${R}`) chromeCursor++ // Mode line goto(chromeCursor, 1); clearLine() const MODE_COLORS: Record = { default: CYAN, 'accept-edits': ORANGE, plan: BLUE, bypass: RED } const mc = MODE_COLORS[state.mode] || CYAN out(`${DIM} ↳ ${R}${mc}${state.mode}${R}${DIM} · shift+tab`) out(` Ctrl+O ${state.thinkingCollapsed ? 'expand' : 'collapse'} thinking`) out(` Ctrl+T ${state.tasklistCollapsed ? 'expand' : 'collapse'} tasks`) out(` Ctrl+B ${state.sidebarVisible ? 'hide' : 'show'} sidebar`) out(` Ctrl+R ${(state as {routerEnabled?:boolean}).routerEnabled ? 'router ON' : 'router OFF'}`) if (clampedOffset > 0) out(`${ORANGE} · scrolled${R}`) if (state.pendingQueue.length > 0) out(`${ORANGE} · ${state.pendingQueue.length} queued${R}`) out(R) chromeCursor++ // Separator bottom goto(chromeCursor, 1); clearLine() out(`${DIM}${'─'.repeat(mainW)}${R}`) chromeCursor++ // Input line goto(chromeCursor, 1); clearLine() const promptColor = state.isProcessing ? ORANGE : GREEN out(`${promptColor}❯ ${R}`) // Show attachment indicator if images are pending const att = (state as { pendingAttachments?: Array<{label: string}> }).pendingAttachments ?? [] if (att.length > 0) out(`${CYAN}[${att.length} image${att.length > 1 ? 's' : ''} attached] ${R}`) out(state.input) if (!state.escPending) out(`${GREEN}▌${R}`) else out(`${DIM} Esc again to clear${R}`) const inputRow = chromeCursor const inputCol = 3 + state.cursorPos // "❯ " = 2 chars + 1 offset chromeCursor++ // Suggestions if (state.suggestions.length > 0) { for (let i = 0; i < state.suggestions.length; i++) { goto(chromeCursor, 1); clearLine() const sel = i === state.suggestionIdx out(`${sel ? GREEN : DIM}${sel ? '❯ ' : ' '}${state.suggestions[i]}${R}`) chromeCursor++ } goto(chromeCursor, 1); clearLine() out(`${DIM} Tab select · Esc close${R}`) } // Show cursor at input position out('\x1b[?25h') goto(inputRow, inputCol) flush() } // ── Sidebar ─────────────────────────────────────────────────────────────────── /** Fila de cada header de sección en el último frame. Se usa para detectar clicks. */ export const lastSidebarHeaderRows = new Map() /** Cuenta cuántas líneas tendría el sidebar si no hubiera límite de altura */ export function countSidebarLines(state: AppState): number { const tasks = state.sidebarTasks const mcpList = state.mcpManager?.list() ?? [] const files = Array.from(state.sessionFiles.entries()).slice(-5) let n = 3 // model + context + sep if (tasks.length > 0) n += Math.min(tasks.length, 5) + (tasks.length > 5 ? 1 : 0) + 1 // +sep 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 n += 1 // git return n } function drawSidebar(state: AppState, trows: number, sidebarStartCol: number, tcols: number): void { const w = tcols - sidebarStartCol if (w < 8) return const tasks = state.sidebarTasks const mcpList = state.mcpManager?.list() ?? [] const files = Array.from(state.sessionFiles.entries()).slice(-5) const git = getGitInfo(state.cwd) const provider = providerOf(state.model) const pc = providerColor(provider) const col_ = state.sidebarCollapsed const sep = `${DIM}${'─'.repeat(w)}${R}` const lines: string[] = [] // Map: section name → index in `lines` where the header is lastSidebarHeaderRows.clear() // Helper: section header with ▶/▼ toggle indicator const sectionHeader = (key: string, label: string) => { const arrow = col_[key as keyof typeof col_] ? `${DIM}▶${R}` : `${DIM}▼${R}` return `${arrow} ${BOLD}${label}${R}` } // ── Model + context + router indicator ────────────────────────────── // Router indicator: ⟳ = routing on, ✦ = also learning (background LLM) const re = (state as { routerEnabled?: boolean }).routerEnabled const rl = (state as { routerLearning?: boolean }).routerLearning const routerTag = re ? ` ${CYAN}[router${rl ? '+learn' : ''}]${R}` : ` ${DIM}[router off]${R}` lines.push(`${pc}● ${R}${BOLD}${trunc(shortModel(state.model), w - 16)}${R} ${DIM}${provider}${R}${routerTag}`) const cwPct = (state as { contextWindowPct?: number }).contextWindowPct ?? 0 const displayPct = cwPct > 0 ? cwPct : state.ctxPct lines.push(`${bar(displayPct, 10)}${DIM} ctx ${displayPct}% $${state.cost.toFixed(2)}${R}`) lines.push(sep) // ── Providers ──────────────────────────────────────────────────────── lastSidebarHeaderRows.set('providers', lines.length) lines.push(sectionHeader('providers', 'Providers')) if (!col_.providers) { const auth = state.authStatus const pDot = (ok: boolean, label: string) => { const c = ok ? GREEN : RED return ` ${c}${ok ? '●' : '○'}${R} ${DIM}${label}${R}` } lines.push(pDot(auth.anthropic, 'anthropic')) lines.push(pDot(auth.openai, 'openai')) lines.push(pDot(auth.google, 'google')) } lines.push(sep) // ── MCP ────────────────────────────────────────────────────────────── lastSidebarHeaderRows.set('mcp', lines.length) lines.push(sectionHeader('mcp', `MCP${mcpList.length > 0 ? ` (${mcpList.length})` : ''}`)) if (!col_.mcp) { if (mcpList.length === 0) { lines.push(`${DIM} empty${R}`) } else { for (const srv of mcpList.slice(0, 5)) { const dot = srv.status === 'connected' ? '●' : srv.status === 'connecting' ? '◐' : '○' const c = srv.status === 'connected' ? GREEN : srv.status === 'connecting' ? ORANGE : srv.status === 'error' ? RED : DIM const st = srv.status === 'connected' ? `${srv.toolCount}t` : srv.status === 'error' ? 'err' : srv.status.slice(0, 4) lines.push(` ${c}${dot}${R} ${trunc(srv.name, 12).padEnd(13)}${DIM}${st}${R}`) } if (mcpList.length > 5) lines.push(`${DIM} … ${mcpList.length - 5} more${R}`) } } lines.push(sep) // ── Tasks ───────────────────────────────────────────────────────────── lastSidebarHeaderRows.set('tasks', lines.length) lines.push(sectionHeader('tasks', `Tasks${tasks.length > 0 ? ` (${tasks.length})` : ''}`)) if (!col_.tasks) { if (tasks.length === 0) { lines.push(`${DIM} empty${R}`) } else { for (const t of tasks.slice(0, 5)) { const icon = t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '⋯' : '○' const c = t.status === 'completed' ? GREEN : t.status === 'in_progress' ? ORANGE : DIM lines.push(` ${c}${icon}${R} ${DIM}#${t.id}${R} ${trunc(t.subject, w - 6)}`) } if (tasks.length > 5) lines.push(`${DIM} … ${tasks.length - 5} more${R}`) } } lines.push(sep) // ── Background processes + monitors ────────────────────────────────── const shells = state.bgShells ?? [] const monitors = state.activeMonitors ?? [] const procTotal = shells.length + monitors.length lastSidebarHeaderRows.set('processes', lines.length) lines.push(sectionHeader('processes', `Processes${procTotal > 0 ? ` (${procTotal})` : ''}`)) if (!col_.processes) { if (procTotal === 0) { lines.push(`${DIM} empty${R}`) } else { for (const sh of shells.slice(0, 4)) { const isRunning = sh.status === 'running' const dot = isRunning ? '●' : '○' const c = isRunning ? GREEN : GRAY const age = sh.ageS < 60 ? `${sh.ageS}s` : `${Math.floor(sh.ageS/60)}m` lines.push(` ${c}${dot}${R} ${BOLD}$${R} ${trunc(sh.command, w - 8)}${DIM} ${age}${R}`) } for (const m of monitors.slice(0, 3)) { const age = m.ageS < 60 ? `${m.ageS}s` : `${Math.floor(m.ageS/60)}m` lines.push(` ${ORANGE}◉${R} ${trunc(m.description, w - 6)}${DIM} ${age}${R}`) } if (procTotal > 7) lines.push(`${DIM} … ${procTotal - 7} more${R}`) } } lines.push(sep) // ── Files ──────────────────────────────────────────────────────────── if (files.length > 0) { lastSidebarHeaderRows.set('files', lines.length) lines.push(sectionHeader('files', `Files (${state.sessionFiles.size})`)) if (!col_.files) { for (const [fp, kind] of files) { const icon = kind === 'created' ? '+' : '✎' const c = kind === 'created' ? GREEN : BLUE lines.push(` ${c}${icon}${R} ${DIM}${trunc(basename(fp), w - 4)}${R}`) } if (state.sessionFiles.size > 5) lines.push(`${DIM} … ${state.sessionFiles.size - 5} more${R}`) } lines.push(sep) } // ── Git ─────────────────────────────────────────────────────────────── if (git) { const dirtyDot = git.dirty ? `${ORANGE}●${R}` : `${DIM}○${R}` lines.push(`${DIM}∓ ${R}${trunc(git.branch ?? 'detached', w - 5)} ${dirtyDot}`) } else { lines.push(`${DIM}∓ (no git)${R}`) } // Render con scroll const scrolled = Math.min(state.sidebarScrollOffset, Math.max(0, lines.length - trows)) const visibleLines = lines.slice(scrolled, scrolled + trows) // Actualizar posiciones reales de los headers (ajustadas por scroll) for (const [key, idx] of lastSidebarHeaderRows) { const screenRow = idx - scrolled + 1 // 1-indexed lastSidebarHeaderRows.set(key, screenRow) } for (let r = 0; r < trows; r++) { goto(r + 1, sidebarStartCol) out(`${CYAN}│${R}`) out('\x1b[K') if (r < visibleLines.length) out(visibleLines[r]) } } // ── Modals ──────────────────────────────────────────────────────────────────── function drawMcpPicker(state: AppState, startRow: number, w: number): number { const p = state.mcpPicker! const mcp = state.mcpManager! const servers = mcp.list() let r = startRow const W = Math.min(w - 2, 72) const header = `MCP Servers (${servers.length})` const hints = 'esc to close' goto(r, 1); clearLine() out(`${CYAN}┌ ${BOLD}${header}${R}${CYAN}${DIM} ${hints}${R}`) r++ for (let i = 0; i < servers.length; i++) { const srv = servers[i] const sel = i === p.idx const isConnecting = p.connecting.has(srv.name) const bg = sel ? '\x1b[48;5;236m' : '' // Status dot + color let dot: string, dotColor: string, statusLabel: string if (isConnecting) { dot = '◐'; dotColor = ORANGE; statusLabel = 'Connecting…' } else if (srv.status === 'connected') { dot = '●'; dotColor = GREEN; statusLabel = `Connected ${DIM}${srv.toolCount} tools${R}` } else if (srv.status === 'connecting') { dot = '◐'; dotColor = ORANGE; statusLabel = 'Connecting…' } else if (srv.status === 'error') { dot = '✕'; dotColor = RED; statusLabel = `Error: ${trunc(srv.lastError ?? 'unknown', W - 24)}` } else { dot = '○'; dotColor = GRAY; statusLabel = 'Disconnected' } goto(r, 1); clearLine() out(bg) out(sel ? `${GREEN}❯ ${R}${bg}` : ` `) out(`${dotColor}${dot}${R}${bg} `) out(`${sel ? BOLD : ''}${trunc(srv.name, 16).padEnd(17)}${R}${bg}`) out(`${dotColor}${statusLabel}${R}`) if (sel) out('\x1b[K') r++ // Segunda línea: comando abreviado + hints de acción (solo para la fila seleccionada) goto(r, 1); clearLine() if (sel) { const cmd = trunc(`${srv.command} ${srv.args.join(' ')}`, W - 30) const actions = srv.status === 'connected' ? `${DIM}enter/r reconnect d disconnect${R}` : `${DIM}enter/r/c connect${R}` out(` ${GRAY}${cmd}${R} ${actions}`) } else { out(` ${GRAY}${trunc(`${srv.command} ${srv.args.join(' ')}`, W - 4)}${R}`) } r++ } goto(r, 1); clearLine() out(`${CYAN}└${DIM} ↑↓ navigate · enter/r reconnect · d disconnect · esc close${R}`) r++ return r } function drawHelp(startRow: number, w: number): number { let r = startRow const line = (content: string) => { goto(r, 1); clearLine(); out(content); r++ } const sep = `${DIM}${'─'.repeat(Math.min(w, 60))}${R}` line(`${CYAN}┌ Squeezr — quick reference${R}${DIM} Esc/Enter to close${R}`) line(sep) line(`${DIM} Models ${R}/model ${DIM}picker ↑↓ Enter${R}`) line(`${DIM} ${R}@alias text ${DIM}one-shot override (@opus, @sonnet…)${R}`) line(sep) line(`${DIM} Session ${R}/status /cost ${DIM}tokens and cost${R}`) line(`${DIM} ${R}/compact ${DIM}compress history${R}`) line(`${DIM} ${R}/clear ${DIM}clear context${R}`) line(`${DIM} ${R}/repeat /cancel ${DIM}resend / clear queue${R}`) line(sep) line(`${DIM} Tasks ${R}/tasklist ${DIM}show tasks · Ctrl+T panel${R}`) line(`${DIM} Sidebar ${R}Ctrl+B /sidebar ${DIM}toggle sidebar${R}`) line(`${DIM} Router ${R}Ctrl+R /router ${DIM}toggle auto-routing${R}`) line(`${DIM} Thinking ${R}Ctrl+O ${DIM}expand/collapse${R}`) line(`${DIM} Scroll ${R}PgUp/PgDn · Ctrl+U/D${R}`) line(`${DIM} History ${R}Ctrl+P prev · Ctrl+N next${DIM} (↑↓ reserved for mouse wheel / suggestions)${R}`) line(`${DIM} @file ${R}@src/foo.ts ${DIM}inject file content into prompt${R}`) line(`${DIM} Images ${R}Alt+V /paste ${DIM}attach clipboard screenshot (Recortes, Win+Shift+S)${R}`) line(`${DIM} ${R}@photo.png ${DIM}attach image file directly${R}`) line(`${CYAN}└${R}`) return r } function drawModelPicker(picker: ModalModelPicker, startRow: number, w: number): number { let r = startRow goto(r, 1); clearLine() out(`${DIM} Select model ↑↓ · Enter confirm · Esc cancel${R}`) r++ for (let i = 0; i < picker.options.length; i++) { goto(r, 1); clearLine() const o = picker.options[i] const sel = i === picker.idx out(sel ? `${GREEN}❯ ${o.alias.padEnd(18)}${R} ${DIM}${o.label}${R}` : `${DIM} ${o.alias.padEnd(18)} ${o.label}${R}`) r++ } return r } function drawModalQuestion(q: ModalQuestion, startRow: number, w: number): number { let r = startRow const W = Math.min(w - 2, 70) goto(r, 1); clearLine(); out(`${CYAN}┌${R}${BLUE}${BOLD} ? ${q.question}${R}`); r++ if (q.multi) { goto(r, 1); clearLine(); out(`${DIM} multi-select · space toggles · enter confirms${R}`); r++ } goto(r, 1); clearLine(); r++ // "Other…" free-text mode if (q.otherMode) { goto(r, 1); clearLine() out(`${CYAN}❯ ${R}${BOLD}Type your answer:${R} ${q.otherText}${GREEN}▌${R}`) r++ goto(r, 1); clearLine(); out(`${DIM} Enter to confirm · Esc to go back${R}`); r++ return r } // Normal picker + always add "Other…" at the bottom const optsWithOther = [...q.options, { label: 'Other…', description: 'Type a custom answer' }] for (let i = 0; i < optsWithOther.length; i++) { const sel = i === q.idx const isOther = i === q.options.length const checked = q.checks.has(i) const cursor = sel ? '❯' : ' ' const box = q.multi && !isOther ? (checked ? '[x]' : '[ ]') : `${i + 1}.` goto(r, 1); clearLine() const labelColor = isOther ? DIM : (sel ? BOLD : '') out(`${sel ? BLUE : DIM}${cursor} ${box} ${R}${labelColor}${trunc(optsWithOther[i].label, W - 8)}${R}`) if (optsWithOther[i].description && sel) out(` ${DIM}${trunc(optsWithOther[i].description!, 35)}${R}`) r++ } goto(r, 1); clearLine(); out(`${DIM} ↑↓ move · enter select · esc cancel · 1-9 jump${R}`); r++ return r } function drawModalPermission(req: ModalPermission, startRow: number, w: number): number { let r = startRow const opts = buildPermOpts(req) goto(r, 1); clearLine(); out(`${ORANGE}${BOLD} Allow ${req.toolName}?${R} ${DIM}${trunc(req.detail, w - 20)}${R}`); r++ if (req.preview) { for (const l of req.preview.split('\n').slice(0, 6)) { goto(r, 1); clearLine(); out(` ${DIM}${l}${R}`); r++ } } goto(r, 1); clearLine(); r++ for (let i = 0; i < opts.length; i++) { const sel = i === req.idx const col = opts[i].danger ? RED : sel ? GREEN : '' goto(r, 1); clearLine() out(`${sel ? GREEN : DIM}${sel ? '❯' : ' '} ${i + 1}. ${R}${col}${sel ? BOLD : ''}${trunc(opts[i].label, w - 8)}${R}`) if (opts[i].hint) out(` ${DIM}${opts[i].hint}${R}`) r++ } goto(r, 1); clearLine(); out(`${DIM} ↑↓ move · enter select · esc denies · y/n shortcuts${R}`); r++ return r } 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 } // ── Thinking animation helpers ──────────────────────────────────────────────── const THINKING_VERBS = [ 'Galloping', 'Pondering', 'Musing', 'Thinking', 'Brewing', 'Contemplating', 'Brainstorming', 'Conjuring', 'Plotting', 'Scheming', 'Hatching', 'Cooking', 'Sizzling', 'Unraveling', 'Scribbling', 'Deliberating', 'Weaving', 'Ruminating', ] function formatK(n: number): string { if (n < 1000) return String(n) const k = n / 1000 return k >= 10 ? `${k.toFixed(0)}k` : `${k.toFixed(1)}k` } // ── Banner ──────────────────────────────────────────────────────────────────── export function drawBanner(version: string, authStatus: { anthropic: boolean; openai: boolean; google: boolean }, cwd: string, resumedInfo?: { sessionId: string; turns: number }): void { const B1 = '\x1b[38;5;22m', B2 = '\x1b[38;5;28m', B3 = '\x1b[38;5;34m' const B4 = '\x1b[38;5;40m', B5 = '\x1b[38;5;46m' const lines = [ '', `${B1} ███████╗ ██████╗ ██╗ ██╗███████╗███████╗███████╗██████╗${R}`, `${B2} ██╔════╝██╔═══██╗██║ ██║██╔════╝██╔════╝╚══███╔╝██╔══██╗${R}`, `${B3} ███████╗██║ ██║██║ ██║█████╗ █████╗ ███╔╝ ██████╔╝${R}`, `${B4} ╚════██║██║▄▄ ██║██║ ██║██╔══╝ ██╔══╝ ███╔╝ ██╔══██╗${R}`, `${B5} ███████║╚██████╔╝╚██████╔╝███████╗███████╗███████╗██║ ██║${R}`, `${DIM} ╚══════╝ ╚══▀▀═╝ ╚═════╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝${R}`, '', ` ${DIM}squeezr-code v${version} · All for One${R}`, ` ${DIM}auth anthropic ${authStatus.anthropic ? '●' : '○'} openai ${authStatus.openai ? '●' : '○'} google ${authStatus.google ? '●' : '○'}${R}`, ` ${DIM}cwd ${cwd}${R}`, ` ${DIM}tip /help · @archivo para contexto · @modelo para override · Shift+Tab cicla modo${R}`, '', ] if (resumedInfo) { lines.push(` ${DIM}resumed session ${resumedInfo.sessionId.slice(0, 13)} (${resumedInfo.turns} turns)${R}`) lines.push('') } process.stdout.write(lines.join('\r\n') + '\r\n') }