diff --git a/browser_cli/__init__.py b/browser_cli/__init__.py index aeee79b..92e314e 100644 --- a/browser_cli/__init__.py +++ b/browser_cli/__init__.py @@ -173,6 +173,13 @@ class BrowserCLI: """Switch browser focus to a tab by ID.""" self._cmd("tabs.active", {"tabId": tab_id}) + def tabs_status(self, tab_id: int | None = None) -> Tab: + """Return status for the active tab or a specific tab.""" + data = self._cmd("tabs.status", {"tabId": tab_id}) + if not isinstance(data, dict) or "id" not in data: + raise RuntimeError("No tab status returned") + return self._make_tab(data) + def tabs_mute(self, tab_id: int | None = None) -> int: """Mute the active tab or a specific tab. Returns the target tab ID.""" result = self._cmd("tabs.mute", {"tabId": tab_id}) diff --git a/browser_cli/commands/tabs.py b/browser_cli/commands/tabs.py index d7053b4..0acd4d4 100644 --- a/browser_cli/commands/tabs.py +++ b/browser_cli/commands/tabs.py @@ -125,6 +125,23 @@ def tabs_active(tab_id): console.print(f"[green]Switched to tab {tab_id}[/green]") +@tabs_group.command("status") +@click.argument("tab_id", type=int, required=False) +def tabs_status(tab_id): + """Show status for the active tab or a specific tab.""" + tab = _handle("tabs.status", {"tabId": tab_id}) or {} + table = Table(show_header=False) + table.add_column("Field", style="bold cyan") + table.add_column("Value") + table.add_row("ID", str(tab.get("id", ""))) + table.add_row("Window", str(tab.get("windowId", ""))) + table.add_row("Active", "yes" if tab.get("active") else "no") + table.add_row("Muted", "yes" if tab.get("muted") else "no") + table.add_row("Title", tab.get("title") or "") + table.add_row("URL", tab.get("url") or "") + console.print(table) + + @tabs_group.command("filter") @click.argument("pattern") def tabs_filter(pattern): diff --git a/extension/background.js b/extension/background.js index 3057264..b0026f2 100644 --- a/extension/background.js +++ b/extension/background.js @@ -134,6 +134,7 @@ async function dispatch(command, args) { case "tabs.move": return tabsMove(args); case "tabs.active": return tabsActive(args); case "tabs.active_in_window": return tabsActiveInWindow(args); + case "tabs.status": return tabsStatus(args); case "tabs.filter": return tabsFilter(args); case "tabs.count": return tabsCount(args); case "tabs.query": return tabsQuery(args); @@ -275,15 +276,10 @@ async function tabsList() { for (const w of windows) { for (const t of w.tabs) { tabs.push({ - id: t.id, - windowId: t.windowId, + ...tabInfo(t), windowAlias: aliases[t.windowId] || null, - active: t.active, pinned: t.pinned, - title: t.title, - url: t.url, favIconUrl: t.favIconUrl, - groupId: t.groupId >= 0 ? t.groupId : null, }); } } @@ -346,6 +342,11 @@ async function tabsActiveInWindow({ windowId }) { return tabInfo(tab); } +async function tabsStatus({ tabId }) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + return tabInfo(tab); +} + async function tabsFilter({ pattern }) { const all = await chrome.tabs.query({}); return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo); @@ -442,13 +443,13 @@ async function tabsMergeWindows() { } async function tabsMute({ tabId }) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + const tab = await resolveTabForDirectAction(tabId, "mute"); await chrome.tabs.update(tab.id, { muted: true }); return { tabId: tab.id, muted: true }; } async function tabsUnmute({ tabId }) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + const tab = await resolveTabForDirectAction(tabId, "unmute"); await chrome.tabs.update(tab.id, { muted: false }); return { tabId: tab.id, muted: false }; } @@ -1243,6 +1244,19 @@ async function getActiveTab() { || activeTabs[0]; } +async function resolveTabForDirectAction(tabId, actionName) { + if (tabId != null) { + return chrome.tabs.get(tabId); + } + const allTabs = await chrome.tabs.query({}); + if (allTabs.length !== 1) { + throw new Error( + `Refusing to ${actionName} without explicit tab ID when ${allTabs.length} tabs are open` + ); + } + return allTabs[0]; +} + async function resolveGroupId(nameOrId) { const asInt = parseInt(nameOrId); if (!isNaN(asInt)) return asInt; diff --git a/extension/manifest.json b/extension/manifest.json index d804061..afe6445 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.5.10", + "version": "0.5.12", "description": "Control your browser from the terminal via browser-cli", "permissions": [ "tabs", diff --git a/pyproject.toml b/pyproject.toml index 7d68acc..5451f30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.5.10" +version = "0.5.12" description = "Control your real running browser from the terminal via a Chrome extension" requires-python = ">=3.10" dependencies = [ diff --git a/tests/test_tabs.py b/tests/test_tabs.py index 45d5410..59518af 100644 --- a/tests/test_tabs.py +++ b/tests/test_tabs.py @@ -52,6 +52,13 @@ def test_tabs_active_in_window(browser): assert result["windowId"] == active["windowId"] +def test_tabs_status(browser): + result = browser("tabs.status", {}) + assert isinstance(result, dict) + assert "id" in result + assert "muted" in result + + def test_tabs_html(browser, http_tab): html = browser("tabs.html", {"tabId": http_tab["id"]}) assert isinstance(html, str) @@ -133,3 +140,14 @@ def test_tabs_mute_and_unmute(browser, http_tab): listed = browser("tabs.list") listed_tab = next(t for t in listed if t["id"] == http_tab["id"]) assert listed_tab["muted"] is False + status = browser("tabs.status", {"tabId": http_tab["id"]}) + assert status["muted"] is False + + +def test_tabs_mute_requires_explicit_tab_when_multiple_tabs_open(browser): + opened = browser("navigate.open", {"url": "https://example.com", "background": True}) + try: + with pytest.raises(RuntimeError, match="Refusing to mute without explicit tab ID"): + browser("tabs.mute", {}) + finally: + browser("tabs.close", {"tabId": opened["id"]})