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    /// Adopt an existing live tab by target ID, binding it to a name.
56    ///
57    /// Use `tab list --all` to discover unnamed tabs and their target IDs,
58    /// then `tab adopt <browser>/<name> <target-id>` to make them
59    /// addressable via `--browser <browser>/<name>` in page-context commands.
60    Adopt {
61        /// `<browser>/<name>` — the browser and name to assign.
62        browser: String,
63        /// The target ID from `tab list --all`.
64        target_id: String,
65        #[arg(long)]
66        json: bool,
67    },
68}
69
70pub async fn run(cmd: TabCmd) -> Result<()> {
71    match cmd {
72        TabCmd::Open { browser, url, json } => {
73            let mut trace = CommandTrace::new("tab-open");
74            let result = open(&browser, &url, json, &mut trace).await;
75            trace.finish(result)
76        }
77        TabCmd::List { browser, all, json } => {
78            let mut trace = CommandTrace::new("tab-list");
79            let result = list(&browser, all, json, &mut trace).await;
80            trace.finish(result)
81        }
82        TabCmd::Adopt {
83            browser,
84            target_id,
85            json,
86        } => {
87            let mut trace = CommandTrace::new("tab-adopt");
88            let result = adopt(&browser, &target_id, json, &mut trace).await;
89            trace.finish(result)
90        }
91    }
92}
93
94async fn open(positional: &str, url: &str, json: bool, trace: &mut CommandTrace) -> Result<()> {
95    let target = env_resolver::parse_target(positional)
96        .with_context(|| format!("parsing `{positional}` as <browser>[/<tab>]"))?;
97    let name = target.tab.as_deref();
98    let url_opt = if url.is_empty() { None } else { Some(url) };
99
100    let registry = Registry::open()?;
101    let resolved = resolve_browser(Some(reassemble_browser_only(positional)?)).await?;
102    let browser_name = match &resolved.source {
103        crate::cli::env_resolver::Source::Registered { name } => name.clone(),
104        _ => {
105            return Err(anyhow!(
106                "named tabs require a registered browser; `{positional}` resolved to an external endpoint"
107            ));
108        }
109    };
110
111    trace.browser(&browser_name).engine(resolved.engine);
112    if let Some(n) = name {
113        trace.tab_name(n);
114    }
115    trace.route("tab-open");
116
117    let _bidi_lock = acquire_bidi_lock_if_needed(&registry, &resolved)?;
118    let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
119    let row = session_tabs::tab_open(&backend, &registry, &browser_name, name, url_opt).await?;
120    trace.target_id(&row.target_id);
121    if name.is_none() {
122        // Capture the daemon-assigned name in the trace too.
123        trace.tab_name(&row.name);
124    }
125    print_summary(&row, json);
126    Ok(())
127}
128
129async fn list(positional: &str, all: bool, json: bool, trace: &mut CommandTrace) -> Result<()> {
130    // `tab list` only accepts a bare browser; tabs in the positional don't
131    // make sense here.
132    let target = env_resolver::parse_target(positional)?;
133    if target.tab.is_some() {
134        return Err(anyhow!(
135            "`tab list` takes a bare `<browser>`, not `<browser>/<tab>`"
136        ));
137    }
138    let registry = Registry::open()?;
139    let resolved = resolve_browser(Some(positional.to_string())).await?;
140    let browser_name = match &resolved.source {
141        crate::cli::env_resolver::Source::Registered { name } => name.clone(),
142        _ => {
143            return Err(anyhow!(
144                "tab list requires a registered browser; `{positional}` resolved to an external endpoint"
145            ));
146        }
147    };
148    trace.browser(&browser_name).engine(resolved.engine);
149    trace.route(if all { "tab-list-all" } else { "tab-list" });
150    let _bidi_lock = acquire_bidi_lock_if_needed(&registry, &resolved)?;
151    let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
152    let rows = session_tabs::tab_list(&backend, &registry, &browser_name).await?;
153
154    // With `--all`, fold in every live tab the browser knows about with
155    // an empty `name` column. Registered ids stay as-is; unregistered
156    // ones get synthesized rows. Sorted: named rows first (alpha), then
157    // unnamed (by url).
158    let merged = if all {
159        let live = backend.live_targets().await?;
160        let known_ids: std::collections::HashSet<&str> =
161            rows.iter().map(|r| r.target_id.as_str()).collect();
162        let mut out: Vec<DisplayRow> = rows.iter().map(DisplayRow::from_row).collect();
163        for t in &live {
164            if !known_ids.contains(t.id.as_str()) {
165                out.push(DisplayRow::from_live(t));
166            }
167        }
168        out
169    } else {
170        rows.iter().map(DisplayRow::from_row).collect()
171    };
172
173    if json {
174        let arr: Vec<serde_json::Value> = merged.iter().map(DisplayRow::to_json).collect();
175        println!("{}", serde_json::to_string_pretty(&arr)?);
176    } else if merged.is_empty() {
177        println!("(no tabs)");
178    } else {
179        println!("NAME\tOWNER\tIDLE_S\tURL");
180        let now = crate::registry::now_epoch_s();
181        for r in &merged {
182            let idle = r
183                .last_used_at_epoch_s
184                .map(|t| (now - t).max(0).to_string())
185                .unwrap_or_else(|| "-".to_string());
186            println!("{}\t{}\t{}\t{}", r.name, r.owner, idle, r.url);
187        }
188    }
189    Ok(())
190}
191
192async fn adopt(
193    positional: &str,
194    target_id: &str,
195    json: bool,
196    trace: &mut CommandTrace,
197) -> Result<()> {
198    let target = env_resolver::parse_target(positional)
199        .with_context(|| format!("parsing `{positional}` as <browser>/<tab>"))?;
200    let name = target
201        .tab
202        .as_deref()
203        .ok_or_else(|| anyhow!("`tab adopt` requires `<browser>/<name>`, got `{positional}`"))?;
204
205    let registry = Registry::open()?;
206    let resolved = resolve_browser(Some(reassemble_browser_only(positional)?)).await?;
207    let browser_name = match &resolved.source {
208        crate::cli::env_resolver::Source::Registered { name } => name.clone(),
209        _ => {
210            return Err(anyhow!(
211                "named tabs require a registered browser; `{positional}` resolved to an external endpoint"
212            ));
213        }
214    };
215
216    trace
217        .browser(&browser_name)
218        .engine(resolved.engine)
219        .tab_name(name)
220        .route("tab-adopt");
221
222    let _bidi_lock = acquire_bidi_lock_if_needed(&registry, &resolved)?;
223    let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
224
225    // Verify the target ID actually exists in the browser.
226    let live_ids = backend.live_target_ids().await?;
227    if !live_ids.contains(target_id) {
228        return Err(anyhow!(
229            "target ID `{target_id}` not found among live tabs. \
230             Use `tab list --all` to see available target IDs."
231        ));
232    }
233
234    // Get the URL of the live tab for the registry row.
235    let live_targets = backend.live_targets().await?;
236    let url = live_targets
237        .iter()
238        .find(|t| t.id == target_id)
239        .map(|t| t.url.as_str())
240        .unwrap_or("about:blank");
241
242    // daemon_created = false because this is a user-adopted tab.
243    registry.tab_upsert(&browser_name, name, target_id, url, false)?;
244    let row = registry
245        .tab_get(&browser_name, name)?
246        .ok_or_else(|| anyhow!("tab row missing immediately after upsert"))?;
247    trace.target_id(&row.target_id);
248    print_summary(&row, json);
249    Ok(())
250}
251
252/// Unified row used by `tab list` output. Comes either from a registered
253/// `TabRow` (name + last_used_at populated, owner = "agent"/"user") or a
254/// live `LiveTarget` that has no row yet (name empty, idle "-",
255/// owner = "unnamed").
256struct DisplayRow {
257    name: String,
258    owner: &'static str,
259    url: String,
260    last_used_at_epoch_s: Option<i64>,
261    target_id: String,
262    daemon_created: bool,
263}
264
265impl DisplayRow {
266    fn from_row(r: &TabRow) -> Self {
267        Self {
268            name: r.name.clone(),
269            owner: if r.daemon_created { "agent" } else { "user" },
270            url: r.last_url.clone(),
271            last_used_at_epoch_s: Some(r.last_used_at_epoch_s),
272            target_id: r.target_id.clone(),
273            daemon_created: r.daemon_created,
274        }
275    }
276    fn from_live(t: &crate::session::backend::LiveTarget) -> Self {
277        Self {
278            name: String::new(),
279            owner: "unnamed",
280            url: t.url.clone(),
281            last_used_at_epoch_s: None,
282            target_id: t.id.clone(),
283            daemon_created: false,
284        }
285    }
286    fn to_json(&self) -> serde_json::Value {
287        json!({
288            "name": self.name,
289            "owner": self.owner,
290            "target_id": self.target_id,
291            "url": self.url,
292            "last_used_at_epoch_s": self.last_used_at_epoch_s,
293            "daemon_created": self.daemon_created,
294        })
295    }
296}
297
298fn tab_to_json(r: &TabRow) -> serde_json::Value {
299    json!({
300        "name": r.name,
301        "target_id": r.target_id,
302        "url": r.last_url,
303        "last_used_at_epoch_s": r.last_used_at_epoch_s,
304        "daemon_created": r.daemon_created,
305    })
306}
307
308fn print_summary(row: &TabRow, json: bool) {
309    if json {
310        println!(
311            "{}",
312            serde_json::to_string_pretty(&tab_to_json(row)).unwrap()
313        );
314    } else {
315        // One-line: NAME<TAB>URL — the minimum the agent needs to capture.
316        println!("{}\t{}", row.name, row.last_url);
317    }
318}
319
320/// Strip the optional `/<tab>` suffix so the result is parseable as a
321/// bare browser positional — i.e. `brave-twilight/scrape` → `brave-twilight`.
322/// Returns the input verbatim if there's no slash (after position 0).
323fn reassemble_browser_only(positional: &str) -> Result<String> {
324    if let Some(idx) = positional.find('/') {
325        if idx > 0 {
326            return Ok(positional[..idx].to_string());
327        }
328    }
329    Ok(positional.to_string())
330}