7cb2a8b618
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m4s
Testing / test (push) Successful in 1m22s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 1m7s
Package Extension / package-extension (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 1m5s
- Split auth into focused package modules for agent keys, file keys, signing, and post-quantum transport helpers while keeping the public browser_cli.auth import surface intact. - Move transport encoding internals into a package with separate codec and binary-hoisting helpers, preserving browser_cli.transport compatibility. - Extract remote TCP auth/socket helpers and serve challenge setup out of the runtime paths to make connection handling easier to reason about. - Move the extension markdown extractor into a dedicated content/markdown folder with separate root selection, code normalization, renderer, and utils. - Centralize CLI Rich rendering helpers for tab/window tree and table output, and add rendering tests for the shared builders. - Remove local typing ignores in SDK/decorator/script plumbing and bump the package and extension version to 0.15.3.
188 lines
7.6 KiB
Python
188 lines
7.6 KiB
Python
"""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
|