feat(tabs): reuse an open tab instead of duplicating a page
Testing / remote-protocol-compat (0.15.0) (push) Successful in 42s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 47s
Testing / test (push) Successful in 50s

- Add browser_cli/url_match.py with a canonical URL key for tab matching
- Canonicalize scheme, host, default ports, www., trailing slash, query order
- Support reuse modes none, exact, similar and origin via normalize_mode()
- Pick the closest match when several tabs qualify, preferring identical URLs
- Skip the reload when the reused tab already shows the requested page
- Expose nav.reusable_tab() so callers can look up a tab without side effects
- Accept reuse/reuse_domain/reuse_title in tabs.open(), not just nav.open()
- Add --reuse-exact to nav open and nav open-wait; --reuse is now fuzzy
- Default MCP tabs_open to reuse="similar" and return a reused flag
- Tell MCP agents in the server instructions to reuse before opening
- Document the reuse modes in README for MCP, CLI and Python API
- Cover url_match, SDK reuse paths and MCP reuse behaviour with new tests
- Bump package and extension version to 0.16.9
- Refresh uv.lock for the updated dependency set
This commit is contained in:
2026-10-19 10:47:56 +02:00
parent 6c0f77e416
commit 7cb034dfc2
12 changed files with 600 additions and 203 deletions
+8 -6
View File
@@ -13,13 +13,14 @@ def nav_group():
@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)")
@click.option("--reuse", is_flag=True, help="Reuse an existing tab with exactly this URL")
@click.option("--reuse", is_flag=True, help="Reuse an existing tab with a nearly identical URL (ignores fragment, trailing slash, query order)")
@click.option("--reuse-exact", is_flag=True, help="Reuse an existing tab only when its URL matches character for character")
@click.option("--reuse-domain", is_flag=True, help="Reuse an existing tab with the same domain")
@click.option("--reuse-title", default=None, metavar="TEXT", help="Reuse an existing tab whose title contains TEXT")
@handle_errors
def cmd_open(url, focus, window_name, group_name, reuse, reuse_domain, reuse_title):
def cmd_open(url, focus, window_name, group_name, reuse, reuse_exact, reuse_domain, reuse_title):
"""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, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
client_from_ctx().nav.open(url, focus=focus, window=window_name, group=group_name, reuse="exact" if reuse_exact else reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
suffix = ""
if group_name:
suffix = f" in group '{group_name}'"
@@ -76,13 +77,14 @@ def cmd_focus(pattern):
@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")
@click.option("--reuse", is_flag=True, help="Reuse an existing tab with exactly this URL")
@click.option("--reuse", is_flag=True, help="Reuse an existing tab with a nearly identical URL (ignores fragment, trailing slash, query order)")
@click.option("--reuse-exact", is_flag=True, help="Reuse an existing tab only when its URL matches character for character")
@click.option("--reuse-domain", is_flag=True, help="Reuse an existing tab with the same domain")
@click.option("--reuse-title", default=None, metavar="TEXT", help="Reuse an existing tab whose title contains TEXT")
@handle_errors
def cmd_open_wait(url, timeout, focus, window_name, group_name, reuse, reuse_domain, reuse_title):
def cmd_open_wait(url, timeout, focus, window_name, group_name, reuse, reuse_exact, reuse_domain, reuse_title):
"""Open URL in a new tab and wait until fully loaded."""
tab = client_from_ctx().nav.open_wait(url, timeout=timeout, focus=focus, window=window_name, group=group_name, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
tab = client_from_ctx().nav.open_wait(url, timeout=timeout, focus=focus, window=window_name, group=group_name, reuse="exact" if reuse_exact else reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
console.print(f"[green]Loaded:[/green] {url}" + (f" — {tab.title}" if tab.title else ""))
@nav_group.command("wait")
+29 -7
View File
@@ -12,12 +12,12 @@ import json
import os
from collections.abc import Callable
from typing import Any, Literal
from urllib.parse import urlsplit
from browser_cli import BrowserCLI
from browser_cli.mcp.naming import resolve_tool_prefix, tool_name
from browser_cli.mcp.serialization import structured
from browser_cli.mcp.targets import resolve_tab_id
from browser_cli.url_match import find_reusable_tab, needs_navigation, tab_id as read_tab_id, tab_url
ClientFactory = Callable[..., BrowserCLI]
@@ -25,6 +25,11 @@ _SERVER_INSTRUCTIONS = """Control a real, user-visible browser through browser-c
The server is stateless: pass browser, remote, and key on each tool call when a
specific target is required. Tool calls affect the user's actual browser. Read
current tabs/page state instead of assuming IDs or content from an earlier call.
Reuse before opening: tabs_open takes over an already-open tab whose URL is
nearly the same (same site and page, or any tab of that site when you ask for
its root) and reports reused=true with that tab's ID. Pass reuse="none" only
when a separate tab is really wanted, and reuse="origin" to take over any tab
of the site. Use tabs_list(search=...) first when you want to pick the tab.
"""
def _client(factory: ClientFactory, browser: str | None, remote: str | None, key: str | None) -> BrowserCLI:
@@ -74,7 +79,10 @@ def create_server(*, client_factory: ClientFactory = BrowserCLI, tool_prefix: st
remote: str | None = None,
key: str | None = None,
) -> list[dict[str, Any]]:
"""List current tabs, optionally filtering by URL or title substring."""
"""List current tabs, optionally filtering by URL or title substring.
Check this before opening anything so an already-open page gets reused.
"""
tabs = structured(_client(client_factory, browser, remote, key).tabs.list())
if not search:
return tabs
@@ -92,15 +100,29 @@ def create_server(*, client_factory: ClientFactory = BrowserCLI, tool_prefix: st
timeout: float = 30.0,
background: bool = False,
focus: bool = False,
reuse: Literal["similar", "exact", "origin", "none"] = "similar",
browser: str | None = None,
remote: str | None = None,
key: str | None = None,
) -> dict[str, Any]:
"""Open a URL in a new real-browser tab and return its current metadata."""
tab = _client(client_factory, browser, remote, key).tabs.open(
url, wait=wait, timeout=timeout, background=background, focus=focus
)
return structured(tab)
"""Open a URL in the real browser, reusing an already-open tab with a nearly identical URL.
Returns the tab metadata plus ``reused``. ``reuse`` selects the match:
``similar`` (default: same site and page, or any tab of that site when the
requested URL is its root), ``exact``, ``origin`` (any tab of the site), or
``none`` to always create a new tab.
"""
client = _client(client_factory, browser, remote, key)
existing = find_reusable_tab(structured(client.tabs.list()), url, reuse)
if existing is not None:
target = read_tab_id(existing)
if needs_navigation(tab_url(existing), url):
client.nav.to(target, url)
if focus:
client.tabs.activate(target)
return {**structured(client.tabs.status(target)), "reused": True}
tab = client.tabs.open(url, wait=wait, timeout=timeout, background=background, focus=focus)
return {**structured(tab), "reused": False}
@mcp.tool(name=tool_name("tabs_close", prefix))
def tabs_close(
+35 -21
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from browser_cli.models import Tab
from browser_cli.sdk.base import Namespace, sdk_command
from browser_cli.url_match import find_reusable_tab, needs_navigation, normalize_mode
def _open_args(self, url, *, background=False, focus=False, window=None, group=None, **_ignored):
return {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group}
@@ -21,18 +22,20 @@ class NavigationNS(Namespace):
focus: bool = False,
window: str | None = None,
group: str | None = None,
reuse: bool = False,
reuse: bool | str = False,
reuse_domain: bool = False,
reuse_title: str | None = None,
) -> None:
"""Open *url* in a new tab without stealing OS focus by default.
``reuse``/``reuse_domain``/``reuse_title`` navigate an existing matching tab
instead of creating a new one.
instead of creating a new one. ``reuse=True`` matches nearly identical URLs;
pass ``reuse='exact'`` or ``reuse='origin'`` to tighten or widen that.
"""
tab = self._reuse_target(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
tab = self.reusable_tab(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
if tab is not None:
self.to(tab.id, url)
if needs_navigation(tab.url, url):
self.to(tab.id, url)
if focus:
self._c.tabs.activate(tab.id)
return None
@@ -48,14 +51,15 @@ class NavigationNS(Namespace):
focus: bool = False,
window: str | None = None,
group: str | None = None,
reuse: bool = False,
reuse: bool | str = False,
reuse_domain: bool = False,
reuse_title: str | None = None,
) -> Tab:
"""Open *url* in a new tab and block until fully loaded. Returns the Tab."""
tab = self._reuse_target(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
tab = self.reusable_tab(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
if tab is not None:
self.to(tab.id, url)
if needs_navigation(tab.url, url):
self.to(tab.id, url)
if focus:
self._c.tabs.activate(tab.id)
return self._c.tabs.wait_for_load(tab.id, timeout=timeout)
@@ -91,21 +95,31 @@ class NavigationNS(Namespace):
def to(self, tab_id: int, url: str) -> None:
"""Navigate a specific tab to *url* in place."""
def _reuse_target(self, url: str, *, reuse: bool, reuse_domain: bool, reuse_title: str | None):
if not (reuse or reuse_domain or reuse_title):
def reusable_tab(
self,
url: str,
*,
reuse: bool | str = False,
reuse_domain: bool = False,
reuse_title: str | None = None,
) -> Tab | None:
"""Return the open tab that already fits *url*, or ``None``.
``reuse`` takes ``True`` (nearly identical URL), ``'exact'``, ``'similar'``,
``'origin'`` or ``'none'``; ``reuse_title`` falls back to a title substring.
"""
mode = normalize_mode(reuse, reuse_domain)
if mode == "none" and not reuse_title:
return None
from urllib.parse import urlparse
wanted = urlparse(url)
wanted_host = wanted.netloc.lower()
for tab in self._c.tabs.list():
tab_url = tab.url or ""
parsed = urlparse(tab_url)
if reuse and tab_url == url:
return tab
if reuse_domain and wanted_host and parsed.netloc.lower() == wanted_host:
return tab
if reuse_title and reuse_title.lower() in (tab.title or "").lower():
return tab
tabs = self._c.tabs.list()
match = find_reusable_tab(tabs, url, mode)
if match is not None:
return match
if reuse_title:
needle = reuse_title.lower()
for tab in tabs:
if needle in (tab.title or "").lower():
return tab
return None
def search(
+17 -1
View File
@@ -5,6 +5,7 @@ from collections.abc import Callable, Iterable
from browser_cli.models import BrowserCounts, Tab
from browser_cli.sdk.base import Namespace
from browser_cli.url_match import needs_navigation
# Keep SDK-driven bulk closes comfortably below the native-host response
# timeout. The extension can close larger batches, but real browsers may take
@@ -32,14 +33,29 @@ class TabsNS(Namespace):
focus: bool = False,
window: str | None = None,
group: str | None = None,
reuse: bool | str = False,
reuse_domain: bool = False,
reuse_title: 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.
``reuse=True`` navigates an already-open tab with a nearly identical URL
instead of creating a duplicate.
"""
if wait:
return self._c.nav.open_wait(url, timeout=timeout, background=background, focus=focus, window=window, group=group)
return self._c.nav.open_wait(
url, timeout=timeout, background=background, focus=focus, window=window, group=group,
reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title,
)
existing = self._c.nav.reusable_tab(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
if existing is not None:
if needs_navigation(existing.url, url):
self._c.nav.to(existing.id, url)
if focus:
self.activate(existing.id)
return self.status(existing.id)
return self.require_tab(
self.command("navigate.open", {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group}),
"navigate.open returned unexpected data",
+138
View File
@@ -0,0 +1,138 @@
"""Match a requested URL against already-open tabs.
Agents and scripts usually want the tab that already shows a page instead of a
duplicate of it. These helpers answer "is this tab already (nearly) the URL I
am about to open?" without touching the browser.
Modes:
``none`` never reuse a tab
``exact`` only a byte-identical URL
``similar`` same site and page, ignoring fragment, trailing slash, query
order, ``www.`` and default ports; a request for the site root
also matches any tab on that site
``origin`` any tab on the same site, whatever its path
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
from urllib.parse import parse_qsl, urlsplit
ReuseMode = Literal["none", "exact", "similar", "origin"]
REUSE_MODES: tuple[str, ...] = ("none", "exact", "similar", "origin")
_DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
_ROOT_PATHS = {"", "/"}
@dataclass(frozen=True)
class UrlKey:
"""Canonical, comparable form of a URL."""
scheme: str
host: str
port: int | None
path: str
query: tuple[tuple[str, str], ...]
@property
def origin(self) -> tuple[str, str, int | None]:
return (self.scheme, self.host, self.port)
@property
def is_root(self) -> bool:
return self.path == "" and not self.query
def url_key(url: str | None) -> UrlKey | None:
"""Return the canonical key of *url*, or ``None`` when it is not comparable."""
if not url:
return None
parts = urlsplit(url.strip())
if not parts.scheme or not parts.hostname:
return None
scheme = parts.scheme.lower()
host = parts.hostname.lower()
if host.startswith("www.") and len(host) > 4:
host = host[4:]
port = parts.port
if port is not None and _DEFAULT_PORTS.get(scheme) == port:
port = None
path = parts.path.rstrip("/")
query = tuple(sorted(parse_qsl(parts.query, keep_blank_values=True)))
return UrlKey(scheme=scheme, host=host, port=port, path=path, query=query)
def normalize_mode(reuse: bool | str | None, reuse_domain: bool = False) -> ReuseMode:
"""Fold the boolean flags and explicit mode names into one :data:`ReuseMode`."""
if reuse_domain:
return "origin"
if reuse is True:
return "similar"
if reuse is False or reuse is None:
return "none"
mode = str(reuse).lower()
if mode not in REUSE_MODES:
raise ValueError(f"Unknown reuse mode '{reuse}'. Use one of: {', '.join(REUSE_MODES)}")
return mode # type: ignore[return-value]
def urls_match(url: str, candidate: str | None, mode: ReuseMode) -> bool:
"""Report whether *candidate* may be reused for *url* under *mode*."""
if mode == "none" or not candidate:
return False
if mode == "exact":
return candidate == url
wanted, other = url_key(url), url_key(candidate)
if wanted is None or other is None:
return False
if wanted.origin != other.origin:
return False
if mode == "origin":
return True
return wanted.path == other.path or wanted.is_root
def needs_navigation(current: str | None, url: str) -> bool:
"""Report whether a reused tab still has to be navigated to *url*."""
if current == url:
return False
wanted, other = url_key(url), url_key(current)
return wanted is None or other is None or wanted != other
def tab_url(tab: Any) -> str | None:
"""Read a tab URL from an SDK ``Tab`` or a serialized tab dict."""
if isinstance(tab, dict):
value = tab.get("url")
else:
value = getattr(tab, "url", None)
return value if isinstance(value, str) else None
def tab_id(tab: Any) -> int | None:
"""Read a tab ID from an SDK ``Tab`` or a serialized tab dict."""
value = tab.get("id") if isinstance(tab, dict) else getattr(tab, "id", None)
return value if isinstance(value, int) else None
def find_reusable_tab(tabs: Any, url: str, mode: ReuseMode):
"""Return the open tab that best fits *url*, or ``None``.
Closer matches win: an identical URL beats a canonical match, which beats a
looser site-level match; among equally loose matches the tab closest to the
requested page wins.
"""
if mode == "none":
return None
wanted = url_key(url)
best, best_rank = None, None
for tab in tabs or ():
candidate = tab_url(tab)
if not urls_match(url, candidate, mode):
continue
rank = _rank(wanted, candidate)
if best_rank is None or rank < best_rank:
best, best_rank = tab, rank
return best
def _rank(wanted: UrlKey | None, candidate: str | None) -> tuple[int, int]:
"""Lower is a closer match: exact string, canonical, then shallowest path."""
other = url_key(candidate)
if wanted is not None and other is not None and wanted == other:
return (0, 0)
depth = other.path.count("/") if other is not None else 99
return (1, depth)