//! `bsk tab …` subcommands. M6.1 landed `list`; M8 adds create / close / //! select / borrow / return for Agent Window tab management and the //! user-tab borrow ↔ return loop. use std::path::PathBuf; use anyhow::Context; use bsk_protocol::Method; use bsk_protocol::tools::{ TabBorrowParams, TabBorrowResult, TabCloseParams, TabCloseResult, TabCreateParams, TabCreateResult, TabInfo, TabListParams, TabListResult, TabReturnParams, TabReturnResult, TabScope, TabSelectParams, TabSelectResult, }; use clap::{Args, Subcommand, ValueEnum}; use serde::Serialize; use crate::cli::TOOL_IPC_TIMEOUT; use crate::cli::ensure_daemon::ensure_daemon; use crate::cli::error::{CliError, Format}; #[derive(Debug, Clone, Args)] pub struct TabCmd { #[command(subcommand)] pub sub: TabSub, } #[derive(Debug, Clone, Subcommand)] pub enum TabSub { /// List tabs visible to the session, filtered by scope. List(TabListArgs), /// Open a new tab inside the session's Agent Window. Create(TabCreateArgs), /// Close a tab in the session's Agent Window. Close(TabCloseArgs), /// Activate (focus) a tab in the session's Agent Window. Select(TabSelectArgs), /// Borrow a user tab into the session's Agent Window. Borrow(TabBorrowArgs), /// Return a previously borrowed tab to its origin window. Return(TabReturnArgs), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)] pub enum CliScope { /// Tabs owned by the user (any window that is not an Agent Window). User, /// Tabs in this session's Agent Window only. Agent, /// Union of `user` + this session's `agent` tabs. #[default] All, } impl From for TabScope { fn from(value: CliScope) -> Self { match value { CliScope::User => TabScope::User, CliScope::Agent => TabScope::Agent, CliScope::All => TabScope::All, } } } #[derive(Debug, Clone, Args)] pub struct TabListArgs { /// Session id (must be active). #[arg(long)] pub session: String, /// View scope (defaults to `all`). #[arg(long, value_enum, default_value_t = CliScope::All)] pub scope: CliScope, } #[derive(Debug, Clone, Args)] pub struct TabCreateArgs { /// Session id (must be active). #[arg(long)] pub session: String, /// Destination URL (default `chrome://newtab/`). #[arg(long)] pub url: Option, /// Open as a *background* tab (default focuses the new tab). #[arg(long = "no-active", action = clap::ArgAction::SetTrue)] pub no_active: bool, /// Insertion index within the Agent Window's tab strip. #[arg(long)] pub index: Option, } #[derive(Debug, Clone, Args)] pub struct TabCloseArgs { /// Tab id to close (must be in the session's Agent Window). pub tab_id: i64, #[arg(long)] pub session: String, } #[derive(Debug, Clone, Args)] pub struct TabSelectArgs { /// Tab id to activate. pub tab_id: i64, #[arg(long)] pub session: String, } #[derive(Debug, Clone, Args)] pub struct TabBorrowArgs { /// User-window tab id to borrow into the session's Agent Window. pub tab_id: i64, #[arg(long)] pub session: String, /// Skip the inline confirmation overlay when borrowing a tab. /// Accepted for forward compatibility; currently has no effect. #[arg(long = "no-confirm", action = clap::ArgAction::SetTrue)] pub no_confirm: bool, } #[derive(Debug, Clone, Args)] pub struct TabReturnArgs { /// Borrowed tab id to return to its origin window. pub tab_id: i64, #[arg(long)] pub session: String, } pub fn dispatch(cmd: TabCmd, format: Format) -> Result<(), CliError> { let info = ensure_daemon().context("ensure daemon is running")?; match cmd.sub { TabSub::List(args) => run_list(info.sock_path, args, format), TabSub::Create(args) => run_create(info.sock_path, args, format), TabSub::Close(args) => run_close(info.sock_path, args, format), TabSub::Select(args) => run_select(info.sock_path, args, format), TabSub::Borrow(args) => run_borrow(info.sock_path, args, format), TabSub::Return(args) => run_return(info.sock_path, args, format), } } fn print_payload( value: &T, format: Format, human: impl FnOnce(), ) -> Result<(), CliError> { match format { Format::Json => { let json = serde_json::to_string_pretty(value) .map_err(|e| CliError::Local(anyhow::anyhow!(e)))?; println!("{json}"); } Format::Human => human(), } Ok(()) } fn run_create(sock: PathBuf, args: TabCreateArgs, format: Format) -> Result<(), CliError> { let params = TabCreateParams { session_id: args.session, url: args.url, active: if args.no_active { Some(false) } else { None }, index: args.index, }; let reply: TabCreateResult = ipc_call("tab-create-1", Method::ToolTabCreate, sock, params)?; print_payload(&reply, format, || { println!( "tab_id={} window_id={} url={}", reply.tab_id, reply.window_id, if reply.url.is_empty() { "" } else { reply.url.as_str() }, ); }) } fn run_close(sock: PathBuf, args: TabCloseArgs, format: Format) -> Result<(), CliError> { let params = TabCloseParams { session_id: args.session, tab_id: args.tab_id, }; let reply: TabCloseResult = ipc_call("tab-close-1", Method::ToolTabClose, sock, params)?; print_payload(&reply, format, || { println!("closed tab_id={}", reply.tab_id) }) } fn run_select(sock: PathBuf, args: TabSelectArgs, format: Format) -> Result<(), CliError> { let params = TabSelectParams { session_id: args.session, tab_id: args.tab_id, }; let reply: TabSelectResult = ipc_call("tab-select-1", Method::ToolTabSelect, sock, params)?; print_payload(&reply, format, || { println!( "selected tab_id={} window_id={}", reply.tab_id, reply.window_id ) }) } fn run_borrow(sock: PathBuf, args: TabBorrowArgs, format: Format) -> Result<(), CliError> { let params = TabBorrowParams { session_id: args.session, tab_id: args.tab_id, // M8: confirm is ignored on the daemon/extension side; we // surface the CLI flag so the same invocation keeps working // once M10 wires the inline overlay. confirm: if args.no_confirm { Some(false) } else { None }, }; let reply: TabBorrowResult = ipc_call("tab-borrow-1", Method::ToolTabBorrow, sock, params)?; print_payload(&reply, format, || { println!( "borrowed tab_id={} from window={} index={} → agent_window={}", reply.tab_id, reply.original_window_id, reply.original_index, reply.agent_window_id ); }) } fn run_return(sock: PathBuf, args: TabReturnArgs, format: Format) -> Result<(), CliError> { let params = TabReturnParams { session_id: args.session, tab_id: args.tab_id, }; let reply: TabReturnResult = ipc_call("tab-return-1", Method::ToolTabReturn, sock, params)?; print_payload(&reply, format, || { let suffix = if reply.fallback { " (fallback window)" } else { "" }; println!( "returned tab_id={} to window={} index={}{}", reply.tab_id, reply.returned_to_window_id, reply.returned_to_index, suffix ); }) } fn ipc_call( rpc_id_prefix: &'static str, method: Method, sock: PathBuf, params: P, ) -> Result where P: serde::Serialize + Send + 'static, R: serde::de::DeserializeOwned + Send + 'static, { crate::cli::business_rpc::call::( sock, rpc_id_prefix, method, Some(params), TOOL_IPC_TIMEOUT, ) } fn run_list(sock: PathBuf, args: TabListArgs, format: Format) -> Result<(), CliError> { let params = TabListParams { session_id: args.session.clone(), scope: args.scope.into(), }; let reply: TabListResult = call(sock, params)?; match format { Format::Json => { let json = serde_json::to_string_pretty(&reply) .map_err(|e| CliError::Local(anyhow::anyhow!(e)))?; println!("{json}"); } Format::Human => render_tabs_table(&reply.tabs), } Ok(()) } fn render_tabs_table(tabs: &[TabInfo]) { if tabs.is_empty() { println!("(no tabs)"); return; } let rows: Vec<[String; 5]> = tabs .iter() .map(|t| { [ t.tab_id.to_string(), t.scope.map(scope_label).unwrap_or("-").to_string(), t.window_id .map(|w| w.to_string()) .unwrap_or_else(|| "-".into()), truncate(t.title.as_deref().unwrap_or("-"), 40), t.url.clone().unwrap_or_else(|| "-".into()), ] }) .collect(); let headers = ["TAB", "SCOPE", "WIN", "TITLE", "URL"]; let widths: [usize; 5] = std::array::from_fn(|i| { rows.iter() .map(|r| r[i].len()) .max() .unwrap_or(0) .max(headers[i].len()) }); println!( "{: &'static str { match scope { TabScope::User => "user", TabScope::Agent => "agent", TabScope::All => "all", } } fn truncate(s: &str, max: usize) -> String { if s.chars().count() <= max { return s.to_string(); } let mut out: String = s.chars().take(max.saturating_sub(1)).collect(); out.push('…'); out } fn call(sock: PathBuf, params: TabListParams) -> Result { crate::cli::business_rpc::call::( sock, "tab-list", Method::ToolTabList, Some(params), TOOL_IPC_TIMEOUT, ) }