Squash the imported source snapshot and follow-up documentation history into one root commit so the archive starts from a single coherent state. Constraint: Repository intentionally tracks an analyzed Claude Code source snapshot Constraint: Author and committer must be instructkr <no-contact@instruct.kr> Rejected: Preserve the four-step import/docs history | user explicitly requested one squashed commit Confidence: high Scope-risk: broad Reversibility: clean Directive: Keep future analysis and refactor commits separate from this archive baseline Tested: git status clean; local history rewritten to one commit; force-pushed main to origin and instructkr Not-tested: Fresh clone verification after push
531 lines
16 KiB
TypeScript
531 lines
16 KiB
TypeScript
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()
|
|
},
|
|
}
|
|
}
|