Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
17 kB · 530 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531import 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() }, }}