source dump of claude code
at main 530 lines 17 kB view raw
1import chalk from 'chalk' 2import { toString as qrToString } from 'qrcode' 3import { 4 BRIDGE_FAILED_INDICATOR, 5 BRIDGE_READY_INDICATOR, 6 BRIDGE_SPINNER_FRAMES, 7} from '../constants/figures.js' 8import { stringWidth } from '../ink/stringWidth.js' 9import { logForDebugging } from '../utils/debug.js' 10import { 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' 23import type { 24 BridgeConfig, 25 BridgeLogger, 26 SessionActivity, 27 SpawnMode, 28} from './types.js' 29 30const 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. */ 37async 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 42export 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}