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,
},
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)
}
}
}
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?;
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 rows = session_tabs::tab_list(&backend, ®istry, &browser_name).await?;
let merged = 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()
};
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\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{}", r.name, r.owner, idle, 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 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?;
let url = live_targets
.iter()
.find(|t| t.id == target_id)
.map(|t| t.url.as_str())
.unwrap_or("about:blank");
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,
}
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,
}
}
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,
}
}
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,
})
}
}
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())
}