import chalk from 'chalk' import { toString as qrToString } from 'qrcode' import { BRIDGE_FAILED_INDICATOR, BRIDGE_READY_INDICATOR, BRIDGE_SPINNER_FRAMES, } from '../constants/figures.js' import { stringWidth } from '../ink/stringWidth.js' import { logForDebugging } from '../utils/debug.js' import { buildActiveFooterText, buildBridgeConnectUrl, buildBridgeSessionUrl, buildIdleFooterText, FAILED_FOOTER_TEXT, formatDuration, type StatusState, TOOL_DISPLAY_EXPIRY_MS, timestamp, truncatePrompt, wrapWithOsc8Link, } from './bridgeStatusUtil.js' import type { BridgeConfig, BridgeLogger, SessionActivity, SpawnMode, } from './types.js' const QR_OPTIONS = { type: 'utf8' as const, errorCorrectionLevel: 'L' as const, small: true, } /** Generate a QR code and return its lines. */ async function generateQr(url: string): Promise { const qr = await qrToString(url, QR_OPTIONS) return qr.split('\n').filter((line: string) => line.length > 0) } export function createBridgeLogger(options: { verbose: boolean write?: (s: string) => void }): BridgeLogger { const write = options.write ?? ((s: string) => process.stdout.write(s)) const verbose = options.verbose // Track how many status lines are currently displayed at the bottom let statusLineCount = 0 // Status state machine let currentState: StatusState = 'idle' let currentStateText = 'Ready' let repoName = '' let branch = '' let debugLogPath = '' // Connect URL (built in printBanner with correct base for staging/prod) let connectUrl = '' let cachedIngressUrl = '' let cachedEnvironmentId = '' let activeSessionUrl: string | null = null // QR code lines for the current URL let qrLines: string[] = [] let qrVisible = false // Tool activity for the second status line let lastToolSummary: string | null = null let lastToolTime = 0 // Session count indicator (shown when multi-session mode is enabled) let sessionActive = 0 let sessionMax = 1 // Spawn mode shown in the session-count line + gates the `w` hint let spawnModeDisplay: 'same-dir' | 'worktree' | null = null let spawnMode: SpawnMode = 'single-session' // Per-session display info for the multi-session bullet list (keyed by compat sessionId) const sessionDisplayInfo = new Map< string, { title?: string; url: string; activity?: SessionActivity } >() // Connecting spinner state let connectingTimer: ReturnType | null = null let connectingTick = 0 /** * Count how many visual terminal rows a string occupies, accounting for * line wrapping. Each `\n` is one row, and content wider than the terminal * wraps to additional rows. */ function countVisualLines(text: string): number { // eslint-disable-next-line custom-rules/prefer-use-terminal-size const cols = process.stdout.columns || 80 // non-React CLI context let count = 0 // Split on newlines to get logical lines for (const logical of text.split('\n')) { if (logical.length === 0) { // Empty segment between consecutive \n — counts as 1 row count++ continue } const width = stringWidth(logical) count += Math.max(1, Math.ceil(width / cols)) } // The trailing \n in "line\n" produces an empty last element — don't count it // because the cursor sits at the start of the next line, not a new visual row. if (text.endsWith('\n')) { count-- } return count } /** Write a status line and track its visual line count. */ function writeStatus(text: string): void { write(text) statusLineCount += countVisualLines(text) } /** Clear any currently displayed status lines. */ function clearStatusLines(): void { if (statusLineCount <= 0) return logForDebugging(`[bridge:ui] clearStatusLines count=${statusLineCount}`) // Move cursor up to the start of the status block, then erase everything below write(`\x1b[${statusLineCount}A`) // cursor up N lines write('\x1b[J') // erase from cursor to end of screen statusLineCount = 0 } /** Print a permanent log line, clearing status first and restoring after. */ function printLog(line: string): void { clearStatusLines() write(line) } /** Regenerate the QR code with the given URL. */ function regenerateQr(url: string): void { generateQr(url) .then(lines => { qrLines = lines renderStatusLine() }) .catch(e => { logForDebugging(`QR code generation failed: ${e}`, { level: 'error' }) }) } /** Render the connecting spinner line (shown before first updateIdleStatus). */ function renderConnectingLine(): void { clearStatusLines() const frame = BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]! let suffix = '' if (repoName) { suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName) } if (branch) { suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch) } writeStatus( `${chalk.yellow(frame)} ${chalk.yellow('Connecting')}${suffix}\n`, ) } /** Start the connecting spinner. Stopped by first updateIdleStatus(). */ function startConnecting(): void { stopConnecting() renderConnectingLine() connectingTimer = setInterval(() => { connectingTick++ renderConnectingLine() }, 150) } /** Stop the connecting spinner. */ function stopConnecting(): void { if (connectingTimer) { clearInterval(connectingTimer) connectingTimer = null } } /** Render and write the current status lines based on state. */ function renderStatusLine(): void { if (currentState === 'reconnecting' || currentState === 'failed') { // These states are handled separately (updateReconnectingStatus / // updateFailedStatus). Return before clearing so callers like toggleQr // and setSpawnModeDisplay don't blank the display during these states. return } clearStatusLines() const isIdle = currentState === 'idle' // QR code above the status line if (qrVisible) { for (const line of qrLines) { writeStatus(`${chalk.dim(line)}\n`) } } // Determine indicator and colors based on state const indicator = BRIDGE_READY_INDICATOR const indicatorColor = isIdle ? chalk.green : chalk.cyan const baseColor = isIdle ? chalk.green : chalk.cyan const stateText = baseColor(currentStateText) // Build the suffix with repo and branch let suffix = '' if (repoName) { suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName) } // In worktree mode each session gets its own branch, so showing the // bridge's branch would be misleading. if (branch && spawnMode !== 'worktree') { suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch) } if (process.env.USER_TYPE === 'ant' && debugLogPath) { writeStatus( `${chalk.yellow('[ANT-ONLY] Logs:')} ${chalk.dim(debugLogPath)}\n`, ) } writeStatus(`${indicatorColor(indicator)} ${stateText}${suffix}\n`) // Session count and per-session list (multi-session mode only) if (sessionMax > 1) { const modeHint = spawnMode === 'worktree' ? 'New sessions will be created in an isolated worktree' : 'New sessions will be created in the current directory' writeStatus( ` ${chalk.dim(`Capacity: ${sessionActive}/${sessionMax} \u00b7 ${modeHint}`)}\n`, ) for (const [, info] of sessionDisplayInfo) { const titleText = info.title ? truncatePrompt(info.title, 35) : chalk.dim('Attached') const titleLinked = wrapWithOsc8Link(titleText, info.url) const act = info.activity const showAct = act && act.type !== 'result' && act.type !== 'error' const actText = showAct ? chalk.dim(` ${truncatePrompt(act.summary, 40)}`) : '' writeStatus(` ${titleLinked}${actText} `) } } // Mode line for spawn modes with a single slot (or true single-session mode) if (sessionMax === 1) { const modeText = spawnMode === 'single-session' ? 'Single session \u00b7 exits when complete' : spawnMode === 'worktree' ? `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in an isolated worktree` : `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in the current directory` writeStatus(` ${chalk.dim(modeText)}\n`) } // Tool activity line for single-session mode if ( sessionMax === 1 && !isIdle && lastToolSummary && Date.now() - lastToolTime < TOOL_DISPLAY_EXPIRY_MS ) { writeStatus(` ${chalk.dim(truncatePrompt(lastToolSummary, 60))}\n`) } // Blank line separator before footer const url = activeSessionUrl ?? connectUrl if (url) { writeStatus('\n') const footerText = isIdle ? buildIdleFooterText(url) : buildActiveFooterText(url) const qrHint = qrVisible ? chalk.dim.italic('space to hide QR code') : chalk.dim.italic('space to show QR code') const toggleHint = spawnModeDisplay ? chalk.dim.italic(' \u00b7 w to toggle spawn mode') : '' writeStatus(`${chalk.dim(footerText)}\n`) writeStatus(`${qrHint}${toggleHint}\n`) } } return { printBanner(config: BridgeConfig, environmentId: string): void { cachedIngressUrl = config.sessionIngressUrl cachedEnvironmentId = environmentId connectUrl = buildBridgeConnectUrl(environmentId, cachedIngressUrl) regenerateQr(connectUrl) if (verbose) { write(chalk.dim(`Remote Control`) + ` v${MACRO.VERSION}\n`) } if (verbose) { if (config.spawnMode !== 'single-session') { write(chalk.dim(`Spawn mode: `) + `${config.spawnMode}\n`) write( chalk.dim(`Max concurrent sessions: `) + `${config.maxSessions}\n`, ) } write(chalk.dim(`Environment ID: `) + `${environmentId}\n`) } if (config.sandbox) { write(chalk.dim(`Sandbox: `) + `${chalk.green('Enabled')}\n`) } write('\n') // Start connecting spinner — first updateIdleStatus() will stop it startConnecting() }, logSessionStart(sessionId: string, prompt: string): void { if (verbose) { const short = truncatePrompt(prompt, 80) printLog( chalk.dim(`[${timestamp()}]`) + ` Session started: ${chalk.white(`"${short}"`)} (${chalk.dim(sessionId)})\n`, ) } }, logSessionComplete(sessionId: string, durationMs: number): void { printLog( chalk.dim(`[${timestamp()}]`) + ` Session ${chalk.green('completed')} (${formatDuration(durationMs)}) ${chalk.dim(sessionId)}\n`, ) }, logSessionFailed(sessionId: string, error: string): void { printLog( chalk.dim(`[${timestamp()}]`) + ` Session ${chalk.red('failed')}: ${error} ${chalk.dim(sessionId)}\n`, ) }, logStatus(message: string): void { printLog(chalk.dim(`[${timestamp()}]`) + ` ${message}\n`) }, logVerbose(message: string): void { if (verbose) { printLog(chalk.dim(`[${timestamp()}] ${message}`) + '\n') } }, logError(message: string): void { printLog(chalk.red(`[${timestamp()}] Error: ${message}`) + '\n') }, logReconnected(disconnectedMs: number): void { printLog( chalk.dim(`[${timestamp()}]`) + ` ${chalk.green('Reconnected')} after ${formatDuration(disconnectedMs)}\n`, ) }, setRepoInfo(repo: string, branchName: string): void { repoName = repo branch = branchName }, setDebugLogPath(path: string): void { debugLogPath = path }, updateIdleStatus(): void { stopConnecting() currentState = 'idle' currentStateText = 'Ready' lastToolSummary = null lastToolTime = 0 activeSessionUrl = null regenerateQr(connectUrl) renderStatusLine() }, setAttached(sessionId: string): void { stopConnecting() currentState = 'attached' currentStateText = 'Connected' lastToolSummary = null lastToolTime = 0 // Multi-session: keep footer/QR on the environment connect URL so users // can spawn more sessions. Per-session links are in the bullet list. if (sessionMax <= 1) { activeSessionUrl = buildBridgeSessionUrl( sessionId, cachedEnvironmentId, cachedIngressUrl, ) regenerateQr(activeSessionUrl) } renderStatusLine() }, updateReconnectingStatus(delayStr: string, elapsedStr: string): void { stopConnecting() clearStatusLines() currentState = 'reconnecting' // QR code above the status line if (qrVisible) { for (const line of qrLines) { writeStatus(`${chalk.dim(line)}\n`) } } const frame = BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]! connectingTick++ writeStatus( `${chalk.yellow(frame)} ${chalk.yellow('Reconnecting')} ${chalk.dim('\u00b7')} ${chalk.dim(`retrying in ${delayStr}`)} ${chalk.dim('\u00b7')} ${chalk.dim(`disconnected ${elapsedStr}`)}\n`, ) }, updateFailedStatus(error: string): void { stopConnecting() clearStatusLines() currentState = 'failed' let suffix = '' if (repoName) { suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName) } if (branch) { suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch) } writeStatus( `${chalk.red(BRIDGE_FAILED_INDICATOR)} ${chalk.red('Remote Control Failed')}${suffix}\n`, ) writeStatus(`${chalk.dim(FAILED_FOOTER_TEXT)}\n`) if (error) { writeStatus(`${chalk.red(error)}\n`) } }, updateSessionStatus( _sessionId: string, _elapsed: string, activity: SessionActivity, _trail: string[], ): void { // Cache tool activity for the second status line if (activity.type === 'tool_start') { lastToolSummary = activity.summary lastToolTime = Date.now() } renderStatusLine() }, clearStatus(): void { stopConnecting() clearStatusLines() }, toggleQr(): void { qrVisible = !qrVisible renderStatusLine() }, updateSessionCount(active: number, max: number, mode: SpawnMode): void { if (sessionActive === active && sessionMax === max && spawnMode === mode) return sessionActive = active sessionMax = max spawnMode = mode // Don't re-render here — the status ticker calls renderStatusLine // on its own cadence, and the next tick will pick up the new values. }, setSpawnModeDisplay(mode: 'same-dir' | 'worktree' | null): void { if (spawnModeDisplay === mode) return spawnModeDisplay = mode // Also sync the #21118-added spawnMode so the next render shows correct // mode hint + branch visibility. Don't render here — matches // updateSessionCount: called before printBanner (initial setup) and // again from the `w` handler (which follows with refreshDisplay). if (mode) spawnMode = mode }, addSession(sessionId: string, url: string): void { sessionDisplayInfo.set(sessionId, { url }) }, updateSessionActivity(sessionId: string, activity: SessionActivity): void { const info = sessionDisplayInfo.get(sessionId) if (!info) return info.activity = activity }, setSessionTitle(sessionId: string, title: string): void { const info = sessionDisplayInfo.get(sessionId) if (!info) return info.title = title // Guard against reconnecting/failed — renderStatusLine clears then returns // early for those states, which would erase the spinner/error. if (currentState === 'reconnecting' || currentState === 'failed') return if (sessionMax === 1) { // Single-session: show title in the main status line too. currentState = 'titled' currentStateText = truncatePrompt(title, 40) } renderStatusLine() }, removeSession(sessionId: string): void { sessionDisplayInfo.delete(sessionId) }, refreshDisplay(): void { // Skip during reconnecting/failed — renderStatusLine clears then returns // early for those states, which would erase the spinner/error. if (currentState === 'reconnecting' || currentState === 'failed') return renderStatusLine() }, } }