bridgeUI.ts 16 KB

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