This pass rewrites a small set of ant-only diagnostic and UI labels to neutral internal wording while leaving command definitions, flags, and runtime logic untouched. It focuses on internal debug output, dead UI branches, and noninteractive headings rather than broader product text. Constraint: Label cleanup only; do not change command semantics or ant-only logic gates Rejected: Renaming ant-only command descriptions in main.tsx | broader UX surface better handled in a separate reviewed pass Confidence: high Scope-risk: narrow Reversibility: clean Directive: Remaining ANT-ONLY hits are mostly command descriptions and intentionally deferred user-facing strings Tested: bun run build Tested: bun run smoke Tested: bun run verify:privacy Tested: bun run test:provider Tested: bun run test:provider-recommendation Not-tested: Full repo typecheck (upstream baseline remains noisy) Co-authored-by: anandh8x <test@example.com>
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('[internal] 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()
|
|
},
|
|
}
|
|
}
|