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
@@ -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