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 Adopt {
61 browser: String,
63 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(®istry, &resolved)?;
118 let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
119 let row = session_tabs::tab_open(&backend, ®istry, &browser_name, name, url_opt).await?;
120 trace.target_id(&row.target_id);
121 if name.is_none() {
122 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 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(®istry, &resolved)?;
151 let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
152 let rows = session_tabs::tab_list(&backend, ®istry, &browser_name).await?;
153
154 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(®istry, &resolved)?;
223 let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
224
225 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 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 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
252struct 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 println!("{}\t{}", row.name, row.last_url);
317 }
318}
319
320fn 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}