Skip to main content

ai_agent/bridge/
bridge_ui.rs

1//! Bridge UI for console logging.
2//!
3//! Translated from openclaudecode/src/bridge/bridgeUI.ts
4//!
5//! Console-based UI for the bridge. For a full TUI implementation,
6//! see the ai-code project which uses ratatui.
7
8use crate::bridge::bridge_status_util::{
9    StatusState, TOOL_DISPLAY_EXPIRY_MS, build_bridge_connect_url, build_bridge_session_url,
10    format_duration, timestamp, truncate_to_width,
11};
12use crate::bridge::bridge_types::{BridgeConfig, SessionActivity, SessionActivityType, SpawnMode};
13
14/// Bridge logger implementation for console output
15pub struct BridgeLoggerImpl {
16    verbose: bool,
17    write: Box<dyn Fn(&str) + Send + Sync>,
18    status_line_count: usize,
19    current_state: StatusState,
20    current_state_text: String,
21    repo_name: String,
22    branch: String,
23    debug_log_path: String,
24    connect_url: String,
25    cached_ingress_url: String,
26    cached_environment_id: String,
27    active_session_url: Option<String>,
28    qr_visible: bool,
29    last_tool_summary: Option<String>,
30    last_tool_time: u64,
31    session_active: u32,
32    session_max: u32,
33    spawn_mode_display: Option<SpawnMode>,
34    spawn_mode: SpawnMode,
35    session_display_info: std::collections::HashMap<String, SessionDisplayInfo>,
36    connecting: bool,
37    connecting_tick: u64,
38}
39
40/// Per-session display info for multi-session mode
41#[derive(Debug, Clone)]
42struct SessionDisplayInfo {
43    title: Option<String>,
44    url: String,
45    activity: Option<SessionActivity>,
46}
47
48impl BridgeLoggerImpl {
49    /// Create a new bridge logger
50    pub fn new(verbose: bool, write: Option<Box<dyn Fn(&str) + Send + Sync>>) -> Self {
51        let write_fn = write.unwrap_or_else(|| Box::new(|s| print!("{}", s)));
52        Self {
53            verbose,
54            write: write_fn,
55            status_line_count: 0,
56            current_state: StatusState::Idle,
57            current_state_text: "Ready".to_string(),
58            repo_name: String::new(),
59            branch: String::new(),
60            debug_log_path: String::new(),
61            connect_url: String::new(),
62            cached_ingress_url: String::new(),
63            cached_environment_id: String::new(),
64            active_session_url: None,
65            qr_visible: false,
66            last_tool_summary: None,
67            last_tool_time: 0,
68            session_active: 0,
69            session_max: 1,
70            spawn_mode_display: None,
71            spawn_mode: SpawnMode::SingleSession,
72            session_display_info: std::collections::HashMap::new(),
73            connecting: false,
74            connecting_tick: 0,
75        }
76    }
77
78    /// Print the bridge banner
79    pub fn print_banner(&mut self, config: &BridgeConfig, environment_id: &str) {
80        self.cached_ingress_url = config.session_ingress_url.clone();
81        self.cached_environment_id = environment_id.to_string();
82        self.connect_url =
83            build_bridge_connect_url(environment_id, Some(&config.session_ingress_url));
84
85        if self.verbose {
86            (self.write)(&format!("Remote Control v{}\n", env!("CARGO_PKG_VERSION")));
87        }
88        if self.verbose {
89            if config.spawn_mode != SpawnMode::SingleSession {
90                (self.write)(&format!("Spawn mode: {:?}\n", config.spawn_mode));
91                (self.write)(&format!(
92                    "Max concurrent sessions: {}\n",
93                    config.max_sessions
94                ));
95            }
96            (self.write)(&format!("Environment ID: {}\n", environment_id));
97        }
98        if config.sandbox {
99            (self.write)("Sandbox: Enabled\n");
100        }
101        (self.write)("\n");
102
103        // Start connecting spinner
104        self.start_connecting();
105    }
106
107    /// Log session start
108    pub fn log_session_start(&self, session_id: &str, prompt: &str) {
109        if self.verbose {
110            let short = truncate_to_width(prompt, 80);
111            (self.write)(&format!(
112                "[{}] Session started: \"{}\" ({})\n",
113                timestamp(),
114                short,
115                session_id
116            ));
117        }
118    }
119
120    /// Log session complete
121    pub fn log_session_complete(&self, session_id: &str, duration_ms: u64) {
122        (self.write)(&format!(
123            "[{}] Session completed ({}) {}\n",
124            timestamp(),
125            format_duration(duration_ms),
126            session_id
127        ));
128    }
129
130    /// Log session failed
131    pub fn log_session_failed(&self, session_id: &str, error: &str) {
132        (self.write)(&format!(
133            "[{}] Session failed: {} {}\n",
134            timestamp(),
135            error,
136            session_id
137        ));
138    }
139
140    /// Log status message
141    pub fn log_status(&self, message: &str) {
142        (self.write)(&format!("[{}] {}\n", timestamp(), message));
143    }
144
145    /// Log verbose message
146    pub fn log_verbose(&self, message: &str) {
147        if self.verbose {
148            (self.write)(&format!("[{}] {}\n", timestamp(), message));
149        }
150    }
151
152    /// Log error message
153    pub fn log_error(&self, message: &str) {
154        (self.write)(&format!("[{}] Error: {}\n", timestamp(), message));
155    }
156
157    /// Log reconnected
158    pub fn log_reconnected(&self, disconnected_ms: u64) {
159        (self.write)(&format!(
160            "[{}] Reconnected after {}\n",
161            timestamp(),
162            format_duration(disconnected_ms)
163        ));
164    }
165
166    /// Set repository info
167    pub fn set_repo_info(&mut self, repo: &str, branch_name: &str) {
168        self.repo_name = repo.to_string();
169        self.branch = branch_name.to_string();
170    }
171
172    /// Set debug log path
173    pub fn set_debug_log_path(&mut self, path: &str) {
174        self.debug_log_path = path.to_string();
175    }
176
177    /// Update to idle status
178    pub fn update_idle_status(&mut self) {
179        self.stop_connecting();
180        self.current_state = StatusState::Idle;
181        self.current_state_text = "Ready".to_string();
182        self.last_tool_summary = None;
183        self.last_tool_time = 0;
184        self.active_session_url = None;
185        self.render_status_line();
186    }
187
188    /// Set attached state
189    pub fn set_attached(&mut self, session_id: &str) {
190        self.stop_connecting();
191        self.current_state = StatusState::Attached;
192        self.current_state_text = "Connected".to_string();
193        self.last_tool_summary = None;
194        self.last_tool_time = 0;
195
196        // Multi-session: keep footer/QR on the environment connect URL
197        if self.session_max <= 1 {
198            self.active_session_url = Some(build_bridge_session_url(
199                session_id,
200                &self.cached_environment_id,
201                Some(&self.cached_ingress_url),
202            ));
203        }
204        self.render_status_line();
205    }
206
207    /// Update reconnecting status
208    pub fn update_reconnecting_status(&mut self, delay_str: &str, elapsed_str: &str) {
209        self.stop_connecting();
210        self.clear_status_lines();
211        self.current_state = StatusState::Reconnecting;
212
213        // Simple status display
214        let status = format!(
215            "Reconnecting - retrying in {} - disconnected {}\n",
216            delay_str, elapsed_str
217        );
218        self.write(&status);
219    }
220
221    /// Update failed status
222    pub fn update_failed_status(&mut self, error: &str) {
223        self.stop_connecting();
224        self.clear_status_lines();
225        self.current_state = StatusState::Failed;
226
227        let mut suffix = String::new();
228        if !self.repo_name.is_empty() {
229            suffix = format!(" · {}", self.repo_name);
230        }
231        if !self.branch.is_empty() {
232            suffix = format!("{} · {}", suffix, self.branch);
233        }
234
235        let error_suffix = if error.is_empty() {
236            String::new()
237        } else {
238            format!("\n{}", error)
239        };
240        let status = format!("Remote Control Failed{}{}\n", suffix, error_suffix);
241        self.write(&status);
242        self.write("Something went wrong, please try again\n");
243    }
244
245    /// Update session status
246    pub fn update_session_status(
247        &mut self,
248        _session_id: &str,
249        _elapsed: &str,
250        activity: &SessionActivity,
251        _trail: &[String],
252    ) {
253        // Cache tool activity for the second status line
254        if activity.activity_type == SessionActivityType::ToolStart {
255            self.last_tool_summary = Some(activity.summary.clone());
256            self.last_tool_time = std::time::SystemTime::now()
257                .duration_since(std::time::UNIX_EPOCH)
258                .unwrap()
259                .as_millis() as u64;
260        }
261        self.render_status_line();
262    }
263
264    /// Clear status
265    pub fn clear_status(&mut self) {
266        self.stop_connecting();
267        self.clear_status_lines();
268    }
269
270    /// Toggle QR code visibility
271    pub fn toggle_qr(&mut self) {
272        self.qr_visible = !self.qr_visible;
273        self.render_status_line();
274    }
275
276    /// Update session count
277    pub fn update_session_count(&mut self, active: u32, max: u32, mode: SpawnMode) {
278        if self.session_active == active && self.session_max == max && self.spawn_mode == mode {
279            return;
280        }
281        self.session_active = active;
282        self.session_max = max;
283        self.spawn_mode = mode;
284    }
285
286    /// Set spawn mode display
287    pub fn set_spawn_mode_display(&mut self, mode: Option<SpawnMode>) {
288        if self.spawn_mode_display == mode {
289            return;
290        }
291        self.spawn_mode_display = mode;
292        if let Some(m) = mode {
293            self.spawn_mode = m;
294        }
295    }
296
297    /// Add session
298    pub fn add_session(&mut self, session_id: &str, url: &str) {
299        self.session_display_info.insert(
300            session_id.to_string(),
301            SessionDisplayInfo {
302                title: None,
303                url: url.to_string(),
304                activity: None,
305            },
306        );
307    }
308
309    /// Update session activity
310    pub fn update_session_activity(&mut self, session_id: &str, activity: &SessionActivity) {
311        if let Some(info) = self.session_display_info.get_mut(session_id) {
312            info.activity = Some(activity.clone());
313        }
314    }
315
316    /// Set session title
317    pub fn set_session_title(&mut self, session_id: &str, title: &str) {
318        if let Some(info) = self.session_display_info.get_mut(session_id) {
319            info.title = Some(title.to_string());
320        }
321
322        // Guard against reconnecting/failed
323        if self.current_state == StatusState::Reconnecting
324            || self.current_state == StatusState::Failed
325        {
326            return;
327        }
328
329        if self.session_max == 1 {
330            // Single-session: show title in the main status line too.
331            self.current_state = StatusState::Titled;
332            self.current_state_text = truncate_to_width(title, 40);
333        }
334        self.render_status_line();
335    }
336
337    /// Remove session
338    pub fn remove_session(&mut self, session_id: &str) {
339        self.session_display_info.remove(session_id);
340    }
341
342    /// Refresh display
343    pub fn refresh_display(&mut self) {
344        // Skip during reconnecting/failed
345        if self.current_state == StatusState::Reconnecting
346            || self.current_state == StatusState::Failed
347        {
348            return;
349        }
350        self.render_status_line();
351    }
352
353    // Helper methods
354
355    fn start_connecting(&mut self) {
356        self.stop_connecting();
357        self.render_connecting_line();
358        self.connecting = true;
359    }
360
361    fn stop_connecting(&mut self) {
362        self.connecting = false;
363    }
364
365    fn render_connecting_line(&mut self) {
366        self.clear_status_lines();
367
368        let frames = ["-", "\\", "|", "/"];
369        let frame = frames[(self.connecting_tick as usize) % frames.len()];
370
371        let mut suffix = String::new();
372        if !self.repo_name.is_empty() {
373            suffix = format!(" · {}", self.repo_name);
374        }
375        if !self.branch.is_empty() {
376            suffix = format!("{} · {}", suffix, self.branch);
377        }
378
379        let line = format!(
380            "{} Connecting{}{}\n",
381            frame,
382            suffix,
383            if suffix.is_empty() { "" } else { "" }
384        );
385        self.write(&line);
386        self.status_line_count += 1;
387    }
388
389    fn render_status_line(&mut self) {
390        // Skip during reconnecting/failed
391        if self.current_state == StatusState::Reconnecting
392            || self.current_state == StatusState::Failed
393        {
394            return;
395        }
396
397        self.clear_status_lines();
398
399        let is_idle = self.current_state == StatusState::Idle;
400
401        // Build suffix with repo and branch
402        let mut suffix = String::new();
403        if !self.repo_name.is_empty() {
404            suffix = format!(" · {}", self.repo_name);
405        }
406        // In worktree mode each session gets its own branch
407        if !self.branch.is_empty() && self.spawn_mode != SpawnMode::Worktree {
408            suffix = format!("{} · {}", suffix, self.branch);
409        }
410
411        let indicator = if is_idle { "[*]" } else { "[+]" };
412        let state_text = &self.current_state_text;
413
414        // Build status line
415        let status = format!("{} {}{}\n", indicator, state_text, suffix);
416        self.write(&status);
417        self.status_line_count += 1;
418
419        // Session count and per-session list (multi-session mode only)
420        if self.session_max > 1 {
421            let mode_hint = match self.spawn_mode {
422                SpawnMode::Worktree => "New sessions will be created in an isolated worktree",
423                SpawnMode::SameDir => "New sessions will be created in the current directory",
424                SpawnMode::SingleSession => "",
425            };
426            if !mode_hint.is_empty() {
427                let line = format!(
428                    "    Capacity: {}/{} · {}\n",
429                    self.session_active, self.session_max, mode_hint
430                );
431                self.write(&line);
432                self.status_line_count += 1;
433            }
434
435            for (_, info) in &self.session_display_info {
436                let title_text = info.title.as_deref().unwrap_or("Attached");
437                let truncated = truncate_to_width(title_text, 35);
438                let act = &info.activity;
439                let show_act = act.is_some()
440                    && act
441                        .as_ref()
442                        .map(|a| {
443                            a.activity_type != SessionActivityType::Result
444                                && a.activity_type != SessionActivityType::Error
445                        })
446                        .unwrap_or(false);
447                let act_text = if show_act {
448                    format!(
449                        " {}",
450                        truncate_to_width(act.as_ref().unwrap().summary.as_str(), 40)
451                    )
452                } else {
453                    String::new()
454                };
455                let line = format!("    {}{}\n", truncated, act_text);
456                self.write(&line);
457                self.status_line_count += 1;
458            }
459        }
460
461        // Tool activity line for single-session mode
462        if self.session_max == 1 && !is_idle {
463            if let Some(ref summary) = self.last_tool_summary {
464                let now = std::time::SystemTime::now()
465                    .duration_since(std::time::UNIX_EPOCH)
466                    .unwrap()
467                    .as_millis() as u64;
468                if now - self.last_tool_time < TOOL_DISPLAY_EXPIRY_MS {
469                    let line = format!("  {}\n", truncate_to_width(summary, 60));
470                    self.write(&line);
471                    self.status_line_count += 1;
472                }
473            }
474        }
475
476        // Footer text
477        let url = self
478            .active_session_url
479            .as_deref()
480            .unwrap_or(&self.connect_url);
481        (self.write)("\n");
482        self.status_line_count += 1;
483
484        let footer_text = if is_idle {
485            format!("Code everywhere with the Claude app or {}", url)
486        } else {
487            format!("Continue coding in the Claude app or {}", url)
488        };
489        (self.write)(&format!("{}\n", footer_text));
490        self.status_line_count += 1;
491
492        let qr_hint = if self.qr_visible {
493            "space to hide QR code"
494        } else {
495            "space to show QR code"
496        };
497        (self.write)(&format!("{}\n", qr_hint));
498        self.status_line_count += 1;
499    }
500
501    fn clear_status_lines(&mut self) {
502        if self.status_line_count > 0 {
503            // Move cursor up and clear lines
504            let escape = format!("\x1b[{}A\x1b[J", self.status_line_count);
505            (self.write)(&escape);
506            self.status_line_count = 0;
507        }
508    }
509
510    fn write(&self, text: &str) {
511        (self.write)(text);
512    }
513}
514
515/// Create a bridge logger with options
516pub fn create_bridge_logger(
517    verbose: bool,
518    write: Option<Box<dyn Fn(&str) + Send + Sync>>,
519) -> BridgeLoggerImpl {
520    BridgeLoggerImpl::new(verbose, write)
521}