feat(cli): improve tab and window tree rendering

- Add shared rendering helpers for width-aware tree labels, truncation, and no-wrap Rich text.
- Preserve tab index and group window metadata through the extension and SDK factories.
- Render tab trees in browser/window/index order with grouped tab details and optional shortened URLs.
- Reuse the tab tree labels in window trees to keep output compact and consistent.
- Cover legacy missing-index responses, grouped/collapsed tabs, URL display, and rendering helpers with tests.
This commit is contained in:
2026-06-15 01:04:02 +02:00
parent 657b1b0923
commit 0b43408a8d
9 changed files with 409 additions and 181 deletions
+55
View File
@@ -0,0 +1,55 @@
"""Reusable rendering helpers for CLI command modules."""
from __future__ import annotations
import shutil
from rich.console import Console
from rich.text import Text
from rich.tree import Tree
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, *, 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"[{tab.id}] ", style="dim")
label.append(shorten(tab.title or "(untitled)", title_limit))
if tab.active:
label.append(" *", style="green")
if show_urls and tab.url:
label.append("", style="dim")
label.append(shorten(tab.url, url_limit), style="dim")
return label
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)
+168 -129
View File
@@ -2,78 +2,117 @@ import base64
import binascii import binascii
import click import click
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
from browser_cli.commands.rendering import no_wrap_text, print_tree, shorten, tab_tree_label, tree_title_limit, tree_url_limit
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from rich.text import Text
from rich.tree import Tree from rich.tree import Tree
console = Console() console = Console()
def _group_tree_label(group_id: int, group, *, title_limit: int) -> Text:
title = getattr(group, "title", "") or f"Group {group_id}"
color = getattr(group, "color", "") or "group"
count = getattr(group, "tab_count", 0) or 0
collapsed = bool(getattr(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):
return (
tab.browser or "",
tab.window_id,
getattr(tab, "index", 0),
tab.group_id if tab.group_id is not None else -1,
tab.id,
)
def _print_tabs(tabs, *, show_browser: bool = False) -> None: def _print_tabs(tabs, *, show_browser: bool = False) -> None:
if not tabs: if not tabs:
console.print("[yellow]No tabs found[/yellow]") console.print("[yellow]No tabs found[/yellow]")
return return
table = Table(show_header=True, header_style="bold cyan") table = Table(show_header=True, header_style="bold cyan")
if show_browser: if show_browser:
table.add_column("Browser", no_wrap=True) table.add_column("Browser", no_wrap=True)
table.add_column("ID", style="dim", no_wrap=True) table.add_column("ID", style="dim", no_wrap=True)
table.add_column("Window", no_wrap=True) table.add_column("Window", no_wrap=True)
table.add_column("Active", width=7) table.add_column("Active", width=7)
table.add_column("Muted", width=7) table.add_column("Muted", width=7)
table.add_column("Title") table.add_column("Title")
table.add_column("URL") table.add_column("URL")
for t in tabs: for t in tabs:
active = "[green]✓[/green]" if t.active else "" active = "[green]✓[/green]" if t.active else ""
muted = "[yellow]✓[/yellow]" if t.muted else "" muted = "[yellow]✓[/yellow]" if t.muted else ""
row = [ row = [
(t.browser or "") if show_browser else None, (t.browser or "") if show_browser else None,
str(t.id), str(t.id),
str(t.window_id), str(t.window_id),
active, active,
muted, muted,
(t.title or "")[:60], (t.title or "")[:60],
(t.url or "")[:80], (t.url or "")[:80],
] ]
table.add_row(*[value for value in row if value is not None]) table.add_row(*[value for value in row if value is not None])
console.print(table) console.print(table)
@click.group("tabs") @click.group("tabs")
def tabs_group(): def tabs_group():
"""Manage browser tabs.""" """Manage browser tabs."""
@tabs_group.command("list") @tabs_group.command("list")
@handle_errors @handle_errors
def tabs_list(): def tabs_list():
"""List all open tabs across all windows.""" """List all open tabs across all windows."""
tabs = client_from_ctx().tabs.list() tabs = client_from_ctx().tabs.list()
_print_tabs(tabs, show_browser=any(t.browser for t in tabs)) _print_tabs(tabs, show_browser=any(t.browser for t in tabs))
@tabs_group.command("tree") @tabs_group.command("tree")
@click.option("--urls", "show_urls", is_flag=True, help="Show shortened URLs next to tab titles")
@handle_errors @handle_errors
def tabs_tree(): def tabs_tree(show_urls):
"""Show tabs grouped as a window/group tree.""" """Show tabs grouped as a window/group tree."""
tabs = sorted(client_from_ctx().tabs.list(), key=lambda t: ((t.browser or ""), t.window_id, t.group_id if t.group_id is not None else -1, t.index)) client = client_from_ctx()
root = Tree("[bold]Tabs[/bold]") tabs = sorted(client.tabs.list(), key=_tab_sort_key)
browsers = {} title_limit = tree_title_limit(console=console, show_browser=any(t.browser for t in tabs), show_urls=show_urls)
windows = {} url_limit = tree_url_limit(title_limit, console=console)
groups = {} group_info = {
show_browser = any(t.browser for t in tabs) (group.browser or "local", group.id): group
for tab in tabs: for group in client.groups.list()
browser_key = tab.browser or "local" }
browser_node = browsers.setdefault(browser_key, root.add(f"[bold cyan]{browser_key}[/bold cyan]") if show_browser else root) root = Tree("[bold]Tabs[/bold]")
win_key = (browser_key, tab.window_id) browsers = {}
win_node = windows.get(win_key) windows = {}
if win_node is None: groups = {}
win_node = browser_node.add(f"Window {tab.window_id}") show_browser = any(t.browser for t in tabs)
windows[win_key] = win_node for tab in tabs:
group_label = f"Group {tab.group_id}" if tab.group_id is not None else "Ungrouped" browser_key = tab.browser or "local"
group_key = (browser_key, tab.window_id, group_label) browser_node = browsers.get(browser_key)
group_node = groups.get(group_key) if browser_node is None:
if group_node is None: browser_node = root.add(Text(browser_key, style="bold cyan")) if show_browser else root
group_node = win_node.add(group_label) browsers[browser_key] = browser_node
groups[group_key] = group_node win_key = (browser_key, tab.window_id)
active = " [green]*[/green]" if tab.active else "" win_node = windows.get(win_key)
group_node.add(f"[{tab.id}] {tab.title or '(untitled)'}{active} — [dim]{tab.url or ''}[/dim]") if win_node is None:
console.print(root) win_node = browser_node.add(f"Window {tab.window_id}")
windows[win_key] = win_node
if tab.group_id is None:
win_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit))
continue
group_key = (browser_key, tab.window_id, tab.group_id)
group_node = groups.get(group_key)
group = group_info.get((browser_key, tab.group_id))
if group_node is None:
group_node = win_node.add(_group_tree_label(tab.group_id, group, title_limit=title_limit))
groups[group_key] = group_node
group_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit))
print_tree(root, console=console)
@tabs_group.command("close") @tabs_group.command("close")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@@ -82,9 +121,9 @@ def tabs_tree():
@gentle_mode_option("Throttle mode for large close operations.") @gentle_mode_option("Throttle mode for large close operations.")
@handle_errors @handle_errors
def tabs_close(tab_id, inactive, duplicates, gentle_mode): def tabs_close(tab_id, inactive, duplicates, gentle_mode):
"""Close a tab, all inactive tabs, or all duplicate tabs.""" """Close a tab, all inactive tabs, or all duplicate tabs."""
count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode) count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode)
console.print(f"[green]Closed {count} tab(s)[/green]") console.print(f"[green]Closed {count} tab(s)[/green]")
@tabs_group.command("move") @tabs_group.command("move")
@click.argument("tab_id", type=int) @click.argument("tab_id", type=int)
@@ -97,123 +136,123 @@ def tabs_close(tab_id, inactive, duplicates, gentle_mode):
@click.option("--index", type=int, default=None, help="Absolute position index in target") @click.option("--index", type=int, default=None, help="Absolute position index in target")
@handle_errors @handle_errors
def tabs_move(tab_id, forward, backward, group_id, window_id, index): def tabs_move(tab_id, forward, backward, group_id, window_id, index):
"""Move a tab. Use --forward/--backward or --right/--left for relative movement.""" """Move a tab. Use --forward/--backward or --right/--left for relative movement."""
client_from_ctx().tabs.move( client_from_ctx().tabs.move(
tab_id, forward=forward, backward=backward, tab_id, forward=forward, backward=backward,
group_id=group_id, window_id=window_id, index=index, group_id=group_id, window_id=window_id, index=index,
) )
console.print("[green]Tab moved[/green]") console.print("[green]Tab moved[/green]")
@tabs_group.command("active") @tabs_group.command("active")
@click.argument("tab_id", type=int) @click.argument("tab_id", type=int)
@handle_errors @handle_errors
def tabs_active(tab_id): def tabs_active(tab_id):
"""Switch browser focus to a tab.""" """Switch browser focus to a tab."""
client_from_ctx().tabs.activate(tab_id) client_from_ctx().tabs.activate(tab_id)
console.print(f"[green]Switched to tab {tab_id}[/green]") console.print(f"[green]Switched to tab {tab_id}[/green]")
@tabs_group.command("status") @tabs_group.command("status")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@handle_errors @handle_errors
def tabs_status(tab_id): def tabs_status(tab_id):
"""Show status for the active tab or a specific tab.""" """Show status for the active tab or a specific tab."""
tab = client_from_ctx().tabs.status(tab_id) tab = client_from_ctx().tabs.status(tab_id)
table = Table(show_header=False) table = Table(show_header=False)
table.add_column("Field", style="bold cyan") table.add_column("Field", style="bold cyan")
table.add_column("Value") table.add_column("Value")
table.add_row("ID", str(tab.id)) table.add_row("ID", str(tab.id))
table.add_row("Window", str(tab.window_id)) table.add_row("Window", str(tab.window_id))
table.add_row("Active", "yes" if tab.active else "no") table.add_row("Active", "yes" if tab.active else "no")
table.add_row("Muted", "yes" if tab.muted else "no") table.add_row("Muted", "yes" if tab.muted else "no")
table.add_row("Title", tab.title or "") table.add_row("Title", tab.title or "")
table.add_row("URL", tab.url or "") table.add_row("URL", tab.url or "")
console.print(table) console.print(table)
@tabs_group.command("filter") @tabs_group.command("filter")
@click.argument("pattern") @click.argument("pattern")
@handle_errors @handle_errors
def tabs_filter(pattern): def tabs_filter(pattern):
"""List tabs whose URL contains PATTERN.""" """List tabs whose URL contains PATTERN."""
_print_tabs(client_from_ctx().tabs.filter(pattern)) _print_tabs(client_from_ctx().tabs.filter(pattern))
@tabs_group.command("count") @tabs_group.command("count")
@click.argument("pattern", required=False) @click.argument("pattern", required=False)
@handle_errors @handle_errors
def tabs_count(pattern): def tabs_count(pattern):
"""Count open tabs, optionally filtered by URL PATTERN.""" """Count open tabs, optionally filtered by URL PATTERN."""
label = f" matching '{pattern}'" if pattern else "" label = f" matching '{pattern}'" if pattern else ""
print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label) print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label)
@tabs_group.command("query") @tabs_group.command("query")
@click.argument("search") @click.argument("search")
@handle_errors @handle_errors
def tabs_query(search): def tabs_query(search):
"""Search tabs by URL or title.""" """Search tabs by URL or title."""
_print_tabs(client_from_ctx().tabs.query(search)) _print_tabs(client_from_ctx().tabs.query(search))
@tabs_group.command("html") @tabs_group.command("html")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@handle_errors @handle_errors
def tabs_html(tab_id): def tabs_html(tab_id):
"""Print the full HTML of a tab.""" """Print the full HTML of a tab."""
console.print(client_from_ctx().tabs.html(tab_id)) console.print(client_from_ctx().tabs.html(tab_id))
@tabs_group.command("dedupe") @tabs_group.command("dedupe")
@gentle_mode_option("Throttle mode for large dedupe operations.") @gentle_mode_option("Throttle mode for large dedupe operations.")
@handle_errors @handle_errors
def tabs_dedupe(gentle_mode): def tabs_dedupe(gentle_mode):
"""Close duplicate tabs (keep the first occurrence of each URL).""" """Close duplicate tabs (keep the first occurrence of each URL)."""
count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode) count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode)
console.print(f"[green]Closed {count} duplicate tab(s)[/green]") console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
@tabs_group.command("sort") @tabs_group.command("sort")
@click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True) @click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True)
@gentle_mode_option("Throttle mode for large sort operations.") @gentle_mode_option("Throttle mode for large sort operations.")
@handle_errors @handle_errors
def tabs_sort(by, gentle_mode): def tabs_sort(by, gentle_mode):
"""Sort tabs within each window.""" """Sort tabs within each window."""
client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode) client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode)
console.print(f"[green]Tabs sorted by {by}[/green]") console.print(f"[green]Tabs sorted by {by}[/green]")
@tabs_group.command("merge-windows") @tabs_group.command("merge-windows")
@gentle_mode_option("Throttle mode for large merge operations.") @gentle_mode_option("Throttle mode for large merge operations.")
@handle_errors @handle_errors
def tabs_merge_windows(gentle_mode): def tabs_merge_windows(gentle_mode):
"""Move all tabs into the focused window.""" """Move all tabs into the focused window."""
count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode) count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode)
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]") console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
@tabs_group.command("mute") @tabs_group.command("mute")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@handle_errors @handle_errors
def tabs_mute(tab_id): def tabs_mute(tab_id):
"""Mute the active tab or a specific tab.""" """Mute the active tab or a specific tab."""
target = client_from_ctx().tabs.mute(tab_id) target = client_from_ctx().tabs.mute(tab_id)
console.print(f"[green]Muted tab {target}[/green]") console.print(f"[green]Muted tab {target}[/green]")
@tabs_group.command("unmute") @tabs_group.command("unmute")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@handle_errors @handle_errors
def tabs_unmute(tab_id): def tabs_unmute(tab_id):
"""Unmute the active tab or a specific tab.""" """Unmute the active tab or a specific tab."""
target = client_from_ctx().tabs.unmute(tab_id) target = client_from_ctx().tabs.unmute(tab_id)
console.print(f"[green]Unmuted tab {target}[/green]") console.print(f"[green]Unmuted tab {target}[/green]")
@tabs_group.command("pin") @tabs_group.command("pin")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@handle_errors @handle_errors
def tabs_pin(tab_id): def tabs_pin(tab_id):
"""Pin the active tab or a specific tab.""" """Pin the active tab or a specific tab."""
target = client_from_ctx().tabs.pin(tab_id) target = client_from_ctx().tabs.pin(tab_id)
console.print(f"[green]Pinned tab {target}[/green]") console.print(f"[green]Pinned tab {target}[/green]")
@tabs_group.command("unpin") @tabs_group.command("unpin")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@handle_errors @handle_errors
def tabs_unpin(tab_id): def tabs_unpin(tab_id):
"""Unpin the active tab or a specific tab.""" """Unpin the active tab or a specific tab."""
target = client_from_ctx().tabs.unpin(tab_id) target = client_from_ctx().tabs.unpin(tab_id)
console.print(f"[green]Unpinned tab {target}[/green]") console.print(f"[green]Unpinned tab {target}[/green]")
@tabs_group.command("watch-url") @tabs_group.command("watch-url")
@click.argument("pattern") @click.argument("pattern")
@@ -221,9 +260,9 @@ def tabs_unpin(tab_id):
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait") @click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
@handle_errors @handle_errors
def tabs_watch_url(pattern, tab_id, timeout): def tabs_watch_url(pattern, tab_id, timeout):
"""Wait until the active (or specified) tab URL matches regex PATTERN.""" """Wait until the active (or specified) tab URL matches regex PATTERN."""
tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout) tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
console.print(f"[green]URL matched:[/green] {tab.url}") console.print(f"[green]URL matched:[/green] {tab.url}")
@tabs_group.command("screenshot") @tabs_group.command("screenshot")
@click.argument("output", required=False, metavar="FILE") @click.argument("output", required=False, metavar="FILE")
@@ -232,21 +271,21 @@ def tabs_watch_url(pattern, tab_id, timeout):
@click.option("--quality", type=int, default=None, help="JPEG quality 0-100") @click.option("--quality", type=int, default=None, help="JPEG quality 0-100")
@handle_errors @handle_errors
def tabs_screenshot(output, tab_id, fmt, quality): def tabs_screenshot(output, tab_id, fmt, quality):
"""Capture a screenshot of the active (or specified) tab. """Capture a screenshot of the active (or specified) tab.
Saves to FILE if given, otherwise prints the base64 data URL. Saves to FILE if given, otherwise prints the base64 data URL.
""" """
data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality) data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality)
if output: if output:
header = f"data:image/{fmt};base64," header = f"data:image/{fmt};base64,"
if not data_url.startswith(header): if not data_url.startswith(header):
raise click.ClickException("Empty or unexpected screenshot response (incognito/protected tab?)") raise click.ClickException("Empty or unexpected screenshot response (incognito/protected tab?)")
try: try:
raw = base64.b64decode(data_url[len(header):]) raw = base64.b64decode(data_url[len(header):])
except binascii.Error as e: except binascii.Error as e:
raise click.ClickException(f"Failed to decode screenshot data: {e}") raise click.ClickException(f"Failed to decode screenshot data: {e}")
with open(output, "wb") as f: with open(output, "wb") as f:
f.write(raw) f.write(raw)
console.print(f"[green]Screenshot saved:[/green] {output}") console.print(f"[green]Screenshot saved:[/green] {output}")
else: else:
console.print(data_url) console.print(data_url)
+53 -51
View File
@@ -1,5 +1,6 @@
import click import click
from browser_cli.commands import client_from_ctx, handle_errors from browser_cli.commands import client_from_ctx, handle_errors
from browser_cli.commands.rendering import print_tree, tab_tree_label, tree_title_limit, tree_url_limit
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from rich.tree import Tree from rich.tree import Tree
@@ -7,81 +8,82 @@ from rich.tree import Tree
console = Console() console = Console()
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None: def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
if not windows: if not windows:
console.print("[yellow]No windows found[/yellow]") console.print("[yellow]No windows found[/yellow]")
return return
table = Table(show_header=True, header_style="bold cyan") table = Table(show_header=True, header_style="bold cyan")
if show_browser: if show_browser:
table.add_column("Browser") table.add_column("Browser")
table.add_column("ID", style="dim", no_wrap=True) table.add_column("ID", style="dim", no_wrap=True)
table.add_column("Alias", width=20) table.add_column("Alias", width=20)
table.add_column("Tabs", width=6) table.add_column("Tabs", width=6)
table.add_column("State", width=12) table.add_column("State", width=12)
for w in windows: for w in windows:
row = [ row = [
w.get("browser", "") if show_browser else None, w.get("browser", "") if show_browser else None,
str(w.get("id", "")), str(w.get("id", "")),
w.get("alias") or "", w.get("alias") or "",
str(w.get("tabCount", "")), str(w.get("tabCount", "")),
w.get("state") or "", w.get("state") or "",
] ]
table.add_row(*[value for value in row if value is not None]) table.add_row(*[value for value in row if value is not None])
console.print(table) console.print(table)
@click.group("windows") @click.group("windows")
def windows_group(): def windows_group():
"""Manage browser windows.""" """Manage browser windows."""
@windows_group.command("list") @windows_group.command("list")
@handle_errors @handle_errors
def windows_list(): def windows_list():
"""List all browser windows.""" """List all browser windows."""
windows = client_from_ctx().windows.list() windows = client_from_ctx().windows.list()
_print_windows(windows, show_browser=any("browser" in w for w in windows)) _print_windows(windows, show_browser=any("browser" in w for w in windows))
@windows_group.command("tree") @windows_group.command("tree")
@handle_errors @handle_errors
def windows_tree(): def windows_tree():
"""Show windows and their tabs as a tree.""" """Show windows and their tabs as a tree."""
client = client_from_ctx() client = client_from_ctx()
windows = client.windows.list() windows = client.windows.list()
tabs = client.tabs.list() tabs = client.tabs.list()
root = Tree("[bold]Windows[/bold]") root = Tree("[bold]Windows[/bold]")
for w in sorted(windows, key=lambda item: (item.get("browser", ""), item.get("id", 0))): title_limit = tree_title_limit(console=console, show_browser=any("browser" in w for w in windows), show_urls=True)
wid = w.get("id") url_limit = tree_url_limit(title_limit, console=console)
label = f"Window {wid}" for w in sorted(windows, key=lambda item: (item.get("browser", ""), item.get("id", 0))):
if w.get("alias"): wid = w.get("id")
label += f" ({w['alias']})" label = f"Window {wid}"
if w.get("browser"): if w.get("alias"):
label = f"{w['browser']}: " + label label += f" ({w['alias']})"
node = root.add(label) if w.get("browser"):
for tab in sorted([t for t in tabs if t.window_id == wid and (not w.get("browser") or t.browser == w.get("browser"))], key=lambda t: t.index): label = f"{w['browser']}: " + label
active = " [green]*[/green]" if tab.active else "" node = root.add(label)
node.add(f"[{tab.id}] {tab.title or '(untitled)'}{active} — [dim]{tab.url or ''}[/dim]") for tab in sorted([t for t in tabs if t.window_id == wid and (not w.get("browser") or t.browser == w.get("browser"))], key=lambda t: getattr(t, "index", 0)):
console.print(root) node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit))
print_tree(root, console=console)
@windows_group.command("rename") @windows_group.command("rename")
@click.argument("window_id", type=int) @click.argument("window_id", type=int)
@click.argument("name") @click.argument("name")
@handle_errors @handle_errors
def windows_rename(window_id, name): def windows_rename(window_id, name):
"""Give a window a local alias NAME (stored in native host).""" """Give a window a local alias NAME (stored in native host)."""
client_from_ctx().windows.rename(window_id, name) client_from_ctx().windows.rename(window_id, name)
console.print(f"[green]Window {window_id} aliased as '{name}'[/green]") console.print(f"[green]Window {window_id} aliased as '{name}'[/green]")
@windows_group.command("close") @windows_group.command("close")
@click.argument("window_id", type=int) @click.argument("window_id", type=int)
@handle_errors @handle_errors
def windows_close(window_id): def windows_close(window_id):
"""Close a browser window.""" """Close a browser window."""
client_from_ctx().windows.close(window_id) client_from_ctx().windows.close(window_id)
console.print(f"[green]Window {window_id} closed[/green]") console.print(f"[green]Window {window_id} closed[/green]")
@windows_group.command("open") @windows_group.command("open")
@click.argument("url", required=False) @click.argument("url", required=False)
@handle_errors @handle_errors
def windows_open(url): def windows_open(url):
"""Open a new browser window.""" """Open a new browser window."""
result = client_from_ctx().windows.open(url) result = client_from_ctx().windows.open(url)
wid = result.get("id") if isinstance(result, dict) else result wid = result.get("id") if isinstance(result, dict) else result
console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else "")) console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else ""))
+2
View File
@@ -44,6 +44,7 @@ class Tab:
title: str = "" title: str = ""
url: str = "" url: str = ""
group_id: int | None = None group_id: int | None = None
index: int = 0
browser: str | None = None browser: str | None = None
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False) _browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
@@ -149,6 +150,7 @@ class Group:
color: str color: str
collapsed: bool collapsed: bool
tab_count: int tab_count: int
window_id: int | None = None
browser: str | None = None browser: str | None = None
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False) _browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
+2
View File
@@ -37,6 +37,7 @@ class FactoryMixin:
title=data.get("title") or "", title=data.get("title") or "",
url=data.get("url") or "", url=data.get("url") or "",
group_id=data.get("groupId") or None, group_id=data.get("groupId") or None,
index=data.get("index", 0) or 0,
browser=browser_name, browser=browser_name,
) )
client = cast(_FactoryClient, self) client = cast(_FactoryClient, self)
@@ -68,6 +69,7 @@ class FactoryMixin:
color=data.get("color") or "", color=data.get("color") or "",
collapsed=data.get("collapsed", False), collapsed=data.get("collapsed", False),
tab_count=data.get("tabCount", 0), tab_count=data.get("tabCount", 0),
window_id=data.get("windowId"),
browser=browser_name, browser=browser_name,
) )
client = cast(_FactoryClient, self) client = cast(_FactoryClient, self)
+1
View File
@@ -22,6 +22,7 @@ export function tabInfo(t: Tab) {
windowId: t.windowId, windowId: t.windowId,
active: t.active, active: t.active,
muted: Boolean(t.mutedInfo && t.mutedInfo.muted), muted: Boolean(t.mutedInfo && t.mutedInfo.muted),
index: t.index,
groupId: t.groupId >= 0 ? t.groupId : null, groupId: t.groupId >= 0 ? t.groupId : null,
title: t.title, title: t.title,
url: t.url || t.pendingUrl || "", url: t.url || t.pendingUrl || "",
+10
View File
@@ -18,6 +18,7 @@ TAB_DATA = {
"id": 10, "id": 10,
"windowId": 1, "windowId": 1,
"active": True, "active": True,
"index": 3,
"title": "Example", "title": "Example",
"url": "https://example.com", "url": "https://example.com",
"groupId": None, "groupId": None,
@@ -73,6 +74,15 @@ class TestBrowserCLIInit:
assert b.remote == "browser-host.example:443" assert b.remote == "browser-host.example:443"
assert b.key == "agent" assert b.key == "agent"
def test_tab_factory_preserves_index(self):
tab = BrowserCLI().tab_from(TAB_DATA)
assert tab.index == 3
def test_tab_factory_defaults_missing_index_to_zero(self):
data = {key: value for key, value in TAB_DATA.items() if key != "index"}
tab = BrowserCLI().tab_from(data)
assert tab.index == 0
def test_namespaces_present_and_bound(self): def test_namespaces_present_and_bound(self):
b = BrowserCLI() b = BrowserCLI()
for name in ("nav", "tabs", "groups", "windows", "dom", "extract", for name in ("nav", "tabs", "groups", "windows", "dom", "extract",
+84 -1
View File
@@ -7,6 +7,7 @@ from click.testing import CliRunner
import pytest import pytest
from browser_cli import BrowserCLI from browser_cli import BrowserCLI
from browser_cli.client import BrowserTarget
from browser_cli.cli import main from browser_cli.cli import main
from browser_cli.command_security import CommandPolicy, assert_command_allowed, command_category from browser_cli.command_security import CommandPolicy, assert_command_allowed, command_category
@@ -47,12 +48,94 @@ def test_nav_open_reuse_navigates_existing_tab_instead_of_opening_new():
("navigate.to", {"tabId": 7, "url": "https://example.com"}), ("navigate.to", {"tabId": 7, "url": "https://example.com"}),
] ]
def _tree_sender(tabs, groups):
def sender(command, args=None, **kwargs):
if command == "tabs.list":
return tabs
if command == "group.list":
return groups
return []
return sender
def test_tabs_tree_command_available(): def test_tabs_tree_command_available():
with patch("browser_cli.send_command", return_value=[]): with patch("browser_cli.send_command", side_effect=_tree_sender([], [])):
result = CliRunner().invoke(main, ["tabs", "tree"]) result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Tabs" in result.output assert "Tabs" in result.output
def test_tabs_tree_handles_tabs_without_index_from_older_extension():
tabs = [{
"id": 7,
"windowId": 1,
"active": True,
"muted": False,
"title": "Example",
"url": "https://example.com",
"groupId": None,
}]
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0
assert "Example" in result.output
def test_tabs_tree_preserves_window_tab_order_and_truncates_long_lines():
tabs = [
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None},
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "[Gold] Grouped", "url": "https://example.com/grouped", "groupId": 20},
{"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/" + "x" * 200, "groupId": None},
]
groups = [{"id": 20, "title": "Group Name", "color": "blue", "collapsed": False, "tabCount": 1, "windowId": 1}]
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)):
result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0
output = result.output
assert output.index("Before") < output.index("Group Name") < output.index("[Gold] Grouped") < output.index("After")
assert "https://example.com/before" not in output
assert "https://example.com/grouped" not in output
assert "https://example.com/" + "x" * 200 not in output
def test_tabs_tree_adds_each_browser_node_only_once():
tabs = [
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "One", "url": "https://example.com/one", "groupId": None, "browser": "work"},
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Two", "url": "https://example.com/two", "groupId": None, "browser": "work"},
]
targets = [
BrowserTarget("work", "work", "/tmp/work.sock"),
BrowserTarget("personal", "personal", "/tmp/personal.sock"),
]
with patch("browser_cli.active_browser_targets", return_value=targets), \
patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0
assert result.output.count("work") == 1
assert result.output.count("personal") == 1
assert "One" in result.output
assert "Two" in result.output
def test_tabs_tree_shows_tabs_inside_collapsed_browser_groups():
tabs = [
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None},
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Hidden", "url": "https://example.com/hidden", "groupId": 20},
{"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/after", "groupId": None},
]
groups = [{"id": 20, "title": "Collapsed Group", "color": "orange", "collapsed": True, "tabCount": 1, "windowId": 1}]
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)):
result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0
assert "Collapsed Group" in result.output
assert "1 tab" in result.output
assert "collapsed" in result.output
assert "Hidden" in result.output
def test_tabs_tree_can_show_shortened_urls_on_request():
tabs = [{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Long URL", "url": "https://example.com/" + "x" * 200, "groupId": None}]
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
result = CliRunner().invoke(main, ["tabs", "tree", "--urls"])
assert result.exit_code == 0
assert "https://example.com/" in result.output
assert "https://example.com/" + "x" * 200 not in result.output
assert "" in result.output
def test_doctor_command_reports_connection_failure_cleanly(): def test_doctor_command_reports_connection_failure_cleanly():
with patch("browser_cli.commands.doctor.active_browser_targets", return_value=[]), \ with patch("browser_cli.commands.doctor.active_browser_targets", return_value=[]), \
patch("browser_cli.send_command", side_effect=RuntimeError("no browser")): patch("browser_cli.send_command", side_effect=RuntimeError("no browser")):
+34
View File
@@ -0,0 +1,34 @@
from os import terminal_size
from rich.console import Console
from rich.tree import Tree
from browser_cli.commands import rendering
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)))
assert rendering.terminal_width(Console(width=80)) == 140
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)
assert label.no_wrap is True
assert label.overflow == "ellipsis"
assert "abc…" in label.plain
assert "https://exa…" in label.plain
def test_print_tree_uses_detected_width(monkeypatch):
widths = []
class CapturingConsole(Console):
def __init__(self, *args, **kwargs):
widths.append(kwargs.get("width"))
super().__init__(*args, **kwargs)
monkeypatch.setattr(rendering, "Console", CapturingConsole)
monkeypatch.setattr(rendering, "terminal_width", lambda console=None: 132)
rendering.print_tree(Tree("Root"))
assert widths == [132]