feat: improve remote browser tree routing
Testing / remote-protocol-compat (0.9.3) (push) Successful in 43s
Testing / test (push) Successful in 1m1s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 39s
Build & Publish Package / publish (push) Successful in 58s
Package Extension / package-extension (push) Successful in 1m15s

- 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.
This commit is contained in:
2026-06-18 00:12:17 +02:00
parent 371b794170
commit 479a0f1964
22 changed files with 672 additions and 221 deletions
+1 -1
View File
@@ -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 (
+24 -2
View File
@@ -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,
)
+2
View File
@@ -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",
]
+27 -12
View File
@@ -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
+2
View File
@@ -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."""
-187
View File
@@ -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
@@ -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",
]
+84
View File
@@ -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)
+63
View File
@@ -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
+185
View File
@@ -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()
@@ -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
+4
View File
@@ -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:
+12
View File
@@ -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
+22 -3
View File
@@ -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):
+13 -5
View File
@@ -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
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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" }
+24 -2
View File
@@ -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),
+78 -2
View File
@@ -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}
+3 -1
View File
@@ -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
+29 -3
View File
@@ -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"),
Generated
+1 -1
View File
@@ -465,7 +465,7 @@ wheels = [
[[package]]
name = "real-browser-cli"
version = "0.15.4"
version = "0.15.5"
source = { editable = "." }
dependencies = [
{ name = "click" },