Files
browser-cli/browser_cli/commands/tabs.py
T
daniel156161 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
refactor: modularize auth transport and markdown
- 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.
2026-06-15 01:23:57 +02:00

220 lines
8.6 KiB
Python

import base64
import binascii
import click
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
from browser_cli.commands.rendering import build_tabs_tree, print_table_rows, print_tree
from rich.console import Console
from rich.table import Table
console = Console()
def _print_tabs(tabs, *, show_browser: bool = False) -> None:
columns = []
if show_browser:
columns.append(("Browser", lambda tab: tab.browser or ""))
columns.extend([
("ID", lambda tab: tab.id),
("Window", lambda tab: tab.window_id),
("Active", lambda tab: "[green]✓[/green]" if tab.active else ""),
("Muted", lambda tab: "[yellow]✓[/yellow]" if tab.muted else ""),
("Title", lambda tab: (tab.title or "")[:60]),
("URL", lambda tab: (tab.url or "")[:80]),
])
print_table_rows(tabs, columns, console=console, empty_message="[yellow]No tabs found[/yellow]")
@click.group("tabs")
def tabs_group():
"""Manage browser tabs."""
@tabs_group.command("list")
@handle_errors
def tabs_list():
"""List all open tabs across all windows."""
tabs = client_from_ctx().tabs.list()
_print_tabs(tabs, show_browser=any(t.browser for t in tabs))
@tabs_group.command("tree")
@click.option("--urls", "show_urls", is_flag=True, help="Show shortened URLs next to tab titles")
@handle_errors
def tabs_tree(show_urls):
"""Show tabs grouped as a window/group tree."""
client = client_from_ctx()
root = build_tabs_tree(client.tabs.list(), client.groups.list(), console=console, show_urls=show_urls)
print_tree(root, console=console)
@tabs_group.command("close")
@click.argument("tab_id", type=int, required=False)
@click.option("--inactive", is_flag=True, help="Close all inactive tabs")
@click.option("--duplicates", is_flag=True, help="Close duplicate tabs (keep first)")
@gentle_mode_option("Throttle mode for large close operations.")
@handle_errors
def tabs_close(tab_id, inactive, duplicates, gentle_mode):
"""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)
console.print(f"[green]Closed {count} tab(s)[/green]")
@tabs_group.command("move")
@click.argument("tab_id", type=int)
@click.option("-f", "--forward", "forward", is_flag=True, help="Move one position to the right")
@click.option("-b", "--backward", "backward", is_flag=True, help="Move one position to the left")
@click.option("-r", "--right", "forward", is_flag=True, help="Move one position to the right")
@click.option("-l", "--left", "backward", is_flag=True, help="Move one position to the left")
@click.option("--group", "group_id", type=int, default=None, help="Move to tab group ID")
@click.option("--window", "window_id", type=int, default=None, help="Move to window ID")
@click.option("--index", type=int, default=None, help="Absolute position index in target")
@handle_errors
def tabs_move(tab_id, forward, backward, group_id, window_id, index):
"""Move a tab. Use --forward/--backward or --right/--left for relative movement."""
client_from_ctx().tabs.move(
tab_id, forward=forward, backward=backward,
group_id=group_id, window_id=window_id, index=index,
)
console.print("[green]Tab moved[/green]")
@tabs_group.command("active")
@click.argument("tab_id", type=int)
@handle_errors
def tabs_active(tab_id):
"""Switch browser focus to a tab."""
client_from_ctx().tabs.activate(tab_id)
console.print(f"[green]Switched to tab {tab_id}[/green]")
@tabs_group.command("status")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_status(tab_id):
"""Show status for the active tab or a specific tab."""
tab = client_from_ctx().tabs.status(tab_id)
table = Table(show_header=False)
table.add_column("Field", style="bold cyan")
table.add_column("Value")
table.add_row("ID", str(tab.id))
table.add_row("Window", str(tab.window_id))
table.add_row("Active", "yes" if tab.active else "no")
table.add_row("Muted", "yes" if tab.muted else "no")
table.add_row("Title", tab.title or "")
table.add_row("URL", tab.url or "")
console.print(table)
@tabs_group.command("filter")
@click.argument("pattern")
@handle_errors
def tabs_filter(pattern):
"""List tabs whose URL contains PATTERN."""
_print_tabs(client_from_ctx().tabs.filter(pattern))
@tabs_group.command("count")
@click.argument("pattern", required=False)
@handle_errors
def tabs_count(pattern):
"""Count open tabs, optionally filtered by URL PATTERN."""
label = f" matching '{pattern}'" if pattern else ""
print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label)
@tabs_group.command("query")
@click.argument("search")
@handle_errors
def tabs_query(search):
"""Search tabs by URL or title."""
_print_tabs(client_from_ctx().tabs.query(search))
@tabs_group.command("html")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_html(tab_id):
"""Print the full HTML of a tab."""
console.print(client_from_ctx().tabs.html(tab_id))
@tabs_group.command("dedupe")
@gentle_mode_option("Throttle mode for large dedupe operations.")
@handle_errors
def tabs_dedupe(gentle_mode):
"""Close duplicate tabs (keep the first occurrence of each URL)."""
count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode)
console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
@tabs_group.command("sort")
@click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True)
@gentle_mode_option("Throttle mode for large sort operations.")
@handle_errors
def tabs_sort(by, gentle_mode):
"""Sort tabs within each window."""
client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode)
console.print(f"[green]Tabs sorted by {by}[/green]")
@tabs_group.command("merge-windows")
@gentle_mode_option("Throttle mode for large merge operations.")
@handle_errors
def tabs_merge_windows(gentle_mode):
"""Move all tabs into the focused window."""
count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode)
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
@tabs_group.command("mute")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_mute(tab_id):
"""Mute the active tab or a specific tab."""
target = client_from_ctx().tabs.mute(tab_id)
console.print(f"[green]Muted tab {target}[/green]")
@tabs_group.command("unmute")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_unmute(tab_id):
"""Unmute the active tab or a specific tab."""
target = client_from_ctx().tabs.unmute(tab_id)
console.print(f"[green]Unmuted tab {target}[/green]")
@tabs_group.command("pin")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_pin(tab_id):
"""Pin the active tab or a specific tab."""
target = client_from_ctx().tabs.pin(tab_id)
console.print(f"[green]Pinned tab {target}[/green]")
@tabs_group.command("unpin")
@click.argument("tab_id", type=int, required=False)
@handle_errors
def tabs_unpin(tab_id):
"""Unpin the active tab or a specific tab."""
target = client_from_ctx().tabs.unpin(tab_id)
console.print(f"[green]Unpinned tab {target}[/green]")
@tabs_group.command("watch-url")
@click.argument("pattern")
@tab_option
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
@handle_errors
def tabs_watch_url(pattern, tab_id, timeout):
"""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)
console.print(f"[green]URL matched:[/green] {tab.url}")
@tabs_group.command("screenshot")
@click.argument("output", required=False, metavar="FILE")
@tab_option
@click.option("--format", "fmt", type=click.Choice(["png", "jpeg"]), default="png", show_default=True)
@click.option("--quality", type=int, default=None, help="JPEG quality 0-100")
@handle_errors
def tabs_screenshot(output, tab_id, fmt, quality):
"""Capture a screenshot of the active (or specified) tab.
Saves to FILE if given, otherwise prints the base64 data URL.
"""
data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality)
if output:
header = f"data:image/{fmt};base64,"
if not data_url.startswith(header):
raise click.ClickException("Empty or unexpected screenshot response (incognito/protected tab?)")
try:
raw = base64.b64decode(data_url[len(header):])
except binascii.Error as e:
raise click.ClickException(f"Failed to decode screenshot data: {e}")
with open(output, "wb") as f:
f.write(raw)
console.print(f"[green]Screenshot saved:[/green] {output}")
else:
console.print(data_url)