| 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, |
| } |
|
|
| |
| async function generateQr(url: string): Promise<string[]> { |
| 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 |
|
|
| |
| let statusLineCount = 0 |
|
|
| |
| let currentState: StatusState = 'idle' |
| let currentStateText = 'Ready' |
| let repoName = '' |
| let branch = '' |
| let debugLogPath = '' |
|
|
| |
| let connectUrl = '' |
| let cachedIngressUrl = '' |
| let cachedEnvironmentId = '' |
| let activeSessionUrl: string | null = null |
|
|
| |
| let qrLines: string[] = [] |
| let qrVisible = false |
|
|
| |
| let lastToolSummary: string | null = null |
| let lastToolTime = 0 |
|
|
| |
| let sessionActive = 0 |
| let sessionMax = 1 |
| |
| let spawnModeDisplay: 'same-dir' | 'worktree' | null = null |
| let spawnMode: SpawnMode = 'single-session' |
|
|
| |
| const sessionDisplayInfo = new Map< |
| string, |
| { title?: string; url: string; activity?: SessionActivity } |
| >() |
|
|
| |
| let connectingTimer: ReturnType<typeof setInterval> | null = null |
| let connectingTick = 0 |
|
|
| |
| |
| |
| |
| |
| function countVisualLines(text: string): number { |
| |
| const cols = process.stdout.columns || 80 |
| let count = 0 |
| |
| for (const logical of text.split('\n')) { |
| if (logical.length === 0) { |
| |
| count++ |
| continue |
| } |
| const width = stringWidth(logical) |
| count += Math.max(1, Math.ceil(width / cols)) |
| } |
| |
| |
| if (text.endsWith('\n')) { |
| count-- |
| } |
| return count |
| } |
|
|
| |
| function writeStatus(text: string): void { |
| write(text) |
| statusLineCount += countVisualLines(text) |
| } |
|
|
| |
| function clearStatusLines(): void { |
| if (statusLineCount <= 0) return |
| logForDebugging(`[bridge:ui] clearStatusLines count=${statusLineCount}`) |
| |
| write(`\x1b[${statusLineCount}A`) |
| write('\x1b[J') |
| statusLineCount = 0 |
| } |
|
|
| |
| function printLog(line: string): void { |
| clearStatusLines() |
| write(line) |
| } |
|
|
| |
| function regenerateQr(url: string): void { |
| generateQr(url) |
| .then(lines => { |
| qrLines = lines |
| renderStatusLine() |
| }) |
| .catch(e => { |
| logForDebugging(`QR code generation failed: ${e}`, { level: 'error' }) |
| }) |
| } |
|
|
| |
| 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`, |
| ) |
| } |
|
|
| |
| function startConnecting(): void { |
| stopConnecting() |
| renderConnectingLine() |
| connectingTimer = setInterval(() => { |
| connectingTick++ |
| renderConnectingLine() |
| }, 150) |
| } |
|
|
| |
| function stopConnecting(): void { |
| if (connectingTimer) { |
| clearInterval(connectingTimer) |
| connectingTimer = null |
| } |
| } |
|
|
| |
| function renderStatusLine(): void { |
| if (currentState === 'reconnecting' || currentState === 'failed') { |
| |
| |
| |
| return |
| } |
|
|
| clearStatusLines() |
|
|
| const isIdle = currentState === 'idle' |
|
|
| |
| if (qrVisible) { |
| for (const line of qrLines) { |
| writeStatus(`${chalk.dim(line)}\n`) |
| } |
| } |
|
|
| |
| const indicator = BRIDGE_READY_INDICATOR |
| const indicatorColor = isIdle ? chalk.green : chalk.cyan |
| const baseColor = isIdle ? chalk.green : chalk.cyan |
| const stateText = baseColor(currentStateText) |
|
|
| |
| let suffix = '' |
| if (repoName) { |
| suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName) |
| } |
| |
| |
| 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`) |
|
|
| |
| 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} |
| `) |
| } |
| } |
|
|
| |
| 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`) |
| } |
|
|
| |
| if ( |
| sessionMax === 1 && |
| !isIdle && |
| lastToolSummary && |
| Date.now() - lastToolTime < TOOL_DISPLAY_EXPIRY_MS |
| ) { |
| writeStatus(` ${chalk.dim(truncatePrompt(lastToolSummary, 60))}\n`) |
| } |
|
|
| |
| 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') |
|
|
| |
| 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 |
| |
| |
| if (sessionMax <= 1) { |
| activeSessionUrl = buildBridgeSessionUrl( |
| sessionId, |
| cachedEnvironmentId, |
| cachedIngressUrl, |
| ) |
| regenerateQr(activeSessionUrl) |
| } |
| renderStatusLine() |
| }, |
|
|
| updateReconnectingStatus(delayStr: string, elapsedStr: string): void { |
| stopConnecting() |
| clearStatusLines() |
| currentState = 'reconnecting' |
|
|
| |
| 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 { |
| |
| 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 |
| |
| |
| }, |
|
|
| setSpawnModeDisplay(mode: 'same-dir' | 'worktree' | null): void { |
| if (spawnModeDisplay === mode) return |
| spawnModeDisplay = mode |
| |
| |
| |
| |
| 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 |
| |
| |
| if (currentState === 'reconnecting' || currentState === 'failed') return |
| if (sessionMax === 1) { |
| |
| currentState = 'titled' |
| currentStateText = truncatePrompt(title, 40) |
| } |
| renderStatusLine() |
| }, |
|
|
| removeSession(sessionId: string): void { |
| sessionDisplayInfo.delete(sessionId) |
| }, |
|
|
| refreshDisplay(): void { |
| |
| |
| if (currentState === 'reconnecting' || currentState === 'failed') return |
| renderStatusLine() |
| }, |
| } |
| } |
|
|