diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index 89163d8d..9d32d6c5 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -182,9 +182,9 @@ def render(d: dict[str, object]) -> object: shown_with_breakdown = [(item, _line_items_summary(item)) for item in shown] show_breakdown = any(summary for _, summary in shown_with_breakdown) table = ( - Table("period", "total", "breakdown", header_style="aai.heading") + output.data_table("period", "total", "breakdown") if show_breakdown - else Table("period", "total", header_style="aai.heading") + else output.data_table("period", "total") ) hidden_count = len(items) - len(shown) for item, breakdown in shown_with_breakdown: @@ -230,7 +230,7 @@ def body(state: AppState, json_mode: bool) -> None: data = ams.get_rate_limits(account_id, jwt) def render(d: dict[str, object]) -> Table: - table = Table("service", "limit", header_style="aai.heading") + table = output.data_table("service", "limit") for limit in jsonshape.mapping_list(d.get("rate_limits")): table.add_row( escape(str(limit.get("service", ""))), diff --git a/aai_cli/commands/audit.py b/aai_cli/commands/audit.py index eb0e9105..7af1c651 100644 --- a/aai_cli/commands/audit.py +++ b/aai_cli/commands/audit.py @@ -6,7 +6,6 @@ import typer from rich.console import Group from rich.markup import escape -from rich.table import Table from rich.text import Text from aai_cli import help_panels, jsonshape, output, timeparse @@ -141,7 +140,7 @@ def render(data: list[dict[str, object]]) -> object: ) return Text(message, style="aai.muted") - table = Table("when (UTC)", "event", "resource", "actor", header_style="aai.heading") + table = output.data_table("when (UTC)", "event", "resource", "actor") for entry in shown: table.add_row( escape(_format_time(entry.get("log_time"))), diff --git a/aai_cli/commands/keys.py b/aai_cli/commands/keys.py index cb656d4d..6adfeae9 100644 --- a/aai_cli/commands/keys.py +++ b/aai_cli/commands/keys.py @@ -61,7 +61,7 @@ def body(state: AppState, json_mode: bool) -> None: ) def render(data: list[dict[str, object]]) -> Table: - table = Table("id", "name", "project", "key", "disabled", header_style="aai.heading") + table = output.data_table("id", "name", "project", "key", "disabled") for row in data: table.add_row( str(row["id"]), diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index db42bedd..41262a33 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -126,9 +126,7 @@ def body(state: AppState, json_mode: bool) -> None: account_id = config.get_account_id(profile) def render(_d: dict[str, object]) -> Table: - table = Table.grid(padding=(0, 3)) - table.add_column(style="aai.muted") - table.add_column() + table = output.detail_table() table.add_row("Profile", escape(profile)) table.add_row("Env", escape(env)) table.add_row("API key", escape(masked)) diff --git a/aai_cli/commands/sessions.py b/aai_cli/commands/sessions.py index 176da677..4e833fca 100644 --- a/aai_cli/commands/sessions.py +++ b/aai_cli/commands/sessions.py @@ -56,13 +56,12 @@ def body(state: AppState, json_mode: bool) -> None: rows = _session_rows(payload.get("data")) def render(data: list[dict[str, object]]) -> Table: - table = Table( + table = output.data_table( "session id", "status", "created", "audio (s)", "model", - header_style="aai.heading", ) for s in data: status_str = str(s["status"]) @@ -99,10 +98,11 @@ def body(state: AppState, json_mode: bool) -> None: data = ams.get_streaming(session_id, jwt) def render(d: dict[str, object]) -> Table: - table = Table(show_header=False) + table = output.detail_table() for field in _DETAIL_FIELDS: value = d.get(field) - table.add_row(field, escape("" if value is None else str(value))) + label = field.replace("_", " ") + table.add_row(label, escape("" if value is None else str(value))) return table output.emit(data, render, json_mode=json_mode) diff --git a/aai_cli/commands/transcripts.py b/aai_cli/commands/transcripts.py index 8ba50252..d3b2737d 100644 --- a/aai_cli/commands/transcripts.py +++ b/aai_cli/commands/transcripts.py @@ -76,7 +76,7 @@ def body(state: AppState, json_mode: bool) -> None: rows = client.list_transcripts(api_key, limit=limit) def render(data: list[dict[str, object]]) -> Table: - table = Table("id", "status", "created", header_style="aai.heading") + table = output.data_table("id", "status", "created") for row in data: status = str(row["status"]) table.add_row( diff --git a/aai_cli/output.py b/aai_cli/output.py index 469d1dd2..a47a1008 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -6,7 +6,9 @@ from collections.abc import Callable from typing import TYPE_CHECKING, TypeVar +from rich import box from rich.markup import escape +from rich.table import Table from aai_cli import theme from aai_cli.errors import UsageError @@ -92,6 +94,31 @@ def heading(text: str) -> str: return f"[aai.heading]{text}[/aai.heading]" +def data_table(*columns: str) -> Table: + """A list table with the one consistent, minimal look used CLI-wide. + + Headers render in the brand heading style with a single rule beneath them and + no surrounding box — the quiet, scannable style the Vercel/Supabase CLIs use. + Defined once here so every listing command (`transcripts list`, `keys list`, + `sessions list`, `usage`, `limits`, `audit`) shares the same table, rather than + each re-deriving Rich's heavy default box. + """ + return Table(*columns, box=box.SIMPLE_HEAD, header_style="aai.heading", pad_edge=False) + + +def detail_table() -> Table: + """A borderless label/value grid for single-record views (`whoami`, `sessions get`). + + The label column is muted so the values read as the content and the pair scans + as a definition list, not a boxed table. Centralizes what was two divergent + one-off tables into one look. + """ + table = Table.grid(padding=(0, 3)) + table.add_column(style="aai.muted") + table.add_column() + return table + + def emit(data: T, human_renderer: Callable[[T], object], *, json_mode: bool) -> None: if json_mode: print(json.dumps(data, default=str)) diff --git a/tests/test_keys.py b/tests/test_keys.py index 28b39173..db21447f 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -37,6 +37,22 @@ def test_keys_list_flattens_tokens(): assert "sk_abcdef1234" not in result.output # api key is masked +def test_keys_list_renders_table_human(monkeypatch): + _auth() + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: explicit) + projects = [ + { + "project": {"id": 1, "name": "Default"}, + "tokens": [{"id": 10, "name": "ci", "api_key": "sk_abcdef1234", "is_disabled": False}], + } + ] + with patch("aai_cli.commands.keys.ams.list_projects", return_value=projects): + result = runner.invoke(app, ["keys", "list"]) + assert result.exit_code == 0 + assert "ci" in result.output and "Default" in result.output + assert "sk_abcdef1234" not in result.output # masked in the human table too + + def test_keys_shape_helpers_filter_invalid_values(): assert keys._project_id({"id": True}) is None assert keys._project_id({"id": 7}) == 7 diff --git a/tests/test_login.py b/tests/test_login.py index 8ae0a4b3..0b5f8ae1 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -47,6 +47,18 @@ def test_whoami_reports_authenticated(): assert data["api_key"].startswith("sk_") and "…" in data["api_key"] +def test_whoami_human_render_shows_detail_rows(monkeypatch): + config.set_api_key("default", "sk_1234567890") + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: explicit) + with patch("aai_cli.commands.login.client.validate_key", return_value=True): + result = runner.invoke(app, ["whoami"]) + assert result.exit_code == 0 + # The shared borderless detail grid: labelled rows, no JSON, key masked. + assert "Profile" in result.output and "default" in result.output + assert "reachable" in result.output + assert "…" in result.output and '"profile"' not in result.output + + def test_whoami_unauthenticated_runs_login(monkeypatch): monkeypatch.setattr("aai_cli.context.run_login_flow", _fake_login_result) with patch("aai_cli.commands.login.client.validate_key", return_value=True) as validate: diff --git a/tests/test_output.py b/tests/test_output.py index 27fcaad1..db003e6f 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -145,3 +145,22 @@ def test_print_code_highlights_for_interactive_human(monkeypatch, capsys): out = capsys.readouterr().out assert "import" in out assert "\x1b[" in out # syntax-highlighted -> ANSI present + + +def test_data_table_is_minimal_and_themed(): + from rich import box + + table = output.data_table("id", "status") + # One shared, quiet look: a header-rule box (no heavy outer border) and the + # brand heading style — so every listing command renders identically. + assert table.box is box.SIMPLE_HEAD + assert table.header_style == "aai.heading" + assert [str(col.header) for col in table.columns] == ["id", "status"] + + +def test_detail_table_is_borderless_label_value_grid(): + table = output.detail_table() + # A grid (no box) with a muted label column, shared by whoami / sessions get. + assert table.box is None + assert len(table.columns) == 2 + assert table.columns[0].style == "aai.muted" diff --git a/tests/test_sessions_command.py b/tests/test_sessions_command.py index 88a093a3..fef08153 100644 --- a/tests/test_sessions_command.py +++ b/tests/test_sessions_command.py @@ -95,6 +95,8 @@ def test_sessions_get_renders_detail(monkeypatch): result = runner.invoke(app, ["sessions", "get", "s_1"]) assert result.exit_code == 0 assert "s_1" in result.output and "universal" in result.output + # Field labels are humanized (underscores -> spaces) for the detail view. + assert "speech model" in result.output and "speech_model" not in result.output def test_sessions_without_session_runs_login(monkeypatch):