From 3e3b8d529cfb34587083856c863b1d8ce451fda9 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Sun, 14 Jun 2026 13:59:15 +0200 Subject: [PATCH] fix: make navigation no-focus by default - Change nav open and open-wait to avoid activating newly created tabs unless --focus is explicitly requested. - Send background=true for default opens so older or remote extensions also avoid stealing focus even if they ignore the new focus flag. - Remove the redundant --bg flag from navigation and search CLI commands now that no-focus/background behavior is the default. - Thread focus support through the sync SDK, async SDK, tab helpers, and workflow decorators. - Update README and demo usage to document the new default and --focus opt-in. - Bump package and extension metadata to 0.12.3. - Add regression coverage for CLI help, wire payloads, and extension behavior. --- README.md | 6 +-- browser_cli/async_sdk.py | 2 + browser_cli/commands/navigate.py | 14 +++--- browser_cli/commands/search.py | 5 +- browser_cli/sdk/navigation.py | 23 +++++++--- browser_cli/sdk/tabs.py | 6 ++- browser_cli/sdk/workflow_decorators.py | 2 + examples/demo.sh | 4 +- extension/manifest.json | 2 +- extension/src/commands/navigation.ts | 8 ++-- extension/src/types/command-args.ts | 2 + pyproject.toml | 2 +- tests/test_api.py | 14 ++++-- tests/test_commands_cli.py | 51 +++++++++++++++++++-- tests/test_extension_error_page_handling.py | 4 +- uv.lock | 2 +- 16 files changed, 105 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 1ee5a52..2164bbc 100644 --- a/README.md +++ b/README.md @@ -138,9 +138,9 @@ Important: profile aliases are browser-instance aliases, not window aliases. Win ### Navigation (`nav`) ```sh -# Open a URL +# Open a URL (no focus stealing by default) browser-cli nav open https://example.com -browser-cli nav open https://example.com --bg # background, no focus +browser-cli nav open https://example.com --focus # bring opened tab/window forward browser-cli nav open https://example.com --window work # into a named window browser-cli nav open https://example.com --group research # into a tab group (name or ID) @@ -163,7 +163,7 @@ Each search command opens the search results in your browser using the same flag ```sh browser-cli search google openai api -browser-cli search brave rust iterators --bg +browser-cli search brave rust iterators browser-cli search ddg tab groups --window work browser-cli search youtube browser automation browser-cli search yt lo fi diff --git a/browser_cli/async_sdk.py b/browser_cli/async_sdk.py index 049b77b..175fede 100644 --- a/browser_cli/async_sdk.py +++ b/browser_cli/async_sdk.py @@ -84,6 +84,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin): wait: bool = False, timeout: float = 30.0, background: bool = False, + focus: bool = False, window: str | None = None, group: str | None = None, close: bool = False, @@ -95,6 +96,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin): wait=wait, timeout=timeout, background=background, + focus=focus, window=window, group=group, ) diff --git a/browser_cli/commands/navigate.py b/browser_cli/commands/navigate.py index 1611daa..662c9d5 100644 --- a/browser_cli/commands/navigate.py +++ b/browser_cli/commands/navigate.py @@ -10,13 +10,13 @@ def nav_group(): @nav_group.command("open") @click.argument("url") -@click.option("--bg", is_flag=True, help="Open in background (no focus)") +@click.option("--focus", is_flag=True, help="Bring the opened tab/window to the front") @click.option("--window", "window_name", default=None, help="Open in named window") @click.option("--group", "group_name", default=None, help="Open directly into a tab group (name or ID)") @handle_errors -def cmd_open(url, bg, window_name, group_name): - """Open URL in a new tab.""" - client_from_ctx().nav.open(url, background=bg, window=window_name, group=group_name) +def cmd_open(url, focus, window_name, group_name): + """Open URL in a new tab without stealing focus by default.""" + client_from_ctx().nav.open(url, focus=focus, window=window_name, group=group_name) suffix = "" if group_name: suffix = f" in group '{group_name}'" @@ -70,13 +70,13 @@ def cmd_focus(pattern): @nav_group.command("open-wait") @click.argument("url") @click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait for load") -@click.option("--bg", is_flag=True, help="Open in background (no focus)") +@click.option("--focus", is_flag=True, help="Bring the opened tab/window to the front") @click.option("--window", "window_name", default=None, help="Open in named window") @click.option("--group", "group_name", default=None, help="Open in tab group") @handle_errors -def cmd_open_wait(url, timeout, bg, window_name, group_name): +def cmd_open_wait(url, timeout, focus, window_name, group_name): """Open URL in a new tab and wait until fully loaded.""" - tab = client_from_ctx().nav.open_wait(url, timeout=timeout, background=bg, window=window_name, group=group_name) + tab = client_from_ctx().nav.open_wait(url, timeout=timeout, focus=focus, window=window_name, group=group_name) console.print(f"[green]Loaded:[/green] {url}" + (f" — {tab.title}" if tab.title else "")) @nav_group.command("wait") diff --git a/browser_cli/commands/search.py b/browser_cli/commands/search.py index 11c7402..beedb2c 100644 --- a/browser_cli/commands/search.py +++ b/browser_cli/commands/search.py @@ -63,13 +63,12 @@ def search_group(): def _build_command(engine_key: str, help_text: str) -> click.Command: @click.command(engine_key, help=help_text) @click.argument("query", nargs=-1, required=True) - @click.option("--bg", is_flag=True, help="Open in background (no focus)") @click.option("--window", "window", default=None, help="Open in named window") @click.option("--group", "group", default=None, help="Open in tab group (name or ID)") @handle_errors - def _cmd(query, bg, window, group): + def _cmd(query, window, group): terms = " ".join(query) - client_from_ctx().nav.search(engine_key, terms, background=bg, window=window, group=group) + client_from_ctx().nav.search(engine_key, terms, window=window, group=group) suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "") display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize()) console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}") diff --git a/browser_cli/sdk/navigation.py b/browser_cli/sdk/navigation.py index c7c4bbc..a85c47a 100644 --- a/browser_cli/sdk/navigation.py +++ b/browser_cli/sdk/navigation.py @@ -4,8 +4,8 @@ from __future__ import annotations from browser_cli.models import Tab from browser_cli.sdk.base import Namespace, sdk_command -def _open_args(self, url, *, background=False, window=None, group=None): - return {"url": url, "background": background, "window": window, "group": group} +def _open_args(self, url, *, background=False, focus=False, window=None, group=None): + return {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group} def _tab_args(self, tab_id=None): return {"tabId": tab_id} @@ -14,8 +14,16 @@ class NavigationNS(Namespace): """Open URLs, navigate history, and focus tabs.""" @sdk_command("navigate.open", _open_args) - def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None: - """Open *url* in a new tab.""" + def open( + self, + url: str, + *, + background: bool = False, + focus: bool = False, + window: str | None = None, + group: str | None = None, + ) -> None: + """Open *url* in a new tab without stealing OS focus by default.""" def open_wait( self, @@ -23,6 +31,7 @@ class NavigationNS(Namespace): *, timeout: float = 30.0, background: bool = False, + focus: bool = False, window: str | None = None, group: str | None = None, ) -> Tab: @@ -30,7 +39,7 @@ class NavigationNS(Namespace): return self.require_tab( self.command("navigate.open_wait", { "url": url, "timeout": int(timeout * 1000), - "background": background, "window": window, "group": group, + "background": background or not focus, "focus": focus, "window": window, "group": group, }), "navigate.open_wait returned unexpected data", ) @@ -61,7 +70,7 @@ class NavigationNS(Namespace): def search( self, engine: str, query: str, *, - background: bool = False, window: str | None = None, group: str | None = None, + background: bool = False, focus: bool = False, window: str | None = None, group: str | None = None, ) -> None: """Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg').""" from urllib.parse import quote_plus @@ -70,4 +79,4 @@ class NavigationNS(Namespace): if template is None: raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}") url = template.format(query=quote_plus(query)) - self.command("navigate.open", {"url": url, "background": background, "window": window, "group": group}) + self.command("navigate.open", {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group}) diff --git a/browser_cli/sdk/tabs.py b/browser_cli/sdk/tabs.py index 17c25e5..6a05a22 100644 --- a/browser_cli/sdk/tabs.py +++ b/browser_cli/sdk/tabs.py @@ -24,17 +24,19 @@ class TabsNS(Namespace): wait: bool = False, timeout: float = 30.0, background: bool = False, + focus: bool = False, window: str | None = None, group: str | None = None, ) -> Tab: """Open *url* in a new tab and return a bound :class:`Tab`. Set ``wait=True`` to block until the page reaches ``readyState=complete``. + Pass ``focus=True`` to explicitly bring the created tab/window forward. """ if wait: - return self._c.nav.open_wait(url, timeout=timeout, background=background, window=window, group=group) + return self._c.nav.open_wait(url, timeout=timeout, background=background, focus=focus, window=window, group=group) return self.require_tab( - self.command("navigate.open", {"url": url, "background": background, "window": window, "group": group}), + self.command("navigate.open", {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group}), "navigate.open returned unexpected data", ) diff --git a/browser_cli/sdk/workflow_decorators.py b/browser_cli/sdk/workflow_decorators.py index 24b7e2e..edbe698 100644 --- a/browser_cli/sdk/workflow_decorators.py +++ b/browser_cli/sdk/workflow_decorators.py @@ -81,6 +81,7 @@ class WorkflowDecoratorsMixin: wait: bool = False, timeout: float = 30.0, background: bool = False, + focus: bool = False, window: str | None = None, group: str | None = None, close: bool = False, @@ -97,6 +98,7 @@ class WorkflowDecoratorsMixin: wait=wait, timeout=timeout, background=background, + focus=focus, window=window, group=group, ) diff --git a/examples/demo.sh b/examples/demo.sh index 46ebdd2..ebf52e6 100755 --- a/examples/demo.sh +++ b/examples/demo.sh @@ -43,8 +43,8 @@ pause header "3/8 · Create 'research' group and open URLs into it" $CLI groups create research echo "" -$CLI nav open https://example.com --group research --bg -$CLI nav open https://wikipedia.org --group research --bg +$CLI nav open https://example.com --group research +$CLI nav open https://wikipedia.org --group research echo "" echo " Tabs are now open inside the 'research' group in your browser." pause diff --git a/extension/manifest.json b/extension/manifest.json index e0bee18..07758a6 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.12.2", + "version": "0.12.3", "description": "Control your browser from the terminal or Python SDK", "permissions": [ "tabs", diff --git a/extension/src/commands/navigation.ts b/extension/src/commands/navigation.ts index 22d619a..2176bf8 100644 --- a/extension/src/commands/navigation.ts +++ b/extension/src/commands/navigation.ts @@ -17,7 +17,7 @@ export class NavigationCommands extends CommandGroup { "navigate.open_wait": (a: NavOpenWaitArgs) => this.navOpenWait(a), }; - private async navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }: NavOpenArgs) { + private async navOpen({ url, background, focus, window: windowName, windowId: explicitWindowId, group: groupNameOrId }: NavOpenArgs) { let windowId: number | undefined; if (explicitWindowId != null) { windowId = explicitWindowId; @@ -26,7 +26,7 @@ export class NavigationCommands extends CommandGroup { const entry = Object.entries(aliases).find(([, v]) => v === windowName); if (entry) windowId = parseInt(entry[0]); } - const tab = await chrome.tabs.create({ url, active: !background, windowId }); + const tab = await chrome.tabs.create({ url, active: Boolean(focus) && !background, windowId }); if (groupNameOrId != null) { let groupId; try { @@ -115,8 +115,8 @@ export class NavigationCommands extends CommandGroup { throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`); } - private async navOpenWait({ url, timeout = 30000, background, window: windowName, group }: NavOpenWaitArgs = {}) { - const opened = await this.navOpen({ url, background, window: windowName, group }); + private async navOpenWait({ url, timeout = 30000, background, focus, window: windowName, group }: NavOpenWaitArgs = {}) { + const opened = await this.navOpen({ url, background, focus, window: windowName, group }); return await this.navWait({ tabId: opened.id, timeout }); } } diff --git a/extension/src/types/command-args.ts b/extension/src/types/command-args.ts index c023837..2976a44 100644 --- a/extension/src/types/command-args.ts +++ b/extension/src/types/command-args.ts @@ -5,6 +5,7 @@ import type { Job } from './jobs'; export interface NavOpenArgs { url?: string; background?: boolean; + focus?: boolean; window?: string; windowId?: number; group?: string | number; @@ -18,6 +19,7 @@ export interface NavOpenWaitArgs { url?: string; timeout?: number; background?: boolean; + focus?: boolean; window?: string; group?: string | number; } diff --git a/pyproject.toml b/pyproject.toml index d466554..029bc62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.12.2" +version = "0.12.3" description = "Control your real running browser from the terminal or Python SDK" requires-python = ">=3.10" dependencies = [ diff --git a/tests/test_api.py b/tests/test_api.py index d0a9b56..4846145 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -160,7 +160,7 @@ class TestNavigation: b.nav.open("https://example.com") mock_send.assert_called_once_with( "navigate.open", - {"url": "https://example.com", "background": False, "window": None, "group": None}, + {"url": "https://example.com", "background": True, "focus": False, "window": None, "group": None}, profile=None, remote=None, key=None, ) @@ -173,6 +173,10 @@ class TestNavigation: b.nav.open("https://x.com", group="Work") assert mock_send.call_args[0][1]["group"] == "Work" + def test_open_focus_is_explicit(self, b, mock_send): + b.nav.open("https://example.com", focus=True) + assert mock_send.call_args[0][1]["focus"] is True + def test_tabs_open_returns_bound_tab(self, b, mock_send): mock_send.return_value = {"id": 123, "url": "https://example.com"} @@ -183,7 +187,7 @@ class TestNavigation: assert tab._browser is b mock_send.assert_called_once_with( "navigate.open", - {"url": "https://example.com", "background": True, "window": None, "group": None}, + {"url": "https://example.com", "background": True, "focus": False, "window": None, "group": None}, profile=None, remote=None, key=None, @@ -197,7 +201,7 @@ class TestNavigation: assert tab.id == 10 mock_send.assert_called_once_with( "navigate.open_wait", - {"url": "https://example.com", "timeout": 1500, "background": False, "window": None, "group": None}, + {"url": "https://example.com", "timeout": 1500, "background": True, "focus": False, "window": None, "group": None}, profile=None, remote=None, key=None, @@ -1031,7 +1035,7 @@ class TestSDKDecorators: assert mock_send.mock_calls == [ call( "navigate.open_wait", - {"url": "https://example.com", "timeout": 1500, "background": False, "window": None, "group": None}, + {"url": "https://example.com", "timeout": 1500, "background": True, "focus": False, "window": None, "group": None}, profile=None, remote=None, key=None, @@ -1190,7 +1194,7 @@ class TestAsyncBrowserCLI: assert mock_send_async.mock_calls == [ call( "navigate.open", - {"url": "https://example.com", "background": False, "window": None, "group": None}, + {"url": "https://example.com", "background": True, "focus": False, "window": None, "group": None}, profile=None, remote=None, key=None, diff --git a/tests/test_commands_cli.py b/tests/test_commands_cli.py index 8522e17..82aedfc 100644 --- a/tests/test_commands_cli.py +++ b/tests/test_commands_cli.py @@ -346,14 +346,43 @@ def test_cli_perf_profile_ultra(): from browser_cli.commands.navigate import nav_group def test_cli_nav_open(): - result = _run(nav_group, ["open", "https://example.com"], {"id": 42, "url": "https://example.com"}) - assert result.exit_code == 0 - assert "Opened" in result.output + with patch("browser_cli.send_command", return_value={"id": 42, "url": "https://example.com"}) as send_command: + result = CliRunner().invoke(nav_group, ["open", "https://example.com"]) -def test_cli_nav_open_bg(): - result = _run(nav_group, ["open", "https://example.com", "--bg"], {"id": 42}) assert result.exit_code == 0 assert "Opened" in result.output + send_command.assert_called_once_with( + "navigate.open", + {"url": "https://example.com", "background": True, "focus": False, "window": None, "group": None}, + profile=None, + remote=None, + key=None, + ) + +def test_cli_nav_open_has_no_bg_option(): + result = CliRunner().invoke(nav_group, ["open", "--help"]) + + assert result.exit_code == 0 + assert "--bg" not in result.output + +def test_cli_nav_open_wait_has_no_bg_option(): + result = CliRunner().invoke(nav_group, ["open-wait", "--help"]) + + assert result.exit_code == 0 + assert "--bg" not in result.output + +def test_cli_nav_open_focus_is_explicit(): + with patch("browser_cli.send_command", return_value={"id": 42}) as send_command: + result = CliRunner().invoke(nav_group, ["open", "https://example.com", "--focus"]) + + assert result.exit_code == 0 + send_command.assert_called_once_with( + "navigate.open", + {"url": "https://example.com", "background": False, "focus": True, "window": None, "group": None}, + profile=None, + remote=None, + key=None, + ) def test_cli_nav_open_with_group(): result = _run(nav_group, ["open", "https://example.com", "--group", "work"], {"id": 42}) @@ -412,6 +441,18 @@ def test_cli_nav_wait(): assert result.exit_code == 0 assert "Ready" in result.output +# --------------------------------------------------------------------------- +# search commands +# --------------------------------------------------------------------------- + +from browser_cli.commands.search import search_group + +def test_cli_search_has_no_bg_option(): + result = CliRunner().invoke(search_group, ["google", "--help"]) + + assert result.exit_code == 0 + assert "--bg" not in result.output + # --------------------------------------------------------------------------- # navigate commands — with tab_id argument # --------------------------------------------------------------------------- diff --git a/tests/test_extension_error_page_handling.py b/tests/test_extension_error_page_handling.py index edbc7d4..e847a90 100644 --- a/tests/test_extension_error_page_handling.py +++ b/tests/test_extension_error_page_handling.py @@ -111,10 +111,12 @@ def test_large_extension_operations_yield_between_batches(): assert "perf.set_profile" in perf assert "__background" in connection -def test_tab_activation_and_merge_do_not_steal_audible_video_window(): +def test_tab_activation_open_and_merge_do_not_steal_audible_video_window(): tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text() + navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text() assert "await chrome.windows.update(tab.windowId, { focused: true });" not in tabs + assert "active: Boolean(focus) && !background" in navigation assert "windowHasAudibleTabs" in tabs assert "!this.windowHasAudibleTabs(w)" in tabs assert "skippedAudibleWindows" in tabs diff --git a/uv.lock b/uv.lock index fd189f9..77b180c 100644 --- a/uv.lock +++ b/uv.lock @@ -18,7 +18,7 @@ wheels = [ [[package]] name = "browser-cli" -version = "0.12.2" +version = "0.12.3" source = { editable = "." } dependencies = [ { name = "click" },