feat(tabs): reuse an open tab instead of duplicating a page
- 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:
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user