1use 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
14pub 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#[derive(Debug, Clone)]
42struct SessionDisplayInfo {
43 title: Option<String>,
44 url: String,
45 activity: Option<SessionActivity>,
46}
47
48impl BridgeLoggerImpl {
49 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 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 self.start_connecting();
105 }
106
107 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 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 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 pub fn log_status(&self, message: &str) {
142 (self.write)(&format!("[{}] {}\n", timestamp(), message));
143 }
144
145 pub fn log_verbose(&self, message: &str) {
147 if self.verbose {
148 (self.write)(&format!("[{}] {}\n", timestamp(), message));
149 }
150 }
151
152 pub fn log_error(&self, message: &str) {
154 (self.write)(&format!("[{}] Error: {}\n", timestamp(), message));
155 }
156
157 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 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 pub fn set_debug_log_path(&mut self, path: &str) {
174 self.debug_log_path = path.to_string();
175 }
176
177 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 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 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 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 let status = format!(
215 "Reconnecting - retrying in {} - disconnected {}\n",
216 delay_str, elapsed_str
217 );
218 self.write(&status);
219 }
220
221 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 pub fn update_session_status(
247 &mut self,
248 _session_id: &str,
249 _elapsed: &str,
250 activity: &SessionActivity,
251 _trail: &[String],
252 ) {
253 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 pub fn clear_status(&mut self) {
266 self.stop_connecting();
267 self.clear_status_lines();
268 }
269
270 pub fn toggle_qr(&mut self) {
272 self.qr_visible = !self.qr_visible;
273 self.render_status_line();
274 }
275
276 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 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 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 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 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 if self.current_state == StatusState::Reconnecting
324 || self.current_state == StatusState::Failed
325 {
326 return;
327 }
328
329 if self.session_max == 1 {
330 self.current_state = StatusState::Titled;
332 self.current_state_text = truncate_to_width(title, 40);
333 }
334 self.render_status_line();
335 }
336
337 pub fn remove_session(&mut self, session_id: &str) {
339 self.session_display_info.remove(session_id);
340 }
341
342 pub fn refresh_display(&mut self) {
344 if self.current_state == StatusState::Reconnecting
346 || self.current_state == StatusState::Failed
347 {
348 return;
349 }
350 self.render_status_line();
351 }
352
353 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 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 let mut suffix = String::new();
403 if !self.repo_name.is_empty() {
404 suffix = format!(" · {}", self.repo_name);
405 }
406 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 let status = format!("{} {}{}\n", indicator, state_text, suffix);
416 self.write(&status);
417 self.status_line_count += 1;
418
419 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 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 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 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
515pub fn create_bridge_logger(
517 verbose: bool,
518 write: Option<Box<dyn Fn(&str) + Send + Sync>>,
519) -> BridgeLoggerImpl {
520 BridgeLoggerImpl::new(verbose, write)
521}