use anyhow::{anyhow, Context, Result};
use clap::Subcommand;
use serde_json::json;
use crate::cli::env_resolver;
use crate::cli::mcp::{acquire_bidi_lock_if_needed, resolve_browser};
use crate::cli::trace::CommandTrace;
use crate::registry::{Registry, TabRow};
use crate::session::backend::open_backend;
use crate::session::tabs as session_tabs;
#[derive(Subcommand, Debug)]
pub enum TabCmd {
Open {
browser: String,
#[arg(default_value = "")]
url: String,
#[arg(long)]
json: bool,
},
List {
browser: String,
#[arg(long)]
all: bool,
#[arg(long)]
json: bool,
},
Foreground {
browser: String,
#[arg(default_value = "on")]
state: String,
#[arg(long, default_value = "1h")]
timeout: String,
#[arg(long)]
json: bool,
},
#[command(hide = true)]
ForegroundHold {
browser_name: String,
target_id: String,
#[arg(long, default_value_t = 3600)]
timeout_s: u64,
},
Adopt {
browser: String,
target_id: String,
#[arg(long)]
json: bool,
},
}
pub async fn run(cmd: TabCmd) -> Result<()> {
match cmd {
TabCmd::Open { browser, url, json } => {
let mut trace = CommandTrace::new("tab-open");
let result = open(&browser, &url, json, &mut trace).await;
trace.finish(result)
}
TabCmd::List { browser, all, json } => {
let mut trace = CommandTrace::new("tab-list");
let result = list(&browser, all, json, &mut trace).await;
trace.finish(result)
}
TabCmd::Adopt {
browser,
target_id,
json,
} => {
let mut trace = CommandTrace::new("tab-adopt");
let result = adopt(&browser, &target_id, json, &mut trace).await;
trace.finish(result)
}
TabCmd::Foreground {
browser,
state,
timeout,
json,
} => {
let mut trace = CommandTrace::new("tab-foreground");
let result = foreground(&browser, &state, &timeout, json, &mut trace).await;
trace.finish(result)
}
TabCmd::ForegroundHold {
browser_name,
target_id,
timeout_s,
} => {
crate::session::foreground::hold(
&browser_name,
&target_id,
std::time::Duration::from_secs(timeout_s),
)
.await
}
}
}
async fn foreground(
positional: &str,
state: &str,
timeout: &str,
json: bool,
trace: &mut CommandTrace,
) -> Result<()> {
use crate::session::foreground as fg;
let enabled = match state.trim().to_ascii_lowercase().as_str() {
"on" | "true" | "1" => true,
"off" | "false" | "0" => false,
other => return Err(anyhow!("state must be `on` or `off`, got `{other}`")),
};
let timeout = crate::session::freshness::parse_max_age(timeout)
.with_context(|| format!("parsing --timeout `{timeout}`"))?;
let target = env_resolver::parse_target(positional)
.with_context(|| format!("parsing `{positional}` as <browser>[/<tab>]"))?;
let registry = Registry::open()?;
let resolved = resolve_browser(Some(reassemble_browser_only(positional)?)).await?;
let browser_name = match &resolved.source {
crate::cli::env_resolver::Source::Registered { name } => name.clone(),
_ => {
return Err(anyhow!(
"foreground emulation requires a registered browser; `{positional}` resolved to an external endpoint"
));
}
};
trace.browser(&browser_name).engine(resolved.engine);
trace.route("tab-foreground");
if resolved.engine != crate::detect::Engine::Cdp {
return Err(anyhow!(
"foreground emulation is Chromium-only: Firefox has no WebDriver BiDi equivalent"
));
}
let Some(name) = target.tab.as_deref() else {
if enabled {
return Err(anyhow!(
"`tab foreground <browser> on` needs a tab: use `<browser>/<tab>`; a bare browser is only valid with `off` (stop all)"
));
}
let n = fg::stop_all(®istry, &browser_name)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(
&json!({"browser": browser_name, "foreground": false, "stopped": n})
)?
);
} else {
println!("foreground off for {n} tab(s) on {browser_name}");
}
return Ok(());
};
trace.tab_name(name);
let _bidi_lock = acquire_bidi_lock_if_needed(®istry, &resolved)?;
let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
let row = crate::session::resolve_tab(&backend, ®istry, &browser_name, name).await;
backend.shutdown().await;
let row = row?.ok_or_else(|| crate::errors::SessionError::TabNotFound {
browser: browser_name.clone(),
name: name.to_string(),
})?;
trace.target_id(&row.target_id);
let (pid, changed) = if enabled {
let (pid, created) = fg::spawn_holder(®istry, &browser_name, &row.target_id, timeout)?;
(Some(pid), created)
} else {
(
None,
fg::stop_holder(®istry, &browser_name, &row.target_id)?,
)
};
let expires_in = fg::status(®istry, &browser_name, &row.target_id)?
.map(|r| (r.expires_at_epoch_s - crate::registry::now_epoch_s()).max(0) as u64)
.map(std::time::Duration::from_secs);
if json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"browser": browser_name,
"name": name,
"target_id": row.target_id,
"foreground": enabled,
"changed": changed,
"holder_pid": pid,
"expires_in_s": expires_in.map(|d| d.as_secs()),
}))?
);
} else if enabled {
println!(
"foreground {} for {browser_name}/{name} (holder pid {}, expires in {})",
if changed { "on" } else { "already on" },
pid.unwrap_or(0),
crate::session::freshness::format_duration(expires_in.unwrap_or(timeout))
);
} else {
println!(
"foreground {} for {browser_name}/{name}",
if changed { "off" } else { "already off" }
);
}
Ok(())
}
async fn open(positional: &str, url: &str, json: bool, trace: &mut CommandTrace) -> Result<()> {
let target = env_resolver::parse_target(positional)
.with_context(|| format!("parsing `{positional}` as <browser>[/<tab>]"))?;
let name = target.tab.as_deref();
let url_opt = if url.is_empty() { None } else { Some(url) };
let registry = Registry::open()?;
let resolved = resolve_browser(Some(reassemble_browser_only(positional)?)).await?;
let browser_name = match &resolved.source {
crate::cli::env_resolver::Source::Registered { name } => name.clone(),
_ => {
return Err(anyhow!(
"named tabs require a registered browser; `{positional}` resolved to an external endpoint"
));
}
};
trace.browser(&browser_name).engine(resolved.engine);
if let Some(n) = name {
trace.tab_name(n);
}
trace.route("tab-open");
let _bidi_lock = acquire_bidi_lock_if_needed(®istry, &resolved)?;
let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
let row = session_tabs::tab_open(&backend, ®istry, &browser_name, name, url_opt).await;
backend.shutdown().await;
let row = row?;
trace.target_id(&row.target_id);
if name.is_none() {
trace.tab_name(&row.name);
}
print_summary(&row, json);
Ok(())
}
async fn list(positional: &str, all: bool, json: bool, trace: &mut CommandTrace) -> Result<()> {
let target = env_resolver::parse_target(positional)?;
if target.tab.is_some() {
return Err(anyhow!(
"`tab list` takes a bare `<browser>`, not `<browser>/<tab>`"
));
}
let registry = Registry::open()?;
let resolved = resolve_browser(Some(positional.to_string())).await?;
let browser_name = match &resolved.source {
crate::cli::env_resolver::Source::Registered { name } => name.clone(),
_ => {
return Err(anyhow!(
"tab list requires a registered browser; `{positional}` resolved to an external endpoint"
));
}
};
trace.browser(&browser_name).engine(resolved.engine);
trace.route(if all { "tab-list-all" } else { "tab-list" });
let _bidi_lock = acquire_bidi_lock_if_needed(®istry, &resolved)?;
let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
let foreground = crate::session::foreground::active_targets(®istry, &browser_name)?;
let merged = async {
let rows = session_tabs::tab_list(&backend, ®istry, &browser_name).await?;
let merged: Vec<DisplayRow> = if all {
let live = backend.live_targets().await?;
let known_ids: std::collections::HashSet<&str> =
rows.iter().map(|r| r.target_id.as_str()).collect();
let mut out: Vec<DisplayRow> = rows.iter().map(DisplayRow::from_row).collect();
for t in &live {
if !known_ids.contains(t.id.as_str()) {
out.push(DisplayRow::from_live(t));
}
}
out
} else {
rows.iter().map(DisplayRow::from_row).collect()
};
Ok::<_, anyhow::Error>(merged)
}
.await;
backend.shutdown().await;
let mut merged = merged?;
for r in &mut merged {
r.foreground = foreground.contains(&r.target_id);
}
if json {
let arr: Vec<serde_json::Value> = merged.iter().map(DisplayRow::to_json).collect();
println!("{}", serde_json::to_string_pretty(&arr)?);
} else if merged.is_empty() {
println!("(no tabs)");
} else {
println!("NAME\tOWNER\tIDLE_S\tFOREGROUND\tURL");
let now = crate::registry::now_epoch_s();
for r in &merged {
let idle = r
.last_used_at_epoch_s
.map(|t| (now - t).max(0).to_string())
.unwrap_or_else(|| "-".to_string());
println!(
"{}\t{}\t{}\t{}\t{}",
r.name,
r.owner,
idle,
if r.foreground { "on" } else { "-" },
r.url
);
}
}
Ok(())
}
async fn adopt(
positional: &str,
target_id: &str,
json: bool,
trace: &mut CommandTrace,
) -> Result<()> {
let target = env_resolver::parse_target(positional)
.with_context(|| format!("parsing `{positional}` as <browser>/<tab>"))?;
let name = target
.tab
.as_deref()
.ok_or_else(|| anyhow!("`tab adopt` requires `<browser>/<name>`, got `{positional}`"))?;
let registry = Registry::open()?;
let resolved = resolve_browser(Some(reassemble_browser_only(positional)?)).await?;
let browser_name = match &resolved.source {
crate::cli::env_resolver::Source::Registered { name } => name.clone(),
_ => {
return Err(anyhow!(
"named tabs require a registered browser; `{positional}` resolved to an external endpoint"
));
}
};
trace
.browser(&browser_name)
.engine(resolved.engine)
.tab_name(name)
.route("tab-adopt");
let _bidi_lock = acquire_bidi_lock_if_needed(®istry, &resolved)?;
let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
let looked_up = async {
let live_ids = backend.live_target_ids().await?;
if !live_ids.contains(target_id) {
return Err(anyhow!(
"target ID `{target_id}` not found among live tabs. \
Use `tab list --all` to see available target IDs."
));
}
let live_targets = backend.live_targets().await?;
Ok::<_, anyhow::Error>(
live_targets
.iter()
.find(|t| t.id == target_id)
.map(|t| t.url.clone())
.unwrap_or_else(|| "about:blank".to_string()),
)
}
.await;
backend.shutdown().await;
let url = looked_up?;
let url = url.as_str();
registry.tab_upsert(&browser_name, name, target_id, url, false)?;
let row = registry
.tab_get(&browser_name, name)?
.ok_or_else(|| anyhow!("tab row missing immediately after upsert"))?;
trace.target_id(&row.target_id);
print_summary(&row, json);
Ok(())
}
struct DisplayRow {
name: String,
owner: &'static str,
url: String,
last_used_at_epoch_s: Option<i64>,
target_id: String,
daemon_created: bool,
foreground: bool,
}
impl DisplayRow {
fn from_row(r: &TabRow) -> Self {
Self {
name: r.name.clone(),
owner: if r.daemon_created { "agent" } else { "user" },
url: r.last_url.clone(),
last_used_at_epoch_s: Some(r.last_used_at_epoch_s),
target_id: r.target_id.clone(),
daemon_created: r.daemon_created,
foreground: false,
}
}
fn from_live(t: &crate::session::backend::LiveTarget) -> Self {
Self {
name: String::new(),
owner: "unnamed",
url: t.url.clone(),
last_used_at_epoch_s: None,
target_id: t.id.clone(),
daemon_created: false,
foreground: false,
}
}
fn to_json(&self) -> serde_json::Value {
json!({
"name": self.name,
"owner": self.owner,
"target_id": self.target_id,
"url": self.url,
"last_used_at_epoch_s": self.last_used_at_epoch_s,
"daemon_created": self.daemon_created,
"foreground": self.foreground,
})
}
}
fn tab_to_json(r: &TabRow) -> serde_json::Value {
json!({
"name": r.name,
"target_id": r.target_id,
"url": r.last_url,
"last_used_at_epoch_s": r.last_used_at_epoch_s,
"daemon_created": r.daemon_created,
})
}
fn print_summary(row: &TabRow, json: bool) {
if json {
println!(
"{}",
serde_json::to_string_pretty(&tab_to_json(row)).unwrap()
);
} else {
println!("{}\t{}", row.name, row.last_url);
}
}
fn reassemble_browser_only(positional: &str) -> Result<String> {
if let Some(idx) = positional.find('/') {
if idx > 0 {
return Ok(positional[..idx].to_string());
}
}
Ok(positional.to_string())
}