| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530 |
- 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<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
- // 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<typeof setInterval> | 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()
- },
- }
- }
|