/ bridge / bridgeUI.ts
bridgeUI.ts
  1  import chalk from 'chalk'
  2  import { toString as qrToString } from 'qrcode'
  3  import {
  4    BRIDGE_FAILED_INDICATOR,
  5    BRIDGE_READY_INDICATOR,
  6    BRIDGE_SPINNER_FRAMES,
  7  } from '../constants/figures.js'
  8  import { stringWidth } from '../ink/stringWidth.js'
  9  import { logForDebugging } from '../utils/debug.js'
 10  import {
 11    buildActiveFooterText,
 12    buildBridgeConnectUrl,
 13    buildBridgeSessionUrl,
 14    buildIdleFooterText,
 15    FAILED_FOOTER_TEXT,
 16    formatDuration,
 17    type StatusState,
 18    TOOL_DISPLAY_EXPIRY_MS,
 19    timestamp,
 20    truncatePrompt,
 21    wrapWithOsc8Link,
 22  } from './bridgeStatusUtil.js'
 23  import type {
 24    BridgeConfig,
 25    BridgeLogger,
 26    SessionActivity,
 27    SpawnMode,
 28  } from './types.js'
 29  
 30  const QR_OPTIONS = {
 31    type: 'utf8' as const,
 32    errorCorrectionLevel: 'L' as const,
 33    small: true,
 34  }
 35  
 36  /** Generate a QR code and return its lines. */
 37  async function generateQr(url: string): Promise<string[]> {
 38    const qr = await qrToString(url, QR_OPTIONS)
 39    return qr.split('\n').filter((line: string) => line.length > 0)
 40  }
 41  
 42  export function createBridgeLogger(options: {
 43    verbose: boolean
 44    write?: (s: string) => void
 45  }): BridgeLogger {
 46    const write = options.write ?? ((s: string) => process.stdout.write(s))
 47    const verbose = options.verbose
 48  
 49    // Track how many status lines are currently displayed at the bottom
 50    let statusLineCount = 0
 51  
 52    // Status state machine
 53    let currentState: StatusState = 'idle'
 54    let currentStateText = 'Ready'
 55    let repoName = ''
 56    let branch = ''
 57    let debugLogPath = ''
 58  
 59    // Connect URL (built in printBanner with correct base for staging/prod)
 60    let connectUrl = ''
 61    let cachedIngressUrl = ''
 62    let cachedEnvironmentId = ''
 63    let activeSessionUrl: string | null = null
 64  
 65    // QR code lines for the current URL
 66    let qrLines: string[] = []
 67    let qrVisible = false
 68  
 69    // Tool activity for the second status line
 70    let lastToolSummary: string | null = null
 71    let lastToolTime = 0
 72  
 73    // Session count indicator (shown when multi-session mode is enabled)
 74    let sessionActive = 0
 75    let sessionMax = 1
 76    // Spawn mode shown in the session-count line + gates the `w` hint
 77    let spawnModeDisplay: 'same-dir' | 'worktree' | null = null
 78    let spawnMode: SpawnMode = 'single-session'
 79  
 80    // Per-session display info for the multi-session bullet list (keyed by compat sessionId)
 81    const sessionDisplayInfo = new Map<
 82      string,
 83      { title?: string; url: string; activity?: SessionActivity }
 84    >()
 85  
 86    // Connecting spinner state
 87    let connectingTimer: ReturnType<typeof setInterval> | null = null
 88    let connectingTick = 0
 89  
 90    /**
 91     * Count how many visual terminal rows a string occupies, accounting for
 92     * line wrapping. Each `\n` is one row, and content wider than the terminal
 93     * wraps to additional rows.
 94     */
 95    function countVisualLines(text: string): number {
 96      // eslint-disable-next-line custom-rules/prefer-use-terminal-size
 97      const cols = process.stdout.columns || 80 // non-React CLI context
 98      let count = 0
 99      // Split on newlines to get logical lines
100      for (const logical of text.split('\n')) {
101        if (logical.length === 0) {
102          // Empty segment between consecutive \n — counts as 1 row
103          count++
104          continue
105        }
106        const width = stringWidth(logical)
107        count += Math.max(1, Math.ceil(width / cols))
108      }
109      // The trailing \n in "line\n" produces an empty last element — don't count it
110      // because the cursor sits at the start of the next line, not a new visual row.
111      if (text.endsWith('\n')) {
112        count--
113      }
114      return count
115    }
116  
117    /** Write a status line and track its visual line count. */
118    function writeStatus(text: string): void {
119      write(text)
120      statusLineCount += countVisualLines(text)
121    }
122  
123    /** Clear any currently displayed status lines. */
124    function clearStatusLines(): void {
125      if (statusLineCount <= 0) return
126      logForDebugging(`[bridge:ui] clearStatusLines count=${statusLineCount}`)
127      // Move cursor up to the start of the status block, then erase everything below
128      write(`\x1b[${statusLineCount}A`) // cursor up N lines
129      write('\x1b[J') // erase from cursor to end of screen
130      statusLineCount = 0
131    }
132  
133    /** Print a permanent log line, clearing status first and restoring after. */
134    function printLog(line: string): void {
135      clearStatusLines()
136      write(line)
137    }
138  
139    /** Regenerate the QR code with the given URL. */
140    function regenerateQr(url: string): void {
141      generateQr(url)
142        .then(lines => {
143          qrLines = lines
144          renderStatusLine()
145        })
146        .catch(e => {
147          logForDebugging(`QR code generation failed: ${e}`, { level: 'error' })
148        })
149    }
150  
151    /** Render the connecting spinner line (shown before first updateIdleStatus). */
152    function renderConnectingLine(): void {
153      clearStatusLines()
154  
155      const frame =
156        BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]!
157      let suffix = ''
158      if (repoName) {
159        suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
160      }
161      if (branch) {
162        suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
163      }
164      writeStatus(
165        `${chalk.yellow(frame)} ${chalk.yellow('Connecting')}${suffix}\n`,
166      )
167    }
168  
169    /** Start the connecting spinner. Stopped by first updateIdleStatus(). */
170    function startConnecting(): void {
171      stopConnecting()
172      renderConnectingLine()
173      connectingTimer = setInterval(() => {
174        connectingTick++
175        renderConnectingLine()
176      }, 150)
177    }
178  
179    /** Stop the connecting spinner. */
180    function stopConnecting(): void {
181      if (connectingTimer) {
182        clearInterval(connectingTimer)
183        connectingTimer = null
184      }
185    }
186  
187    /** Render and write the current status lines based on state. */
188    function renderStatusLine(): void {
189      if (currentState === 'reconnecting' || currentState === 'failed') {
190        // These states are handled separately (updateReconnectingStatus /
191        // updateFailedStatus). Return before clearing so callers like toggleQr
192        // and setSpawnModeDisplay don't blank the display during these states.
193        return
194      }
195  
196      clearStatusLines()
197  
198      const isIdle = currentState === 'idle'
199  
200      // QR code above the status line
201      if (qrVisible) {
202        for (const line of qrLines) {
203          writeStatus(`${chalk.dim(line)}\n`)
204        }
205      }
206  
207      // Determine indicator and colors based on state
208      const indicator = BRIDGE_READY_INDICATOR
209      const indicatorColor = isIdle ? chalk.green : chalk.cyan
210      const baseColor = isIdle ? chalk.green : chalk.cyan
211      const stateText = baseColor(currentStateText)
212  
213      // Build the suffix with repo and branch
214      let suffix = ''
215      if (repoName) {
216        suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
217      }
218      // In worktree mode each session gets its own branch, so showing the
219      // bridge's branch would be misleading.
220      if (branch && spawnMode !== 'worktree') {
221        suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
222      }
223  
224      if (process.env.USER_TYPE === 'ant' && debugLogPath) {
225        writeStatus(
226          `${chalk.yellow('[ANT-ONLY] Logs:')} ${chalk.dim(debugLogPath)}\n`,
227        )
228      }
229      writeStatus(`${indicatorColor(indicator)} ${stateText}${suffix}\n`)
230  
231      // Session count and per-session list (multi-session mode only)
232      if (sessionMax > 1) {
233        const modeHint =
234          spawnMode === 'worktree'
235            ? 'New sessions will be created in an isolated worktree'
236            : 'New sessions will be created in the current directory'
237        writeStatus(
238          `    ${chalk.dim(`Capacity: ${sessionActive}/${sessionMax} \u00b7 ${modeHint}`)}\n`,
239        )
240        for (const [, info] of sessionDisplayInfo) {
241          const titleText = info.title
242            ? truncatePrompt(info.title, 35)
243            : chalk.dim('Attached')
244          const titleLinked = wrapWithOsc8Link(titleText, info.url)
245          const act = info.activity
246          const showAct = act && act.type !== 'result' && act.type !== 'error'
247          const actText = showAct
248            ? chalk.dim(` ${truncatePrompt(act.summary, 40)}`)
249            : ''
250          writeStatus(`    ${titleLinked}${actText}
251  `)
252        }
253      }
254  
255      // Mode line for spawn modes with a single slot (or true single-session mode)
256      if (sessionMax === 1) {
257        const modeText =
258          spawnMode === 'single-session'
259            ? 'Single session \u00b7 exits when complete'
260            : spawnMode === 'worktree'
261              ? `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in an isolated worktree`
262              : `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in the current directory`
263        writeStatus(`    ${chalk.dim(modeText)}\n`)
264      }
265  
266      // Tool activity line for single-session mode
267      if (
268        sessionMax === 1 &&
269        !isIdle &&
270        lastToolSummary &&
271        Date.now() - lastToolTime < TOOL_DISPLAY_EXPIRY_MS
272      ) {
273        writeStatus(`  ${chalk.dim(truncatePrompt(lastToolSummary, 60))}\n`)
274      }
275  
276      // Blank line separator before footer
277      const url = activeSessionUrl ?? connectUrl
278      if (url) {
279        writeStatus('\n')
280        const footerText = isIdle
281          ? buildIdleFooterText(url)
282          : buildActiveFooterText(url)
283        const qrHint = qrVisible
284          ? chalk.dim.italic('space to hide QR code')
285          : chalk.dim.italic('space to show QR code')
286        const toggleHint = spawnModeDisplay
287          ? chalk.dim.italic(' \u00b7 w to toggle spawn mode')
288          : ''
289        writeStatus(`${chalk.dim(footerText)}\n`)
290        writeStatus(`${qrHint}${toggleHint}\n`)
291      }
292    }
293  
294    return {
295      printBanner(config: BridgeConfig, environmentId: string): void {
296        cachedIngressUrl = config.sessionIngressUrl
297        cachedEnvironmentId = environmentId
298        connectUrl = buildBridgeConnectUrl(environmentId, cachedIngressUrl)
299        regenerateQr(connectUrl)
300  
301        if (verbose) {
302          write(chalk.dim(`Remote Control`) + ` v${MACRO.VERSION}\n`)
303        }
304        if (verbose) {
305          if (config.spawnMode !== 'single-session') {
306            write(chalk.dim(`Spawn mode: `) + `${config.spawnMode}\n`)
307            write(
308              chalk.dim(`Max concurrent sessions: `) + `${config.maxSessions}\n`,
309            )
310          }
311          write(chalk.dim(`Environment ID: `) + `${environmentId}\n`)
312        }
313        if (config.sandbox) {
314          write(chalk.dim(`Sandbox: `) + `${chalk.green('Enabled')}\n`)
315        }
316        write('\n')
317  
318        // Start connecting spinner — first updateIdleStatus() will stop it
319        startConnecting()
320      },
321  
322      logSessionStart(sessionId: string, prompt: string): void {
323        if (verbose) {
324          const short = truncatePrompt(prompt, 80)
325          printLog(
326            chalk.dim(`[${timestamp()}]`) +
327              ` Session started: ${chalk.white(`"${short}"`)} (${chalk.dim(sessionId)})\n`,
328          )
329        }
330      },
331  
332      logSessionComplete(sessionId: string, durationMs: number): void {
333        printLog(
334          chalk.dim(`[${timestamp()}]`) +
335            ` Session ${chalk.green('completed')} (${formatDuration(durationMs)}) ${chalk.dim(sessionId)}\n`,
336        )
337      },
338  
339      logSessionFailed(sessionId: string, error: string): void {
340        printLog(
341          chalk.dim(`[${timestamp()}]`) +
342            ` Session ${chalk.red('failed')}: ${error} ${chalk.dim(sessionId)}\n`,
343        )
344      },
345  
346      logStatus(message: string): void {
347        printLog(chalk.dim(`[${timestamp()}]`) + ` ${message}\n`)
348      },
349  
350      logVerbose(message: string): void {
351        if (verbose) {
352          printLog(chalk.dim(`[${timestamp()}] ${message}`) + '\n')
353        }
354      },
355  
356      logError(message: string): void {
357        printLog(chalk.red(`[${timestamp()}] Error: ${message}`) + '\n')
358      },
359  
360      logReconnected(disconnectedMs: number): void {
361        printLog(
362          chalk.dim(`[${timestamp()}]`) +
363            ` ${chalk.green('Reconnected')} after ${formatDuration(disconnectedMs)}\n`,
364        )
365      },
366  
367      setRepoInfo(repo: string, branchName: string): void {
368        repoName = repo
369        branch = branchName
370      },
371  
372      setDebugLogPath(path: string): void {
373        debugLogPath = path
374      },
375  
376      updateIdleStatus(): void {
377        stopConnecting()
378  
379        currentState = 'idle'
380        currentStateText = 'Ready'
381        lastToolSummary = null
382        lastToolTime = 0
383        activeSessionUrl = null
384        regenerateQr(connectUrl)
385        renderStatusLine()
386      },
387  
388      setAttached(sessionId: string): void {
389        stopConnecting()
390        currentState = 'attached'
391        currentStateText = 'Connected'
392        lastToolSummary = null
393        lastToolTime = 0
394        // Multi-session: keep footer/QR on the environment connect URL so users
395        // can spawn more sessions. Per-session links are in the bullet list.
396        if (sessionMax <= 1) {
397          activeSessionUrl = buildBridgeSessionUrl(
398            sessionId,
399            cachedEnvironmentId,
400            cachedIngressUrl,
401          )
402          regenerateQr(activeSessionUrl)
403        }
404        renderStatusLine()
405      },
406  
407      updateReconnectingStatus(delayStr: string, elapsedStr: string): void {
408        stopConnecting()
409        clearStatusLines()
410        currentState = 'reconnecting'
411  
412        // QR code above the status line
413        if (qrVisible) {
414          for (const line of qrLines) {
415            writeStatus(`${chalk.dim(line)}\n`)
416          }
417        }
418  
419        const frame =
420          BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]!
421        connectingTick++
422        writeStatus(
423          `${chalk.yellow(frame)} ${chalk.yellow('Reconnecting')} ${chalk.dim('\u00b7')} ${chalk.dim(`retrying in ${delayStr}`)} ${chalk.dim('\u00b7')} ${chalk.dim(`disconnected ${elapsedStr}`)}\n`,
424        )
425      },
426  
427      updateFailedStatus(error: string): void {
428        stopConnecting()
429        clearStatusLines()
430        currentState = 'failed'
431  
432        let suffix = ''
433        if (repoName) {
434          suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
435        }
436        if (branch) {
437          suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
438        }
439  
440        writeStatus(
441          `${chalk.red(BRIDGE_FAILED_INDICATOR)} ${chalk.red('Remote Control Failed')}${suffix}\n`,
442        )
443        writeStatus(`${chalk.dim(FAILED_FOOTER_TEXT)}\n`)
444  
445        if (error) {
446          writeStatus(`${chalk.red(error)}\n`)
447        }
448      },
449  
450      updateSessionStatus(
451        _sessionId: string,
452        _elapsed: string,
453        activity: SessionActivity,
454        _trail: string[],
455      ): void {
456        // Cache tool activity for the second status line
457        if (activity.type === 'tool_start') {
458          lastToolSummary = activity.summary
459          lastToolTime = Date.now()
460        }
461        renderStatusLine()
462      },
463  
464      clearStatus(): void {
465        stopConnecting()
466        clearStatusLines()
467      },
468  
469      toggleQr(): void {
470        qrVisible = !qrVisible
471        renderStatusLine()
472      },
473  
474      updateSessionCount(active: number, max: number, mode: SpawnMode): void {
475        if (sessionActive === active && sessionMax === max && spawnMode === mode)
476          return
477        sessionActive = active
478        sessionMax = max
479        spawnMode = mode
480        // Don't re-render here — the status ticker calls renderStatusLine
481        // on its own cadence, and the next tick will pick up the new values.
482      },
483  
484      setSpawnModeDisplay(mode: 'same-dir' | 'worktree' | null): void {
485        if (spawnModeDisplay === mode) return
486        spawnModeDisplay = mode
487        // Also sync the #21118-added spawnMode so the next render shows correct
488        // mode hint + branch visibility. Don't render here — matches
489        // updateSessionCount: called before printBanner (initial setup) and
490        // again from the `w` handler (which follows with refreshDisplay).
491        if (mode) spawnMode = mode
492      },
493  
494      addSession(sessionId: string, url: string): void {
495        sessionDisplayInfo.set(sessionId, { url })
496      },
497  
498      updateSessionActivity(sessionId: string, activity: SessionActivity): void {
499        const info = sessionDisplayInfo.get(sessionId)
500        if (!info) return
501        info.activity = activity
502      },
503  
504      setSessionTitle(sessionId: string, title: string): void {
505        const info = sessionDisplayInfo.get(sessionId)
506        if (!info) return
507        info.title = title
508        // Guard against reconnecting/failed — renderStatusLine clears then returns
509        // early for those states, which would erase the spinner/error.
510        if (currentState === 'reconnecting' || currentState === 'failed') return
511        if (sessionMax === 1) {
512          // Single-session: show title in the main status line too.
513          currentState = 'titled'
514          currentStateText = truncatePrompt(title, 40)
515        }
516        renderStatusLine()
517      },
518  
519      removeSession(sessionId: string): void {
520        sessionDisplayInfo.delete(sessionId)
521      },
522  
523      refreshDisplay(): void {
524        // Skip during reconnecting/failed — renderStatusLine clears then returns
525        // early for those states, which would erase the spinner/error.
526        if (currentState === 'reconnecting' || currentState === 'failed') return
527        renderStatusLine()
528      },
529    }
530  }