/ bridge / bridgeStatusUtil.ts
bridgeStatusUtil.ts
  1  import {
  2    getClaudeAiBaseUrl,
  3    getRemoteSessionUrl,
  4  } from '../constants/product.js'
  5  import { stringWidth } from '../ink/stringWidth.js'
  6  import { formatDuration, truncateToWidth } from '../utils/format.js'
  7  import { getGraphemeSegmenter } from '../utils/intl.js'
  8  
  9  /** Bridge status state machine states. */
 10  export type StatusState =
 11    | 'idle'
 12    | 'attached'
 13    | 'titled'
 14    | 'reconnecting'
 15    | 'failed'
 16  
 17  /** How long a tool activity line stays visible after last tool_start (ms). */
 18  export const TOOL_DISPLAY_EXPIRY_MS = 30_000
 19  
 20  /** Interval for the shimmer animation tick (ms). */
 21  export const SHIMMER_INTERVAL_MS = 150
 22  
 23  export function timestamp(): string {
 24    const now = new Date()
 25    const h = String(now.getHours()).padStart(2, '0')
 26    const m = String(now.getMinutes()).padStart(2, '0')
 27    const s = String(now.getSeconds()).padStart(2, '0')
 28    return `${h}:${m}:${s}`
 29  }
 30  
 31  export { formatDuration, truncateToWidth as truncatePrompt }
 32  
 33  /** Abbreviate a tool activity summary for the trail display. */
 34  export function abbreviateActivity(summary: string): string {
 35    return truncateToWidth(summary, 30)
 36  }
 37  
 38  /** Build the connect URL shown when the bridge is idle. */
 39  export function buildBridgeConnectUrl(
 40    environmentId: string,
 41    ingressUrl?: string,
 42  ): string {
 43    const baseUrl = getClaudeAiBaseUrl(undefined, ingressUrl)
 44    return `${baseUrl}/code?bridge=${environmentId}`
 45  }
 46  
 47  /**
 48   * Build the session URL shown when a session is attached. Delegates to
 49   * getRemoteSessionUrl for the cse_→session_ prefix translation, then appends
 50   * the v1-specific ?bridge={environmentId} query.
 51   */
 52  export function buildBridgeSessionUrl(
 53    sessionId: string,
 54    environmentId: string,
 55    ingressUrl?: string,
 56  ): string {
 57    return `${getRemoteSessionUrl(sessionId, ingressUrl)}?bridge=${environmentId}`
 58  }
 59  
 60  /** Compute the glimmer index for a reverse-sweep shimmer animation. */
 61  export function computeGlimmerIndex(
 62    tick: number,
 63    messageWidth: number,
 64  ): number {
 65    const cycleLength = messageWidth + 20
 66    return messageWidth + 10 - (tick % cycleLength)
 67  }
 68  
 69  /**
 70   * Split text into three segments by visual column position for shimmer rendering.
 71   *
 72   * Uses grapheme segmentation and `stringWidth` so the split is correct for
 73   * multi-byte characters, emoji, and CJK glyphs.
 74   *
 75   * Returns `{ before, shimmer, after }` strings. Both renderers (chalk in
 76   * bridgeUI.ts and React/Ink in bridge.tsx) apply their own coloring to
 77   * these segments.
 78   */
 79  export function computeShimmerSegments(
 80    text: string,
 81    glimmerIndex: number,
 82  ): { before: string; shimmer: string; after: string } {
 83    const messageWidth = stringWidth(text)
 84    const shimmerStart = glimmerIndex - 1
 85    const shimmerEnd = glimmerIndex + 1
 86  
 87    // When shimmer is offscreen, return all text as "before"
 88    if (shimmerStart >= messageWidth || shimmerEnd < 0) {
 89      return { before: text, shimmer: '', after: '' }
 90    }
 91  
 92    // Split into at most 3 segments by visual column position
 93    const clampedStart = Math.max(0, shimmerStart)
 94    let colPos = 0
 95    let before = ''
 96    let shimmer = ''
 97    let after = ''
 98    for (const { segment } of getGraphemeSegmenter().segment(text)) {
 99      const segWidth = stringWidth(segment)
100      if (colPos + segWidth <= clampedStart) {
101        before += segment
102      } else if (colPos > shimmerEnd) {
103        after += segment
104      } else {
105        shimmer += segment
106      }
107      colPos += segWidth
108    }
109  
110    return { before, shimmer, after }
111  }
112  
113  /** Computed bridge status label and color from connection state. */
114  export type BridgeStatusInfo = {
115    label:
116      | 'Remote Control failed'
117      | 'Remote Control reconnecting'
118      | 'Remote Control active'
119      | 'Remote Control connecting\u2026'
120    color: 'error' | 'warning' | 'success'
121  }
122  
123  /** Derive a status label and color from the bridge connection state. */
124  export function getBridgeStatus({
125    error,
126    connected,
127    sessionActive,
128    reconnecting,
129  }: {
130    error: string | undefined
131    connected: boolean
132    sessionActive: boolean
133    reconnecting: boolean
134  }): BridgeStatusInfo {
135    if (error) return { label: 'Remote Control failed', color: 'error' }
136    if (reconnecting)
137      return { label: 'Remote Control reconnecting', color: 'warning' }
138    if (sessionActive || connected)
139      return { label: 'Remote Control active', color: 'success' }
140    return { label: 'Remote Control connecting\u2026', color: 'warning' }
141  }
142  
143  /** Footer text shown when bridge is idle (Ready state). */
144  export function buildIdleFooterText(url: string): string {
145    return `Code everywhere with the Claude app or ${url}`
146  }
147  
148  /** Footer text shown when a session is active (Connected state). */
149  export function buildActiveFooterText(url: string): string {
150    return `Continue coding in the Claude app or ${url}`
151  }
152  
153  /** Footer text shown when the bridge has failed. */
154  export const FAILED_FOOTER_TEXT = 'Something went wrong, please try again'
155  
156  /**
157   * Wrap text in an OSC 8 terminal hyperlink. Zero visual width for layout purposes.
158   * strip-ansi (used by stringWidth) correctly strips these sequences, so
159   * countVisualLines in bridgeUI.ts remains accurate.
160   */
161  export function wrapWithOsc8Link(text: string, url: string): string {
162    return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
163  }