Skip to main content

browser_control/cli/
tab.rs

1//! `browser-control tab` subcommand: named-tab lifecycle backed by the
2//! SQLite `tabs` table. Commands: `tab open`, `tab list`, `tab adopt`.
3//!
4//! - Close is implicit (sweep-on-read evicts stale rows; LRU recycles
5//!   under budget pressure). Agents don't know when they're done.
6//! - Navigate is folded into `tab open <browser>/<name> <url>`, which
7//!   navigates the existing tab if `url` differs from `last_url`.
8//! - `tab adopt` binds an unnamed live tab (discovered via `tab list --all`)
9//!   to a name so it becomes addressable in page-context commands.
10
11use 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    /// Get-or-create a named tab. Idempotent: re-running with the same
25    /// `<browser>/<name>` returns the existing tab (navigating if `url`
26    /// differs from `last_url`).
27    Open {
28        /// `<browser>` or `<browser>/<name>`. With no `/<name>`, the
29        /// daemon assigns a cute name (`tab-<word>`) and creates fresh.
30        browser: String,
31        /// URL to open or navigate to. Passing a url for an existing named
32        /// tab navigates it (when the url differs from its `last_url`); this
33        /// is the way to navigate — do not eval `location.href`. Omit to
34        /// create a fresh tab at `about:blank`.
35        #[arg(default_value = "")]
36        url: String,
37        /// Emit JSON instead of the one-line text summary.
38        #[arg(long)]
39        json: bool,
40    },
41    /// List tabs for `<browser>`.
42    ///
43    /// By default returns rows in the named-tab registry (those agents
44    /// opened explicitly via `tab open`). With `--all`, returns every
45    /// live top-level tab in the browser — registered rows merged with
46    /// unnamed user tabs (name column empty for the unnamed ones).
47    List {
48        browser: String,
49        /// Include every live tab in the browser, not just registered names.
50        #[arg(long)]
51        all: bool,
52        #[arg(long)]
53        json: bool,
54    },
55    /// Make a tab behave as the focused, visible foreground tab even while
56    /// the window is minimized or the display is locked (games, canvas apps,
57    /// anything that pauses in the background). A small holder process keeps
58    /// it on until `off`, the timeout elapses, the tab closes, or the browser
59    /// exits. Chromium only.
60    ///
61    ///   browser-control tab foreground brave/game on --timeout 2h
62    ///   browser-control tab foreground brave/game off
63    ///   browser-control tab foreground brave off        # every tab
64    Foreground {
65        /// `<browser>/<name>`, or a bare `<browser>` with `off` to stop all.
66        browser: String,
67        /// `on` (default) or `off`.
68        #[arg(default_value = "on")]
69        state: String,
70        /// How long to hold it (`30m`, `2h`, `90s`). Default 1h.
71        #[arg(long, default_value = "1h")]
72        timeout: String,
73        #[arg(long)]
74        json: bool,
75    },
76    /// Internal: the holder process spawned by `tab foreground`.
77    #[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 an existing live tab by target ID, binding it to a name.
85    ///
86    /// Use `tab list --all` to discover unnamed tabs and their target IDs,
87    /// then `tab adopt <browser>/<name> <target-id>` to make them
88    /// addressable via `--browser <browser>/<name>` in page-context commands.
89    Adopt {
90        /// `<browser>/<name>` — the browser and name to assign.
91        browser: String,
92        /// The target ID from `tab list --all`.
93        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(&registry, &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(&registry, &resolved)?;
200    let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
201    let row = crate::session::resolve_tab(&backend, &registry, &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(&registry, &browser_name, &row.target_id, timeout)?;
210        (Some(pid), created)
211    } else {
212        (
213            None,
214            fg::stop_holder(&registry, &browser_name, &row.target_id)?,
215        )
216    };
217    // The holder that is actually running may predate this call with its
218    // own expiry; report that rather than the requested timeout.
219    let expires_in = fg::status(&registry, &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(&registry, &resolved)?;
275    let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
276    let row = session_tabs::tab_open(&backend, &registry, &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        // Capture the daemon-assigned name in the trace too.
282        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    // `tab list` only accepts a bare browser; tabs in the positional don't
290    // make sense here.
291    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(&registry, &resolved)?;
310    let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
311    let foreground = crate::session::foreground::active_targets(&registry, &browser_name)?;
312    let merged = async {
313        let rows = session_tabs::tab_list(&backend, &registry, &browser_name).await?;
314        // With `--all`, fold in every live tab the browser knows about with
315        // an empty `name` column. Registered ids stay as-is; unregistered
316        // ones get synthesized rows. Sorted: named rows first (alpha), then
317        // unnamed (by url).
318        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(&registry, &resolved)?;
398    let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
399    let looked_up = async {
400        // Verify the target ID actually exists in the browser.
401        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        // Get the URL of the live tab for the registry row.
409        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    // daemon_created = false because this is a user-adopted tab.
424    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
433/// Unified row used by `tab list` output. Comes either from a registered
434/// `TabRow` (name + last_used_at populated, owner = "agent"/"user") or a
435/// live `LiveTarget` that has no row yet (name empty, idle "-",
436/// owner = "unnamed").
437struct 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        // One-line: NAME<TAB>URL — the minimum the agent needs to capture.
501        println!("{}\t{}", row.name, row.last_url);
502    }
503}
504
505/// Strip the optional `/<tab>` suffix so the result is parseable as a
506/// bare browser positional — i.e. `brave-twilight/scrape` → `brave-twilight`.
507/// Returns the input verbatim if there's no slash (after position 0).
508fn 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}