Skip to main content

ai_agent/bridge/
bridge_status_util.rs

1//! Bridge status utilities.
2//!
3//! Translated from openclaudecode/src/bridge/bridgeStatusUtil.ts
4
5use std::time::{SystemTime, UNIX_EPOCH};
6
7/// Bridge status state machine states.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9#[repr(C)]
10pub enum StatusState {
11    Idle,
12    Attached,
13    Titled,
14    Reconnecting,
15    Failed,
16}
17
18impl StatusState {
19    pub fn as_str(&self) -> &'static str {
20        match self {
21            StatusState::Idle => "idle",
22            StatusState::Attached => "attached",
23            StatusState::Titled => "titled",
24            StatusState::Reconnecting => "reconnecting",
25            StatusState::Failed => "failed",
26        }
27    }
28}
29
30/// How long a tool activity line stays visible after last tool_start (ms).
31pub const TOOL_DISPLAY_EXPIRY_MS: u64 = 30_000;
32
33/// Interval for the shimmer animation tick (ms).
34pub const SHIMMER_INTERVAL_MS: u64 = 150;
35
36/// Get current timestamp as HH:MM:SS
37pub fn timestamp() -> String {
38    let now = SystemTime::now()
39        .duration_since(UNIX_EPOCH)
40        .unwrap()
41        .as_secs();
42    let h = (now / 3600) % 24;
43    let m = (now / 60) % 60;
44    let s = now % 60;
45    format!("{:02}:{:02}:{:02}", h, m, s)
46}
47
48/// Format duration in human-readable form
49pub fn format_duration(ms: u64) -> String {
50    let seconds = ms / 1000;
51    let minutes = seconds / 60;
52    let hours = minutes / 60;
53
54    if hours > 0 {
55        format!("{}h {}m", hours, minutes % 60)
56    } else if minutes > 0 {
57        format!("{}m {}s", minutes, seconds % 60)
58    } else {
59        format!("{}s", seconds)
60    }
61}
62
63/// Truncate text to a specific visual width
64pub fn truncate_to_width(text: &str, max_width: usize) -> String {
65    // Simplified: just use character count
66    if text.len() <= max_width {
67        text.to_string()
68    } else {
69        format!("{}...", &text[..max_width.saturating_sub(3)])
70    }
71}
72
73/// Abbreviate a tool activity summary for the trail display.
74pub fn abbreviate_activity(summary: &str) -> String {
75    truncate_to_width(summary, 30)
76}
77
78/// Build the connect URL shown when the bridge is idle.
79pub fn build_bridge_connect_url(environment_id: &str, ingress_url: Option<&str>) -> String {
80    let base_url = get_claude_ai_base_url(None, ingress_url);
81    format!("{}/code?bridge={}", base_url, environment_id)
82}
83
84/// Get Claude AI base URL
85fn get_claude_ai_base_url(_env: Option<&str>, ingress_url: Option<&str>) -> String {
86    ingress_url
87        .map(|s| s.to_string())
88        .unwrap_or_else(|| "https://claude.ai".to_string())
89}
90
91/// Build the session URL shown when a session is attached.
92/// Appends the v1-specific ?bridge={environmentId} query.
93pub fn build_bridge_session_url(
94    session_id: &str,
95    environment_id: &str,
96    ingress_url: Option<&str>,
97) -> String {
98    let base = get_remote_session_url(session_id, ingress_url);
99    format!("{}?bridge={}", base, environment_id)
100}
101
102/// Get remote session URL
103fn get_remote_session_url(session_id: &str, ingress_url: Option<&str>) -> String {
104    let base_url = get_claude_ai_base_url(None, ingress_url);
105    // Convert cse_ prefix to session_ for compat gateway
106    let compat_id = session_id.replace("cse_", "session_");
107    format!("{}/code/{}", base_url, compat_id)
108}
109
110/// Compute the glimmer index for a reverse-sweep shimmer animation.
111pub fn compute_glimmer_index(tick: u64, message_width: u64) -> u64 {
112    let cycle_length = message_width + 20;
113    message_width + 10 - (tick % cycle_length)
114}
115
116/// Split text into three segments by visual column position for shimmer rendering.
117///
118/// Uses grapheme segmentation and string width so the split is correct for
119/// multi-byte characters, emoji, and CJK glyphs.
120///
121/// Returns (before, shimmer, after) strings. Both renderers (chalk in
122/// bridgeUI.ts and React/Ink in bridge.tsx) apply their own coloring to
123/// these segments.
124pub fn compute_shimmer_segments(text: &str, glimmer_index: u64) -> (String, String, String) {
125    let message_width = string_width(text) as u64;
126    let shimmer_start = glimmer_index as i64 - 1;
127    let shimmer_end = glimmer_index as i64 + 1;
128
129    // When shimmer is offscreen, return all text as "before"
130    if shimmer_start >= message_width as i64 || shimmer_end < 0 {
131        return (text.to_string(), String::new(), String::new());
132    }
133
134    // Split into at most 3 segments by visual column position
135    let clamped_start = shimmer_start.max(0) as usize;
136    let mut col_pos = 0usize;
137    let mut before = String::new();
138    let mut shimmer = String::new();
139    let mut after = String::new();
140
141    // Simplified: just use character iteration
142    for c in text.chars() {
143        let seg_width = string_width(&c.to_string()) as usize;
144        if col_pos + seg_width <= clamped_start {
145            before.push(c);
146        } else if col_pos > shimmer_end as usize {
147            after.push(c);
148        } else {
149            shimmer.push(c);
150        }
151        col_pos += seg_width;
152    }
153
154    (before, shimmer, after)
155}
156
157/// Get visual width of a string (simplified)
158fn string_width(s: &str) -> usize {
159    // Simplified: count actual characters
160    // Full implementation would handle Unicode, emoji, CJK
161    s.chars().count()
162}
163
164/// Bridge status label and color from connection state.
165#[derive(Debug, Clone)]
166pub struct BridgeStatusInfo {
167    pub label: BridgeStatusLabel,
168    pub color: BridgeStatusColor,
169}
170
171/// Bridge status label
172#[derive(Debug, Clone)]
173pub enum BridgeStatusLabel {
174    RemoteControlFailed,
175    RemoteControlReconnecting,
176    RemoteControlActive,
177    RemoteControlConnecting,
178}
179
180impl BridgeStatusLabel {
181    pub fn as_str(&self) -> &'static str {
182        match self {
183            BridgeStatusLabel::RemoteControlFailed => "Remote Control failed",
184            BridgeStatusLabel::RemoteControlReconnecting => "Remote Control reconnecting",
185            BridgeStatusLabel::RemoteControlActive => "Remote Control active",
186            BridgeStatusLabel::RemoteControlConnecting => "Remote Control connecting...",
187        }
188    }
189}
190
191/// Bridge status color
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum BridgeStatusColor {
194    Error,
195    Warning,
196    Success,
197}
198
199/// Derive a status label and color from the bridge connection state.
200pub fn get_bridge_status(params: &GetBridgeStatusParams) -> BridgeStatusInfo {
201    if params.error.is_some() {
202        return BridgeStatusInfo {
203            label: BridgeStatusLabel::RemoteControlFailed,
204            color: BridgeStatusColor::Error,
205        };
206    }
207    if params.reconnecting {
208        return BridgeStatusInfo {
209            label: BridgeStatusLabel::RemoteControlReconnecting,
210            color: BridgeStatusColor::Warning,
211        };
212    }
213    if params.session_active || params.connected {
214        return BridgeStatusInfo {
215            label: BridgeStatusLabel::RemoteControlActive,
216            color: BridgeStatusColor::Success,
217        };
218    }
219    BridgeStatusInfo {
220        label: BridgeStatusLabel::RemoteControlConnecting,
221        color: BridgeStatusColor::Warning,
222    }
223}
224
225/// Parameters for get_bridge_status
226pub struct GetBridgeStatusParams<'a> {
227    pub error: Option<&'a str>,
228    pub connected: bool,
229    pub session_active: bool,
230    pub reconnecting: bool,
231}
232
233/// Footer text shown when bridge is idle (Ready state).
234pub fn build_idle_footer_text(url: &str) -> String {
235    format!("Code everywhere with the Claude app or {}", url)
236}
237
238/// Footer text shown when a session is active (Connected state).
239pub fn build_active_footer_text(url: &str) -> String {
240    format!("Continue coding in the Claude app or {}", url)
241}
242
243/// Footer text shown when the bridge has failed.
244pub const FAILED_FOOTER_TEXT: &str = "Something went wrong, please try again";
245
246/// Wrap text in an OSC 8 terminal hyperlink. Zero visual width for layout purposes.
247/// strip-ansi (used by stringWidth) correctly strips these sequences.
248pub fn wrap_with_osc8_link(text: &str, url: &str) -> String {
249    format!("\x1b]8;;{}\x07{}\x1b]8;;\x07", url, text)
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_timestamp() {
258        let ts = timestamp();
259        assert_eq!(ts.len(), 8);
260        assert!(ts.contains(':'));
261    }
262
263    #[test]
264    fn test_truncate_to_width() {
265        assert_eq!(truncate_to_width("hello", 10), "hello");
266        assert_eq!(truncate_to_width("hello world", 8), "hello...");
267    }
268
269    #[test]
270    fn test_compute_glimmer_index() {
271        assert_eq!(compute_glimmer_index(0, 50), 60);
272        assert_eq!(compute_glimmer_index(10, 50), 50);
273    }
274}