From 479a0f1964c30fea62644cd0dc02e3e629c65646 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Thu, 18 Jun 2026 00:12:17 +0200 Subject: [PATCH] feat: improve remote browser tree routing - Allow remote host aliases passed via --browser to fan out for read-only multi-browser SDK paths while preserving strict routing for mutating commands. - Add remote host grouping and scoped profile labels to tabs tree output so global views avoid repeated host prefixes. - Carry browser family metadata through remote targets, tabs, and groups and style tree browser labels by family. - Split CLI rendering helpers into a typed rendering package with dedicated common, label, tabs-tree, and windows-tree modules. - Bump browser-cli and extension versions to 0.15.5. - Cover the new routing and rendering behavior with unit and CLI tests. --- browser_cli/__init__.py | 2 +- browser_cli/async_sdk.py | 26 ++- browser_cli/client/__init__.py | 2 + browser_cli/client/core.py | 39 ++-- browser_cli/client/targets.py | 2 + browser_cli/commands/rendering.py | 187 ------------------ browser_cli/commands/rendering/__init__.py | 58 ++++++ browser_cli/commands/rendering/common.py | 84 ++++++++ browser_cli/commands/rendering/labels.py | 63 ++++++ browser_cli/commands/rendering/tabs_tree.py | 185 +++++++++++++++++ .../commands/rendering/windows_tree.py | 38 ++++ browser_cli/models.py | 4 + browser_cli/sdk/factories.py | 12 ++ browser_cli/sdk/routing.py | 25 ++- browser_cli/serve/control.py | 18 +- extension/manifest.json | 2 +- pyproject.toml | 2 +- tests/test_api.py | 26 ++- tests/test_cli.py | 80 +++++++- tests/test_client.py | 4 +- tests/test_rendering.py | 32 ++- uv.lock | 2 +- 22 files changed, 672 insertions(+), 221 deletions(-) delete mode 100644 browser_cli/commands/rendering.py create mode 100644 browser_cli/commands/rendering/__init__.py create mode 100644 browser_cli/commands/rendering/common.py create mode 100644 browser_cli/commands/rendering/labels.py create mode 100644 browser_cli/commands/rendering/tabs_tree.py create mode 100644 browser_cli/commands/rendering/windows_tree.py diff --git a/browser_cli/__init__.py b/browser_cli/__init__.py index a03b0b0..0ea1d0c 100644 --- a/browser_cli/__init__.py +++ b/browser_cli/__init__.py @@ -36,7 +36,7 @@ Commands are grouped into namespaces on the client: """ from collections.abc import Callable -from browser_cli.client import active_browser_targets, remote_browser_targets, send_command, send_command_async +from browser_cli.client import active_browser_targets, remote_browser_targets, remote_targets_for_alias, send_command, send_command_async from browser_cli.errors import BrowserNotConnected from browser_cli.models import BrowserCounts, Group, Tab from browser_cli.sdk import ( diff --git a/browser_cli/async_sdk.py b/browser_cli/async_sdk.py index b84cdf9..dbe7a87 100644 --- a/browser_cli/async_sdk.py +++ b/browser_cli/async_sdk.py @@ -220,18 +220,40 @@ class AsyncBrowserCLI: async def clients(self) -> list[dict]: return await self._cmd("clients.list", {}) - def tab_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Tab: + def tab_from( + self, + data: dict, + *, + browser_profile: str | None = None, + browser_name: str | None = None, + browser_remote: str | None = None, + browser_type: str | None = None, + browser_group: str | None = None, + ) -> Tab: return self._sync.tab_from( data, browser_profile=browser_profile, browser_name=browser_name, browser_remote=browser_remote, + browser_type=browser_type, + browser_group=browser_group, ) - def group_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Group: + def group_from( + self, + data: dict, + *, + browser_profile: str | None = None, + browser_name: str | None = None, + browser_remote: str | None = None, + browser_type: str | None = None, + browser_group: str | None = None, + ) -> Group: return self._sync.group_from( data, browser_profile=browser_profile, browser_name=browser_name, browser_remote=browser_remote, + browser_type=browser_type, + browser_group=browser_group, ) diff --git a/browser_cli/client/__init__.py b/browser_cli/client/__init__.py index 0a21060..a724821 100644 --- a/browser_cli/client/__init__.py +++ b/browser_cli/client/__init__.py @@ -10,6 +10,7 @@ from browser_cli.client.core import ( remote_browser_targets, remote_browser_targets_async, remote_target_for_alias, + remote_targets_for_alias, send_command, send_command_async, ) @@ -42,6 +43,7 @@ __all__ = [ "remote_browser_targets", "remote_browser_targets_async", "remote_target_for_alias", + "remote_targets_for_alias", "send_command", "send_command_async", ] diff --git a/browser_cli/client/core.py b/browser_cli/client/core.py index 8d280c5..ba6e7ef 100644 --- a/browser_cli/client/core.py +++ b/browser_cli/client/core.py @@ -25,12 +25,16 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse for item in items or []: profile = str(item.get("profile") or "default") display = str(item.get("displayName") or profile) + display_name = _remote_display_name(endpoint, profile, display) + browser_name = item.get("browserName") or item.get("name") targets.append( BrowserTarget( profile=profile, - display_name=_remote_display_name(endpoint, profile, display), + display_name=display_name, socket_path="", remote=endpoint, + browser_name=str(browser_name) if browser_name else None, + display_group=display_name.rsplit(":", 1)[0], ) ) return targets @@ -52,15 +56,21 @@ def _remote_browser_targets(key=None, *, suppress_pq_warning: bool = False) -> l continue return targets -def remote_target_for_alias(alias: str | None) -> BrowserTarget | None: - """Resolve a user-facing remote alias such as 'host:profile' to a target.""" +def remote_targets_for_alias(alias: str | None, key=None) -> list[BrowserTarget]: + """Return remote targets matching a user-facing alias. + + Exact browser aliases such as ``host:profile`` return one target. Endpoint + aliases such as ``host`` or ``host:8765`` may return multiple targets, which + lets read/list SDK commands fan out while command dispatch can still reject + the ambiguous target. + """ if not alias: - return None - targets = _remote_browser_targets() + return [] + targets = _remote_browser_targets(key=key) if key is not None else _remote_browser_targets() for target in targets: endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None if alias in {target.display_name, endpoint_profile}: - return target + return [target] endpoint_matches = [] for target in targets: @@ -69,16 +79,21 @@ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None: remote_host, sep, _remote_port = target.remote.rpartition(":") if alias == target.remote or (sep and alias == remote_host): endpoint_matches.append(target) - if len(endpoint_matches) == 1: - return endpoint_matches[0] - if len(endpoint_matches) > 1: - aliases = [target.profile for target in endpoint_matches] - endpoint = endpoint_matches[0].remote or alias + return endpoint_matches + +def remote_target_for_alias(alias: str | None) -> BrowserTarget | None: + """Resolve a user-facing remote alias such as 'host:profile' to a target.""" + matches = remote_targets_for_alias(alias) + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + aliases = [target.profile for target in matches] + endpoint = matches[0].remote or alias or "remote" examples = "\n".join( f" browser-cli --remote {endpoint} --browser {a} ..." for a in aliases ) - display_aliases = [target.display_name for target in endpoint_matches] + display_aliases = [target.display_name for target in matches] shorthand_examples = "\n".join( f" browser-cli --browser {a} ..." for a in display_aliases diff --git a/browser_cli/client/targets.py b/browser_cli/client/targets.py index 5d84dde..adecd00 100644 --- a/browser_cli/client/targets.py +++ b/browser_cli/client/targets.py @@ -17,6 +17,8 @@ class BrowserTarget: display_name: str socket_path: str remote: str | None = None + browser_name: str | None = None + display_group: str | None = None def is_reachable_unix_endpoint(endpoint: str) -> bool: """Return True when a Unix socket path exists and accepts connections.""" diff --git a/browser_cli/commands/rendering.py b/browser_cli/commands/rendering.py deleted file mode 100644 index ff0d3f1..0000000 --- a/browser_cli/commands/rendering.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Reusable rendering helpers for CLI command modules.""" -from __future__ import annotations - -import shutil -from collections.abc import Callable, Iterable, Sequence -from typing import Any - -from rich.console import Console -from rich.table import Table -from rich.text import Text -from rich.tree import Tree - -Column = tuple[str, Callable[[Any], Any]] - -def item_value(item: Any, name: str, default: Any = None) -> Any: - """Read *name* from a dict-like or attribute object.""" - if isinstance(item, dict): - return item.get(name, default) - return getattr(item, name, default) - -def shorten(value: str | None, limit: int) -> str: - """Return *value* shortened to *limit* cells-ish, using an ellipsis.""" - value = value or "" - return value if len(value) <= limit else value[:max(0, limit - 1)] + "…" - -def terminal_width(console: Console | None = None, *, fallback: int = 120) -> int: - """Best-effort terminal width for interactive and redirected output. - - Rich falls back to 80 columns when stdout is redirected. browser-cli output is - often piped into files for inspection, so also consult ``shutil``/``COLUMNS`` - and prefer the wider value. - """ - rich_width = (console.width if console is not None else 0) or 0 - shell_width = shutil.get_terminal_size((fallback, 20)).columns - return max(rich_width, shell_width) - -def tree_title_limit(*, console: Console | None = None, show_browser: bool = False, show_urls: bool = False) -> int: - """Title width for tree labels, reserving space for branches/IDs/metadata.""" - reserve = 48 if show_urls else 32 - if show_browser: - reserve += 4 - return max(50, terminal_width(console) - reserve) - -def tree_url_limit(title_limit: int, *, console: Console | None = None) -> int: - """URL width for tree labels when URLs are displayed.""" - return max(35, terminal_width(console) - title_limit - 40) - -def no_wrap_text() -> Text: - """Text configured for one-line tree labels with edge ellipsis.""" - return Text(no_wrap=True, overflow="ellipsis") - -def tab_tree_label(tab: Any, *, title_limit: int, show_urls: bool = False, url_limit: int = 55) -> Text: - """Reusable one-line label for a browser tab in tree views.""" - label = no_wrap_text() - label.append(f"[{item_value(tab, 'id')}] ", style="dim") - label.append(shorten(item_value(tab, 'title') or "(untitled)", title_limit)) - if item_value(tab, "active", False): - label.append(" *", style="green") - url = item_value(tab, "url") - if show_urls and url: - label.append(" — ", style="dim") - label.append(shorten(url, url_limit), style="dim") - return label - -def group_tree_label(group_id: int, group: Any, *, title_limit: int) -> Text: - """Reusable one-line label for a browser tab group in tree views.""" - title = item_value(group, "title", "") or f"Group {group_id}" - color = item_value(group, "color", "") or "group" - count = item_value(group, "tab_count", item_value(group, "tabCount", 0)) or 0 - collapsed = bool(item_value(group, "collapsed", False)) - label = no_wrap_text() - label.append(shorten(title, title_limit), style="bold") - meta = [color] - if count: - meta.append(f"{count} tab" + ("" if count == 1 else "s")) - if collapsed: - meta.append("collapsed") - label.append(" (" + ", ".join(meta) + ")", style="dim") - return label - -def tab_sort_key(tab: Any) -> tuple: - """Stable tab ordering across multi-browser responses.""" - group_id = item_value(tab, "group_id", item_value(tab, "groupId")) - return ( - item_value(tab, "browser") or "", - item_value(tab, "window_id", item_value(tab, "windowId", 0)), - item_value(tab, "index", 0) or 0, - group_id if group_id is not None else -1, - item_value(tab, "id", 0), - ) - -def print_tree(tree: Tree, *, console: Console | None = None) -> None: - """Render a Rich tree using the detected full terminal width.""" - Console(width=terminal_width(console)).print(tree) - -def print_table_rows( - rows: Sequence[Any], - columns: Sequence[Column], - *, - console: Console, - empty_message: str, - show_header: bool = True, - header_style: str = "bold cyan", -) -> None: - """Render a small Rich table from arbitrary row objects.""" - if not rows: - console.print(empty_message) - return - table = Table(show_header=show_header, header_style=header_style) - for header, _getter in columns: - table.add_column(header) - for row in rows: - table.add_row(*[str(getter(row) or "") for _header, getter in columns]) - Console(width=terminal_width(console)).print(table) - -def build_tabs_tree( - tabs: Iterable[Any], - groups: Iterable[Any], - *, - console: Console, - show_urls: bool = False, -) -> Tree: - """Build a browser → window → group/tab tree from tab and group responses.""" - tabs = sorted(tabs, key=tab_sort_key) - show_browser = any(item_value(tab, "browser") for tab in tabs) - title_limit = tree_title_limit(console=console, show_browser=show_browser, show_urls=show_urls) - url_limit = tree_url_limit(title_limit, console=console) - group_info = { - ( - item_value(group, "browser") or "local", - item_value(group, "window_id", item_value(group, "windowId")), - item_value(group, "id"), - ): group - for group in groups - } - root = Tree("[bold]Tabs[/bold]") - browser_nodes: dict[str, Tree] = {} - window_nodes: dict[tuple[str, int], Tree] = {} - group_nodes: dict[tuple[str, int, int], Tree] = {} - for tab in tabs: - browser_key = item_value(tab, "browser") or "local" - browser_node = browser_nodes.get(browser_key) - if browser_node is None: - browser_node = root.add(Text(browser_key, style="bold cyan")) if show_browser else root - browser_nodes[browser_key] = browser_node - window_id = item_value(tab, "window_id", item_value(tab, "windowId", 0)) - window_key = (browser_key, window_id) - window_node = window_nodes.get(window_key) - if window_node is None: - window_node = browser_node.add(f"Window {window_id}") - window_nodes[window_key] = window_node - group_id = item_value(tab, "group_id", item_value(tab, "groupId")) - if group_id is None: - window_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit)) - continue - group_key = (browser_key, window_id, group_id) - group_node = group_nodes.get(group_key) - if group_node is None: - group = group_info.get(group_key) or group_info.get((browser_key, None, group_id)) - group_node = window_node.add(group_tree_label(group_id, group, title_limit=title_limit)) - group_nodes[group_key] = group_node - group_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit)) - return root - -def build_windows_tree(windows: Iterable[dict], tabs: Iterable[Any], *, console: Console) -> Tree: - """Build a window → tab tree from window and tab responses.""" - windows = list(windows) - tabs = list(tabs) - title_limit = tree_title_limit(console=console, show_browser=any("browser" in w for w in windows), show_urls=True) - url_limit = tree_url_limit(title_limit, console=console) - root = Tree("[bold]Windows[/bold]") - for window in sorted(windows, key=lambda item: (item.get("browser", ""), item.get("id", 0))): - window_id = window.get("id") - label = f"Window {window_id}" - if window.get("alias"): - label += f" ({window['alias']})" - if window.get("browser"): - label = f"{window['browser']}: " + label - node = root.add(label) - window_tabs = [ - tab for tab in tabs - if item_value(tab, "window_id", item_value(tab, "windowId")) == window_id - and (not window.get("browser") or item_value(tab, "browser") == window.get("browser")) - ] - for tab in sorted(window_tabs, key=lambda item: item_value(item, "index", 0) or 0): - node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit)) - return root diff --git a/browser_cli/commands/rendering/__init__.py b/browser_cli/commands/rendering/__init__.py new file mode 100644 index 0000000..624cd6a --- /dev/null +++ b/browser_cli/commands/rendering/__init__.py @@ -0,0 +1,58 @@ +"""Reusable rendering helpers for CLI command modules.""" +from browser_cli.commands.rendering.common import ( + Column, + item_value, + print_table_rows, + print_tree, + shorten, + terminal_width, + tree_title_limit, + tree_url_limit, +) +from browser_cli.commands.rendering.labels import ( + BROWSER_FAMILY_STYLES, + DEFAULT_BROWSER_STYLE, + DEFAULT_SCOPE, + browser_label_style, + group_tree_label, + no_wrap_text, + scoped_browser_label, + tab_tree_label, +) +from browser_cli.commands.rendering.tabs_tree import ( + TabsTreeBuilder, + browser_label_key, + browser_scope, + build_tabs_tree, + tab_group_id, + tab_sort_key, + tab_window_id, +) +from browser_cli.commands.rendering.windows_tree import build_windows_tree + +__all__ = [ + "BROWSER_FAMILY_STYLES", + "Column", + "DEFAULT_BROWSER_STYLE", + "DEFAULT_SCOPE", + "TabsTreeBuilder", + "browser_label_key", + "browser_label_style", + "browser_scope", + "build_tabs_tree", + "build_windows_tree", + "group_tree_label", + "item_value", + "no_wrap_text", + "print_table_rows", + "print_tree", + "scoped_browser_label", + "shorten", + "tab_group_id", + "tab_sort_key", + "tab_tree_label", + "tab_window_id", + "terminal_width", + "tree_title_limit", + "tree_url_limit", +] diff --git a/browser_cli/commands/rendering/common.py b/browser_cli/commands/rendering/common.py new file mode 100644 index 0000000..5f4ba36 --- /dev/null +++ b/browser_cli/commands/rendering/common.py @@ -0,0 +1,84 @@ +"""Common Rich rendering helpers for CLI command modules.""" +from __future__ import annotations + +import shutil +from collections.abc import Callable, Mapping, Sequence +from typing import TypeVar, cast + +from rich.console import Console +from rich.table import Table +from rich.tree import Tree + +Row = object +CellValue = object +Column = tuple[str, Callable[[Row], CellValue]] +T = TypeVar("T") + + +def item_value(item: Row, name: str, default: T | None = None) -> CellValue | T | None: + """Read *name* from a dict-like or attribute object.""" + if isinstance(item, Mapping): + return cast(Mapping[str, CellValue], item).get(name, default) + return getattr(item, name, default) + +def text_value(value: CellValue | None, default: str = "") -> str: + """Coerce a nullable cell value to display text.""" + return default if value is None else str(value) + +def int_value(value: CellValue | None, default: int = 0) -> int: + """Coerce a cell value to int, falling back when conversion is not possible.""" + try: + return int(cast(int | str | float | bool, value)) + except (TypeError, ValueError): + return default + +def shorten(value: str | None, limit: int) -> str: + """Return *value* shortened to *limit* cells-ish, using an ellipsis.""" + value = value or "" + return value if len(value) <= limit else value[:max(0, limit - 1)] + "…" + +def terminal_width(console: Console | None = None, *, fallback: int = 120) -> int: + """Best-effort terminal width for interactive and redirected output. + + Rich falls back to 80 columns when stdout is redirected. browser-cli output is + often piped into files for inspection, so also consult ``shutil``/``COLUMNS`` + and prefer the wider value. + """ + rich_width = (console.width if console is not None else 0) or 0 + shell_width = shutil.get_terminal_size((fallback, 20)).columns + return max(rich_width, shell_width) + +def tree_title_limit(*, console: Console | None = None, show_browser: bool = False, show_urls: bool = False) -> int: + """Title width for tree labels, reserving space for branches/IDs/metadata.""" + reserve = 48 if show_urls else 32 + if show_browser: + reserve += 4 + return max(50, terminal_width(console) - reserve) + +def tree_url_limit(title_limit: int, *, console: Console | None = None) -> int: + """URL width for tree labels when URLs are displayed.""" + return max(35, terminal_width(console) - title_limit - 40) + +def print_tree(tree: Tree, *, console: Console | None = None) -> None: + """Render a Rich tree using the detected full terminal width.""" + Console(width=terminal_width(console)).print(tree) + +def print_table_rows( + rows: Sequence[Row], + columns: Sequence[Column], + *, + console: Console, + empty_message: str, + show_header: bool = True, + header_style: str = "bold cyan", +) -> None: + """Render a small Rich table from arbitrary row objects.""" + if not rows: + console.print(empty_message) + return + table = Table(show_header=show_header, header_style=header_style) + for header, _getter in columns: + table.add_column(header) + for row in rows: + table.add_row(*[text_value(getter(row)) for _header, getter in columns]) + Console(width=terminal_width(console)).print(table) diff --git a/browser_cli/commands/rendering/labels.py b/browser_cli/commands/rendering/labels.py new file mode 100644 index 0000000..cd9de31 --- /dev/null +++ b/browser_cli/commands/rendering/labels.py @@ -0,0 +1,63 @@ +"""Rich label helpers for tab/window tree renderers.""" +from __future__ import annotations + +from rich.text import Text + +from browser_cli.commands.rendering.common import Row, int_value, item_value, shorten, text_value + +BROWSER_FAMILY_STYLES = { + "firefox": "orange1", + "chrome": "cyan", + "chromium": "cyan", + "brave": "cyan", + "edge": "cyan", + "vivaldi": "cyan", +} +DEFAULT_SCOPE = "local" +DEFAULT_BROWSER_STYLE = "bold cyan" + +def no_wrap_text() -> Text: + """Text configured for one-line tree labels with edge ellipsis.""" + return Text(no_wrap=True, overflow="ellipsis") + +def tab_tree_label(tab: Row, *, title_limit: int, show_urls: bool = False, url_limit: int = 55) -> Text: + """Reusable one-line label for a browser tab in tree views.""" + label = no_wrap_text() + label.append(f"[{text_value(item_value(tab, 'id'))}] ", style="dim") + label.append(shorten(text_value(item_value(tab, 'title'), "(untitled)") or "(untitled)", title_limit)) + if bool(item_value(tab, "active", False)): + label.append(" *", style="green") + url = text_value(item_value(tab, "url")) + if show_urls and url: + label.append(" — ", style="dim") + label.append(shorten(url, url_limit), style="dim") + return label + +def group_tree_label(group_id: object, group: Row | None, *, title_limit: int) -> Text: + """Reusable one-line label for a browser tab group in tree views.""" + title = text_value(item_value(group, "title", "") if group is not None else "") or f"Group {group_id}" + color = text_value(item_value(group, "color", "") if group is not None else "") or "group" + count = int_value(item_value(group, "tab_count", item_value(group, "tabCount", 0)) if group is not None else 0) + collapsed = bool(item_value(group, "collapsed", False)) if group is not None else False + label = no_wrap_text() + label.append(shorten(title, title_limit), style="bold") + meta = [color] + if count: + meta.append(f"{count} tab" + ("" if count == 1 else "s")) + if collapsed: + meta.append("collapsed") + label.append(" (" + ", ".join(meta) + ")", style="dim") + return label + +def browser_label_style(browser_name: str | None) -> str: + """Return a Rich style for a browser family label.""" + name = (browser_name or "").lower() + for family, style in BROWSER_FAMILY_STYLES.items(): + if family in name: + return style + return DEFAULT_BROWSER_STYLE + +def scoped_browser_label(browser: str, scope: str, *, grouped: bool) -> str: + """Shorten browser labels under a remote/local group node.""" + prefix = f"{scope}:" + return browser[len(prefix):] if grouped and browser.startswith(prefix) else browser diff --git a/browser_cli/commands/rendering/tabs_tree.py b/browser_cli/commands/rendering/tabs_tree.py new file mode 100644 index 0000000..e710e04 --- /dev/null +++ b/browser_cli/commands/rendering/tabs_tree.py @@ -0,0 +1,185 @@ +"""Tabs tree renderer.""" +from __future__ import annotations + +from collections.abc import Iterable + +from rich.console import Console +from rich.text import Text +from rich.tree import Tree + +from browser_cli.commands.rendering.common import Row, int_value, item_value, text_value, tree_title_limit, tree_url_limit +from browser_cli.commands.rendering.labels import ( + DEFAULT_BROWSER_STYLE, + DEFAULT_SCOPE, + browser_label_style, + group_tree_label, + scoped_browser_label, + tab_tree_label, +) + +GroupId = object +GroupKey = tuple[str, str, int | None, GroupId] +TreeNodeKey = tuple[str, str] +WindowNodeKey = tuple[str, str, int] +BrowserGroupNodeKey = tuple[str, str, int, GroupId] + +def browser_scope(item: Row) -> str: + """Return the remote/local scope key used by tree renderers.""" + return text_value(item_value(item, "browser_group")) or DEFAULT_SCOPE + +def browser_label_key(item: Row) -> str: + """Return the browser/profile key used by tree renderers.""" + return text_value(item_value(item, "browser")) or DEFAULT_SCOPE + +def tab_window_id(tab: Row) -> int: + """Return a stable window id from object or dict-shaped tab responses.""" + return int_value(item_value(tab, "window_id", item_value(tab, "windowId", 0))) + +def tab_group_id(tab: Row) -> GroupId | None: + """Return a tab group id from object or dict-shaped tab responses.""" + group_id = item_value(tab, "group_id", item_value(tab, "groupId")) + return None if group_id is None else group_id + +def tab_sort_key(tab: Row) -> tuple[str, str, int, int, int, int]: + """Stable tab ordering across multi-browser responses.""" + group_id = tab_group_id(tab) + return ( + browser_scope(tab), + browser_label_key(tab), + tab_window_id(tab), + int_value(item_value(tab, "index", 0)), + int_value(group_id, -1) if group_id is not None else -1, + int_value(item_value(tab, "id", 0)), + ) + +class TabsTreeBuilder: + """Stateful builder for the browser tabs tree. + + The tree has optional scope nodes (remote host/local), then browser/profile, + then window, then browser tab-groups/tabs. Keeping this state in a helper + keeps ``build_tabs_tree`` small while preserving stable node reuse. + """ + + tabs: list[Row] + groups: list[Row] + show_urls: bool + show_browser: bool + group_by_scope: bool + title_limit: int + url_limit: int + root: Tree + group_info: dict[GroupKey, Row] + browser_styles: dict[str, str] + scope_nodes: dict[str, Tree] + browser_nodes: dict[TreeNodeKey, Tree] + window_nodes: dict[WindowNodeKey, Tree] + group_nodes: dict[BrowserGroupNodeKey, Tree] + + def __init__( + self, + tabs: Iterable[Row], + groups: Iterable[Row], + *, + console: Console, + show_urls: bool = False, + ): + self.tabs = sorted(tabs, key=tab_sort_key) + self.groups = list(groups) + self.show_urls = show_urls + self.show_browser = any(bool(item_value(tab, "browser")) for tab in self.tabs) + self.group_by_scope = any(bool(item_value(item, "browser_group")) for item in self.tabs + self.groups) + self.title_limit = tree_title_limit(console=console, show_browser=self.show_browser, show_urls=show_urls) + self.url_limit = tree_url_limit(self.title_limit, console=console) + self.root = Tree("[bold]Tabs[/bold]") + self.group_info = self._group_info() + self.browser_styles = self._browser_styles() + self.scope_nodes = {} + self.browser_nodes = {} + self.window_nodes = {} + self.group_nodes = {} + + def build(self) -> Tree: + for tab in self.tabs: + self._add_tab(tab) + return self.root + + def _group_info(self) -> dict[GroupKey, Row]: + return { + ( + browser_scope(group), + browser_label_key(group), + int_value(item_value(group, "window_id", item_value(group, "windowId")), 0), + item_value(group, "id"), + ): group + for group in self.groups + } + + def _browser_styles(self) -> dict[str, str]: + styles: dict[str, str] = {} + for item in self.tabs + self.groups: + key = browser_label_key(item) + styles.setdefault(key, browser_label_style(text_value(item_value(item, "browser_name")) or None)) + return styles + + def _scope_node(self, scope: str) -> Tree: + if not self.group_by_scope: + return self.root + node = self.scope_nodes.get(scope) + if node is None: + node = self.root.add(Text(scope, style="bold")) + self.scope_nodes[scope] = node + return node + + def _browser_node(self, scope: str, browser: str) -> Tree: + key = (scope, browser) + node = self.browser_nodes.get(key) + if node is None: + parent = self._scope_node(scope) + if self.show_browser: + label = scoped_browser_label(browser, scope, grouped=self.group_by_scope) + node = parent.add(Text(label, style=self.browser_styles.get(browser, DEFAULT_BROWSER_STYLE))) + else: + node = parent + self.browser_nodes[key] = node + return node + + def _window_node(self, scope: str, browser: str, window_id: int) -> Tree: + key = (scope, browser, window_id) + node = self.window_nodes.get(key) + if node is None: + node = self._browser_node(scope, browser).add(f"Window {window_id}") + self.window_nodes[key] = node + return node + + def _group_node(self, scope: str, browser: str, window_id: int, group_id: GroupId, parent: Tree) -> Tree: + key = (scope, browser, window_id, group_id) + node = self.group_nodes.get(key) + if node is None: + group = self.group_info.get(key) or self.group_info.get((scope, browser, None, group_id)) + node = parent.add(group_tree_label(group_id, group, title_limit=self.title_limit)) + self.group_nodes[key] = node + return node + + def _add_tab(self, tab: Row) -> None: + scope = browser_scope(tab) + browser = browser_label_key(tab) + window_id = tab_window_id(tab) + window_node = self._window_node(scope, browser, window_id) + group_id = tab_group_id(tab) + parent = window_node if group_id is None else self._group_node(scope, browser, window_id, group_id, window_node) + parent.add(tab_tree_label( + tab, + title_limit=self.title_limit, + show_urls=self.show_urls, + url_limit=self.url_limit, + )) + +def build_tabs_tree( + tabs: Iterable[Row], + groups: Iterable[Row], + *, + console: Console, + show_urls: bool = False, +) -> Tree: + """Build a remote/local → browser → window → group/tab tree from tab responses.""" + return TabsTreeBuilder(tabs, groups, console=console, show_urls=show_urls).build() diff --git a/browser_cli/commands/rendering/windows_tree.py b/browser_cli/commands/rendering/windows_tree.py new file mode 100644 index 0000000..f11ffea --- /dev/null +++ b/browser_cli/commands/rendering/windows_tree.py @@ -0,0 +1,38 @@ +"""Windows tree renderer.""" +from __future__ import annotations + +from collections.abc import Iterable, Mapping + +from rich.console import Console +from rich.tree import Tree + +from browser_cli.commands.rendering.common import Row, int_value, item_value, text_value, tree_title_limit, tree_url_limit +from browser_cli.commands.rendering.labels import tab_tree_label + +WindowRow = Mapping[str, object] + +def build_windows_tree(windows: Iterable[WindowRow], tabs: Iterable[Row], *, console: Console) -> Tree: + """Build a window → tab tree from window and tab responses.""" + windows = list(windows) + tabs = list(tabs) + title_limit = tree_title_limit(console=console, show_browser=any("browser" in w for w in windows), show_urls=True) + url_limit = tree_url_limit(title_limit, console=console) + root = Tree("[bold]Windows[/bold]") + for window in sorted(windows, key=lambda item: (text_value(item.get("browser")), int_value(item.get("id")))): + window_id = int_value(window.get("id")) + label = f"Window {window_id}" + alias = text_value(window.get("alias")) + browser = text_value(window.get("browser")) + if alias: + label += f" ({alias})" + if browser: + label = f"{browser}: " + label + node = root.add(label) + window_tabs = [ + tab for tab in tabs + if int_value(item_value(tab, "window_id", item_value(tab, "windowId"))) == window_id + and (not browser or text_value(item_value(tab, "browser")) == browser) + ] + for tab in sorted(window_tabs, key=lambda item: int_value(item_value(item, "index", 0))): + node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit)) + return root diff --git a/browser_cli/models.py b/browser_cli/models.py index 6b5f1a4..34eb7b2 100644 --- a/browser_cli/models.py +++ b/browser_cli/models.py @@ -46,6 +46,8 @@ class Tab: group_id: int | None = None index: int = 0 browser: str | None = None + browser_name: str | None = None + browser_group: str | None = None _browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False) def _b(self) -> BoundBrowser: @@ -152,6 +154,8 @@ class Group: tab_count: int window_id: int | None = None browser: str | None = None + browser_name: str | None = None + browser_group: str | None = None _browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False) def _b(self) -> BoundBrowser: diff --git a/browser_cli/sdk/factories.py b/browser_cli/sdk/factories.py index da6a66b..244afc5 100644 --- a/browser_cli/sdk/factories.py +++ b/browser_cli/sdk/factories.py @@ -28,6 +28,8 @@ class FactoryMixin: browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None, + browser_type: str | None = None, + browser_group: str | None = None, ) -> Tab: tab = Tab( id=data["id"], @@ -39,6 +41,8 @@ class FactoryMixin: group_id=data.get("groupId") or None, index=data.get("index", 0) or 0, browser=browser_name, + browser_name=browser_type, + browser_group=browser_group, ) client = cast(_FactoryClient, self) tab._browser = self if browser_profile is None else cast(Any, type(self))( @@ -62,6 +66,8 @@ class FactoryMixin: browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None, + browser_type: str | None = None, + browser_group: str | None = None, ) -> Group: group = Group( id=data["id"], @@ -71,6 +77,8 @@ class FactoryMixin: tab_count=data.get("tabCount", 0), window_id=data.get("windowId"), browser=browser_name, + browser_name=browser_type, + browser_group=browser_group, ) client = cast(_FactoryClient, self) group._browser = self if browser_profile is None else cast(Any, type(self))( @@ -88,6 +96,8 @@ class FactoryMixin: browser_profile=target.profile if target else None, browser_name=target.display_name if target else None, browser_remote=target.remote if target else None, + browser_type=getattr(target, "browser_name", None) if target else None, + browser_group=getattr(target, "display_group", None) if target else None, ) def group_from_target(self, data: dict, target) -> Group: @@ -97,6 +107,8 @@ class FactoryMixin: browser_profile=target.profile if target else None, browser_name=target.display_name if target else None, browser_remote=target.remote if target else None, + browser_type=getattr(target, "browser_name", None) if target else None, + browser_group=getattr(target, "display_group", None) if target else None, ) @staticmethod diff --git a/browser_cli/sdk/routing.py b/browser_cli/sdk/routing.py index 1728084..6da2354 100644 --- a/browser_cli/sdk/routing.py +++ b/browser_cli/sdk/routing.py @@ -37,6 +37,20 @@ _UNSET = object() def _browser_cli_package(): return sys.modules.get("browser_cli") or importlib.import_module("browser_cli") +def _with_profile_display(targets: list[BrowserTarget]) -> list[BrowserTarget]: + """Use profile-only labels when a command is already scoped to one remote.""" + return [ + BrowserTarget( + profile=target.profile, + display_name=target.profile if target.remote else target.display_name, + socket_path=target.socket_path, + remote=target.remote, + browser_name=target.browser_name, + display_group=None, + ) + for target in targets + ] + class RoutingMixin: """Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``. @@ -51,10 +65,15 @@ class RoutingMixin: def _multi_browser_targets(self) -> list[BrowserTarget]: client = self._client package = _browser_cli_package() - if client._browser is not None: + if client._browser is not None and not client._remote: + targets = package.remote_targets_for_alias(client._browser, key=client._key) + if len(targets) <= 1: + return [] + targets = _with_profile_display(targets) + elif client._browser is not None: return [] - if client._remote: - targets = package.remote_browser_targets(client._remote, key=client._key) + elif client._remote: + targets = _with_profile_display(package.remote_browser_targets(client._remote, key=client._key)) else: targets = package.active_browser_targets() if len(targets) <= 1 and not any(target.remote for target in targets): diff --git a/browser_cli/serve/control.py b/browser_cli/serve/control.py index 1b3a7eb..2666af5 100644 --- a/browser_cli/serve/control.py +++ b/browser_cli/serve/control.py @@ -16,11 +16,19 @@ class ServeControlMixin: async def handle_control_command(self, msg: dict) -> bool: if self.command == "browser-cli.targets": - from browser_cli.client import active_browser_targets - targets = [ - {"profile": target.profile, "displayName": target.display_name} - for target in active_browser_targets(include_remotes=False) - ] + from browser_cli.client import active_browser_targets, send_command + targets = [] + for target in active_browser_targets(include_remotes=False): + item = {"profile": target.profile, "displayName": target.display_name} + try: + clients = send_command("clients.list", profile=target.profile, suppress_pq_warning=True) + if clients: + browser_name = clients[0].get("name") + if browser_name: + item["browserName"] = browser_name + except Exception: + pass + targets.append(item) await self.send_ok(targets, self.command) log_request(self.addr, self.command, None, "OK") return True diff --git a/extension/manifest.json b/extension/manifest.json index 0111c6e..8155351 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.15.4", + "version": "0.15.5", "description": "Control your browser from the terminal or Python SDK", "browser_specific_settings": { "gecko": { diff --git a/pyproject.toml b/pyproject.toml index 0a58ba4..1f27c34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "real-browser-cli" -version = "0.15.4" +version = "0.15.5" description = "Control your real running browser from the terminal or Python SDK" readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_api.py b/tests/test_api.py index 3a7af80..0d8718c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -470,7 +470,7 @@ class TestTabs: tabs = b.tabs.list() tabs[0].close() - assert [tab.browser for tab in tabs] == ["host:work"] + assert [tab.browser for tab in tabs] == ["work"] assert mock_send.call_args_list == [ call("tabs.list", {}, profile="work", remote="host:8765", key=None), call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None), @@ -491,6 +491,28 @@ class TestTabs: call("tabs.close", {"tabId": 10}, profile="work", remote="browser-host.example", key="agent"), ] + def test_tabs_list_browser_host_alias_fans_out_to_remote_targets(self, mock_send): + b = BrowserCLI(browser="browser-host.example", key="agent") + with patch( + "browser_cli.remote_targets_for_alias", + return_value=[ + BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", browser_name="Chrome"), + BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", browser_name="Firefox"), + ], + ): + mock_send.side_effect = [[TAB_DATA], [{**TAB_DATA, "id": 11}], None] + tabs = b.tabs.list() + tabs[1].close() + + assert [tab.browser for tab in tabs] == ["main", "work"] + assert [tab.browser_name for tab in tabs] == ["Chrome", "Firefox"] + assert [tab.browser_group for tab in tabs] == [None, None] + assert mock_send.call_args_list == [ + call("tabs.list", {}, profile="main", remote="browser-host.example:8765", key="agent"), + call("tabs.list", {}, profile="work", remote="browser-host.example:8765", key="agent"), + call("tabs.close", {"tabId": 11}, profile="work", remote="browser-host.example:8765", key="agent"), + ] + def test_tabs_active_returns_active_tab(self, b, mock_send): mock_send.side_effect = [[TAB_DATA], TAB_DATA] @@ -659,7 +681,7 @@ class TestGroups: groups = b.groups.list() groups[0].close() - assert [group.browser for group in groups] == ["host:work"] + assert [group.browser for group in groups] == ["work"] assert mock_send.call_args_list == [ call("group.list", {}, profile="work", remote="host:8765", key=None), call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None), diff --git a/tests/test_cli.py b/tests/test_cli.py index 72ac57d..ea7d45f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,7 +4,7 @@ import os import sys from click.testing import CliRunner -from unittest.mock import patch +from unittest.mock import call, patch from browser_cli.cli import main, _project_version from browser_cli.client import BrowserTarget @@ -379,7 +379,8 @@ def test_tabs_list_with_remote_uses_only_remote_targets(): result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"]) assert result.exit_code == 0 - assert "remote-host:work" in result.output + assert "work" in result.output + assert "remote-host:work" not in result.output assert "Remote" in result.output send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765", key=None) @@ -400,6 +401,81 @@ def test_tabs_list_with_explicit_browser_does_not_show_browser_column(): assert "Browser" not in result.output send_command.assert_called_once_with("tabs.list", {}, profile="work", remote=None, key=None) +def test_tabs_tree_with_browser_host_alias_fans_out_to_remote_targets(): + targets = [ + BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", browser_name="Chrome"), + BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", browser_name="Firefox"), + ] + + def fake_send_command(command, args=None, profile=None, remote=None, key=None): + assert remote == "browser-host.example:8765" + if command == "tabs.list": + return [{ + "id": 1 if profile == "main" else 2, + "windowId": 1, + "active": True, + "index": 0, + "title": f"{profile} tab", + "url": "https://example.com", + }] + if command == "group.list": + return [] + raise AssertionError(command) + + with patch("browser_cli.remote_targets_for_alias", return_value=targets), patch( + "browser_cli.send_command", side_effect=fake_send_command + ) as send_command: + result = CliRunner().invoke(main, ["--browser", "browser-host.example", "tabs", "tree"]) + + assert result.exit_code == 0 + assert "main" in result.output + assert "work" in result.output + assert "browser-host.example:main" not in result.output + assert "browser-host.example:work" not in result.output + assert "main tab" in result.output + assert "work tab" in result.output + assert send_command.call_args_list == [ + call("tabs.list", {}, profile="main", remote="browser-host.example:8765", key=None), + call("tabs.list", {}, profile="work", remote="browser-host.example:8765", key=None), + call("group.list", {}, profile="main", remote="browser-host.example:8765", key=None), + call("group.list", {}, profile="work", remote="browser-host.example:8765", key=None), + ] + +def test_tabs_tree_unscoped_groups_remote_targets_by_host(): + targets = [ + BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"), + BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"), + ] + + def fake_send_command(command, args=None, profile=None, remote=None, key=None): + assert remote == "browser-host.example:8765" + if command == "tabs.list": + return [{ + "id": 1 if profile == "main" else 2, + "windowId": 1, + "active": True, + "index": 0, + "title": f"{profile} tab", + "url": "https://example.com", + }] + if command == "group.list": + return [] + raise AssertionError(command) + + with patch("browser_cli.active_browser_targets", return_value=targets), patch( + "browser_cli.send_command", side_effect=fake_send_command + ): + result = CliRunner().invoke(main, ["tabs", "tree"]) + + assert result.exit_code == 0 + assert "browser-host.example" in result.output + assert "main" in result.output + assert "work" in result.output + assert "browser-host.example:main" not in result.output + assert "browser-host.example:work" not in result.output + assert "main tab" in result.output + assert "work tab" in result.output + def test_tabs_count_multi_browser_shows_total(): counts = {"default": 3, "work": 4} diff --git a/tests/test_client.py b/tests/test_client.py index 64149f9..096bb16 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -342,7 +342,7 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path): def fake_send_command(command, args=None, profile=None, remote=None, key=None): assert command == "browser-cli.targets" assert remote == endpoint - return [{"profile": "work", "displayName": "work"}] + return [{"profile": "work", "displayName": "work", "browserName": "Firefox"}] monkeypatch.setattr("browser_cli.client.core.send_command", fake_send_command) @@ -352,6 +352,8 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path): assert targets[0].profile == "work" assert targets[0].display_name == "browser-host.example:work" assert targets[0].remote == endpoint + assert targets[0].browser_name == "Firefox" + assert targets[0].display_group == "browser-host.example" def test_looks_like_domain(): assert _looks_like_domain("browsercli.yiprawr.dev") is True diff --git a/tests/test_rendering.py b/tests/test_rendering.py index 95b24bc..bd95c71 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -5,15 +5,26 @@ from rich.tree import Tree from browser_cli.models import Tab from browser_cli.commands import rendering +from browser_cli.commands.rendering import common def test_shorten_uses_ellipsis(): assert rendering.shorten("abcdef", 4) == "abc…" assert rendering.shorten("abc", 4) == "abc" def test_terminal_width_prefers_shell_width_when_rich_is_redirected(monkeypatch): - monkeypatch.setattr(rendering.shutil, "get_terminal_size", lambda fallback: terminal_size((140, 20))) + monkeypatch.setattr(common.shutil, "get_terminal_size", lambda fallback: terminal_size((140, 20))) assert rendering.terminal_width(Console(width=80)) == 140 +def test_browser_label_style_distinguishes_browser_families(): + assert rendering.browser_label_style("Firefox") == "orange1" + assert rendering.browser_label_style("Chrome") == "cyan" + assert rendering.browser_label_style(None) == "bold cyan" + +def test_scoped_browser_label_strips_repeated_remote_prefix(): + assert rendering.scoped_browser_label("browser-host.example:work", "browser-host.example", grouped=True) == "work" + assert rendering.scoped_browser_label("work", "browser-host.example", grouped=True) == "work" + assert rendering.scoped_browser_label("browser-host.example:work", "browser-host.example", grouped=False) == "browser-host.example:work" + def test_tab_tree_label_is_reusable_no_wrap_text(): tab = type("Tab", (), {"id": 1, "title": "abcdef", "active": True, "url": "https://example.com"})() label = rendering.tab_tree_label(tab, title_limit=4, show_urls=True, url_limit=12) @@ -29,8 +40,8 @@ def test_print_tree_uses_detected_width(monkeypatch): widths.append(kwargs.get("width")) super().__init__(*args, **kwargs) - monkeypatch.setattr(rendering, "Console", CapturingConsole) - monkeypatch.setattr(rendering, "terminal_width", lambda console=None: 132) + monkeypatch.setattr(common, "Console", CapturingConsole) + monkeypatch.setattr(common, "terminal_width", lambda console=None: 132) rendering.print_tree(Tree("Root")) assert widths == [132] @@ -48,6 +59,21 @@ def test_build_tabs_tree_groups_by_browser_window_and_group(): assert "collapsed" in text assert "Inside" in text +def test_build_tabs_tree_groups_remote_browsers_by_scope(): + tabs = [ + Tab(id=1, window_id=5, active=False, muted=False, title="Remote A", url="https://example.com/a", index=0, browser="main", browser_group="browser-host.example"), + Tab(id=2, window_id=6, active=False, muted=False, title="Remote B", url="https://example.com/b", index=0, browser="work", browser_group="browser-host.example"), + Tab(id=3, window_id=7, active=False, muted=False, title="Local", url="https://example.com/local", index=0, browser="local"), + ] + tree = rendering.build_tabs_tree(tabs, [], console=Console(width=120)) + text = "\n".join(str(line) for line in tree.__rich_console__(Console(width=120), Console(width=120).options)) + assert "browser-host.example" in text + assert "main" in text + assert "work" in text + assert "browser-host.example:main" not in text + assert "browser-host.example:work" not in text + assert "Local" in text + def test_build_windows_tree_keeps_multi_browser_windows_separate(): tabs = [ Tab(id=1, window_id=5, active=False, muted=False, title="Work Tab", url="https://example.com/work", index=0, browser="work"), diff --git a/uv.lock b/uv.lock index 7726b98..a69b2ee 100644 --- a/uv.lock +++ b/uv.lock @@ -465,7 +465,7 @@ wheels = [ [[package]] name = "real-browser-cli" -version = "0.15.4" +version = "0.15.5" source = { editable = "." } dependencies = [ { name = "click" },