1use anyhow::{anyhow, Context, Result};
12use clap::Subcommand;
13use serde_json::json;
14
15use crate::cli::env_resolver;
16use crate::cli::mcp::{acquire_bidi_lock_if_needed, resolve_browser};
17use crate::cli::trace::CommandTrace;
18use crate::registry::{Registry, TabRow};
19use crate::session::backend::open_backend;
20use crate::session::tabs as session_tabs;
21
22#[derive(Subcommand, Debug)]
23pub enum TabCmd {
24 Open {
28 browser: String,
31 #[arg(default_value = "")]
36 url: String,
37 #[arg(long)]
39 json: bool,
40 },
41 List {
48 browser: String,
49 #[arg(long)]
51 all: bool,
52 #[arg(long)]
53 json: bool,
54 },
55 Foreground {
65 browser: String,
67 #[arg(default_value = "on")]
69 state: String,
70 #[arg(long, default_value = "1h")]
72 timeout: String,
73 #[arg(long)]
74 json: bool,
75 },
76 #[command(hide = true)]
78 ForegroundHold {
79 browser_name: String,
80 target_id: String,
81 #[arg(long, default_value_t = 3600)]
82 timeout_s: u64,
83 },
84 Adopt {
90 browser: String,
92 target_id: String,
94 #[arg(long)]
95 json: bool,
96 },
97}
98
99pub async fn run(cmd: TabCmd) -> Result<()> {
100 match cmd {
101 TabCmd::Open { browser, url, json } => {
102 let mut trace = CommandTrace::new("tab-open");
103 let result = open(&browser, &url, json, &mut trace).await;
104 trace.finish(result)
105 }
106 TabCmd::List { browser, all, json } => {
107 let mut trace = CommandTrace::new("tab-list");
108 let result = list(&browser, all, json, &mut trace).await;
109 trace.finish(result)
110 }
111 TabCmd::Adopt {
112 browser,
113 target_id,
114 json,
115 } => {
116 let mut trace = CommandTrace::new("tab-adopt");
117 let result = adopt(&browser, &target_id, json, &mut trace).await;
118 trace.finish(result)
119 }
120 TabCmd::Foreground {
121 browser,
122 state,
123 timeout,
124 json,
125 } => {
126 let mut trace = CommandTrace::new("tab-foreground");
127 let result = foreground(&browser, &state, &timeout, json, &mut trace).await;
128 trace.finish(result)
129 }
130 TabCmd::ForegroundHold {
131 browser_name,
132 target_id,
133 timeout_s,
134 } => {
135 crate::session::foreground::hold(
136 &browser_name,
137 &target_id,
138 std::time::Duration::from_secs(timeout_s),
139 )
140 .await
141 }
142 }
143}
144
145async fn foreground(
146 positional: &str,
147 state: &str,
148 timeout: &str,
149 json: bool,
150 trace: &mut CommandTrace,
151) -> Result<()> {
152 use crate::session::foreground as fg;
153 let enabled = match state.trim().to_ascii_lowercase().as_str() {
154 "on" | "true" | "1" => true,
155 "off" | "false" | "0" => false,
156 other => return Err(anyhow!("state must be `on` or `off`, got `{other}`")),
157 };
158 let timeout = crate::session::freshness::parse_max_age(timeout)
159 .with_context(|| format!("parsing --timeout `{timeout}`"))?;
160 let target = env_resolver::parse_target(positional)
161 .with_context(|| format!("parsing `{positional}` as <browser>[/<tab>]"))?;
162 let registry = Registry::open()?;
163 let resolved = resolve_browser(Some(reassemble_browser_only(positional)?)).await?;
164 let browser_name = match &resolved.source {
165 crate::cli::env_resolver::Source::Registered { name } => name.clone(),
166 _ => {
167 return Err(anyhow!(
168 "foreground emulation requires a registered browser; `{positional}` resolved to an external endpoint"
169 ));
170 }
171 };
172 trace.browser(&browser_name).engine(resolved.engine);
173 trace.route("tab-foreground");
174 if resolved.engine != crate::detect::Engine::Cdp {
175 return Err(anyhow!(
176 "foreground emulation is Chromium-only: Firefox has no WebDriver BiDi equivalent"
177 ));
178 }
179 let Some(name) = target.tab.as_deref() else {
180 if enabled {
181 return Err(anyhow!(
182 "`tab foreground <browser> on` needs a tab: use `<browser>/<tab>`; a bare browser is only valid with `off` (stop all)"
183 ));
184 }
185 let n = fg::stop_all(®istry, &browser_name)?;
186 if json {
187 println!(
188 "{}",
189 serde_json::to_string_pretty(
190 &json!({"browser": browser_name, "foreground": false, "stopped": n})
191 )?
192 );
193 } else {
194 println!("foreground off for {n} tab(s) on {browser_name}");
195 }
196 return Ok(());
197 };
198 trace.tab_name(name);
199 let _bidi_lock = acquire_bidi_lock_if_needed(®istry, &resolved)?;
200 let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
201 let row = crate::session::resolve_tab(&backend, ®istry, &browser_name, name).await;
202 backend.shutdown().await;
203 let row = row?.ok_or_else(|| crate::errors::SessionError::TabNotFound {
204 browser: browser_name.clone(),
205 name: name.to_string(),
206 })?;
207 trace.target_id(&row.target_id);
208 let (pid, changed) = if enabled {
209 let (pid, created) = fg::spawn_holder(®istry, &browser_name, &row.target_id, timeout)?;
210 (Some(pid), created)
211 } else {
212 (
213 None,
214 fg::stop_holder(®istry, &browser_name, &row.target_id)?,
215 )
216 };
217 let expires_in = fg::status(®istry, &browser_name, &row.target_id)?
220 .map(|r| (r.expires_at_epoch_s - crate::registry::now_epoch_s()).max(0) as u64)
221 .map(std::time::Duration::from_secs);
222 if json {
223 println!(
224 "{}",
225 serde_json::to_string_pretty(&json!({
226 "browser": browser_name,
227 "name": name,
228 "target_id": row.target_id,
229 "foreground": enabled,
230 "changed": changed,
231 "holder_pid": pid,
232 "expires_in_s": expires_in.map(|d| d.as_secs()),
233 }))?
234 );
235 } else if enabled {
236 println!(
237 "foreground {} for {browser_name}/{name} (holder pid {}, expires in {})",
238 if changed { "on" } else { "already on" },
239 pid.unwrap_or(0),
240 crate::session::freshness::format_duration(expires_in.unwrap_or(timeout))
241 );
242 } else {
243 println!(
244 "foreground {} for {browser_name}/{name}",
245 if changed { "off" } else { "already off" }
246 );
247 }
248 Ok(())
249}
250
251async fn open(positional: &str, url: &str, json: bool, trace: &mut CommandTrace) -> Result<()> {
252 let target = env_resolver::parse_target(positional)
253 .with_context(|| format!("parsing `{positional}` as <browser>[/<tab>]"))?;
254 let name = target.tab.as_deref();
255 let url_opt = if url.is_empty() { None } else { Some(url) };
256
257 let registry = Registry::open()?;
258 let resolved = resolve_browser(Some(reassemble_browser_only(positional)?)).await?;
259 let browser_name = match &resolved.source {
260 crate::cli::env_resolver::Source::Registered { name } => name.clone(),
261 _ => {
262 return Err(anyhow!(
263 "named tabs require a registered browser; `{positional}` resolved to an external endpoint"
264 ));
265 }
266 };
267
268 trace.browser(&browser_name).engine(resolved.engine);
269 if let Some(n) = name {
270 trace.tab_name(n);
271 }
272 trace.route("tab-open");
273
274 let _bidi_lock = acquire_bidi_lock_if_needed(®istry, &resolved)?;
275 let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
276 let row = session_tabs::tab_open(&backend, ®istry, &browser_name, name, url_opt).await;
277 backend.shutdown().await;
278 let row = row?;
279 trace.target_id(&row.target_id);
280 if name.is_none() {
281 trace.tab_name(&row.name);
283 }
284 print_summary(&row, json);
285 Ok(())
286}
287
288async fn list(positional: &str, all: bool, json: bool, trace: &mut CommandTrace) -> Result<()> {
289 let target = env_resolver::parse_target(positional)?;
292 if target.tab.is_some() {
293 return Err(anyhow!(
294 "`tab list` takes a bare `<browser>`, not `<browser>/<tab>`"
295 ));
296 }
297 let registry = Registry::open()?;
298 let resolved = resolve_browser(Some(positional.to_string())).await?;
299 let browser_name = match &resolved.source {
300 crate::cli::env_resolver::Source::Registered { name } => name.clone(),
301 _ => {
302 return Err(anyhow!(
303 "tab list requires a registered browser; `{positional}` resolved to an external endpoint"
304 ));
305 }
306 };
307 trace.browser(&browser_name).engine(resolved.engine);
308 trace.route(if all { "tab-list-all" } else { "tab-list" });
309 let _bidi_lock = acquire_bidi_lock_if_needed(®istry, &resolved)?;
310 let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
311 let foreground = crate::session::foreground::active_targets(®istry, &browser_name)?;
312 let merged = async {
313 let rows = session_tabs::tab_list(&backend, ®istry, &browser_name).await?;
314 let merged: Vec<DisplayRow> = if all {
319 let live = backend.live_targets().await?;
320 let known_ids: std::collections::HashSet<&str> =
321 rows.iter().map(|r| r.target_id.as_str()).collect();
322 let mut out: Vec<DisplayRow> = rows.iter().map(DisplayRow::from_row).collect();
323 for t in &live {
324 if !known_ids.contains(t.id.as_str()) {
325 out.push(DisplayRow::from_live(t));
326 }
327 }
328 out
329 } else {
330 rows.iter().map(DisplayRow::from_row).collect()
331 };
332 Ok::<_, anyhow::Error>(merged)
333 }
334 .await;
335 backend.shutdown().await;
336 let mut merged = merged?;
337 for r in &mut merged {
338 r.foreground = foreground.contains(&r.target_id);
339 }
340
341 if json {
342 let arr: Vec<serde_json::Value> = merged.iter().map(DisplayRow::to_json).collect();
343 println!("{}", serde_json::to_string_pretty(&arr)?);
344 } else if merged.is_empty() {
345 println!("(no tabs)");
346 } else {
347 println!("NAME\tOWNER\tIDLE_S\tFOREGROUND\tURL");
348 let now = crate::registry::now_epoch_s();
349 for r in &merged {
350 let idle = r
351 .last_used_at_epoch_s
352 .map(|t| (now - t).max(0).to_string())
353 .unwrap_or_else(|| "-".to_string());
354 println!(
355 "{}\t{}\t{}\t{}\t{}",
356 r.name,
357 r.owner,
358 idle,
359 if r.foreground { "on" } else { "-" },
360 r.url
361 );
362 }
363 }
364 Ok(())
365}
366
367async fn adopt(
368 positional: &str,
369 target_id: &str,
370 json: bool,
371 trace: &mut CommandTrace,
372) -> Result<()> {
373 let target = env_resolver::parse_target(positional)
374 .with_context(|| format!("parsing `{positional}` as <browser>/<tab>"))?;
375 let name = target
376 .tab
377 .as_deref()
378 .ok_or_else(|| anyhow!("`tab adopt` requires `<browser>/<name>`, got `{positional}`"))?;
379
380 let registry = Registry::open()?;
381 let resolved = resolve_browser(Some(reassemble_browser_only(positional)?)).await?;
382 let browser_name = match &resolved.source {
383 crate::cli::env_resolver::Source::Registered { name } => name.clone(),
384 _ => {
385 return Err(anyhow!(
386 "named tabs require a registered browser; `{positional}` resolved to an external endpoint"
387 ));
388 }
389 };
390
391 trace
392 .browser(&browser_name)
393 .engine(resolved.engine)
394 .tab_name(name)
395 .route("tab-adopt");
396
397 let _bidi_lock = acquire_bidi_lock_if_needed(®istry, &resolved)?;
398 let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
399 let looked_up = async {
400 let live_ids = backend.live_target_ids().await?;
402 if !live_ids.contains(target_id) {
403 return Err(anyhow!(
404 "target ID `{target_id}` not found among live tabs. \
405 Use `tab list --all` to see available target IDs."
406 ));
407 }
408 let live_targets = backend.live_targets().await?;
410 Ok::<_, anyhow::Error>(
411 live_targets
412 .iter()
413 .find(|t| t.id == target_id)
414 .map(|t| t.url.clone())
415 .unwrap_or_else(|| "about:blank".to_string()),
416 )
417 }
418 .await;
419 backend.shutdown().await;
420 let url = looked_up?;
421 let url = url.as_str();
422
423 registry.tab_upsert(&browser_name, name, target_id, url, false)?;
425 let row = registry
426 .tab_get(&browser_name, name)?
427 .ok_or_else(|| anyhow!("tab row missing immediately after upsert"))?;
428 trace.target_id(&row.target_id);
429 print_summary(&row, json);
430 Ok(())
431}
432
433struct DisplayRow {
438 name: String,
439 owner: &'static str,
440 url: String,
441 last_used_at_epoch_s: Option<i64>,
442 target_id: String,
443 daemon_created: bool,
444 foreground: bool,
445}
446
447impl DisplayRow {
448 fn from_row(r: &TabRow) -> Self {
449 Self {
450 name: r.name.clone(),
451 owner: if r.daemon_created { "agent" } else { "user" },
452 url: r.last_url.clone(),
453 last_used_at_epoch_s: Some(r.last_used_at_epoch_s),
454 target_id: r.target_id.clone(),
455 daemon_created: r.daemon_created,
456 foreground: false,
457 }
458 }
459 fn from_live(t: &crate::session::backend::LiveTarget) -> Self {
460 Self {
461 name: String::new(),
462 owner: "unnamed",
463 url: t.url.clone(),
464 last_used_at_epoch_s: None,
465 target_id: t.id.clone(),
466 daemon_created: false,
467 foreground: false,
468 }
469 }
470 fn to_json(&self) -> serde_json::Value {
471 json!({
472 "name": self.name,
473 "owner": self.owner,
474 "target_id": self.target_id,
475 "url": self.url,
476 "last_used_at_epoch_s": self.last_used_at_epoch_s,
477 "daemon_created": self.daemon_created,
478 "foreground": self.foreground,
479 })
480 }
481}
482
483fn tab_to_json(r: &TabRow) -> serde_json::Value {
484 json!({
485 "name": r.name,
486 "target_id": r.target_id,
487 "url": r.last_url,
488 "last_used_at_epoch_s": r.last_used_at_epoch_s,
489 "daemon_created": r.daemon_created,
490 })
491}
492
493fn print_summary(row: &TabRow, json: bool) {
494 if json {
495 println!(
496 "{}",
497 serde_json::to_string_pretty(&tab_to_json(row)).unwrap()
498 );
499 } else {
500 println!("{}\t{}", row.name, row.last_url);
502 }
503}
504
505fn reassemble_browser_only(positional: &str) -> Result<String> {
509 if let Some(idx) = positional.find('/') {
510 if idx > 0 {
511 return Ok(positional[..idx].to_string());
512 }
513 }
514 Ok(positional.to_string())
515}