From ab07881cbae8bae0106ec4640e8baab608223ddb Mon Sep 17 00:00:00 2001 From: Abhishek Mishra Date: Fri, 14 Aug 2026 11:27:04 +0530 Subject: [PATCH] feat(cli): brand banner (SMALLEST AI, #3B82F6, pulsing rings) + rename waves group to models Redesign the bare-`smallestai` welcome banner: - Wordmark reads SMALLEST AI (was SMALLEST) in the brand blue #3B82F6. - Concentric rings, left-aligned above the wordmark, gently pulse on an interactive TTY. Piped/CI/NO_COLOR/SMALLESTAI_NO_ANIM render a single static frame; narrow terminals fall back to a compact text wordmark so nothing wraps. - Banner rendering moves to smallestai/cli/banner.py. Rename the speech command group from `waves` to `models` in the menu and command. `waves` still works as a hidden back-compat alias. Tests cover ring rasterisation and the static/compact/animated paths. --- src/smallestai/cli/banner.py | 132 +++++++++++++++++++++++++ src/smallestai/cli/main.py | 21 ++-- tests/custom/test_cli_banner_mcp.py | 4 +- tests/custom/test_cli_banner_render.py | 44 +++++++++ 4 files changed, 186 insertions(+), 15 deletions(-) create mode 100644 src/smallestai/cli/banner.py create mode 100644 tests/custom/test_cli_banner_render.py diff --git a/src/smallestai/cli/banner.py b/src/smallestai/cli/banner.py new file mode 100644 index 00000000..9eee8306 --- /dev/null +++ b/src/smallestai/cli/banner.py @@ -0,0 +1,132 @@ +"""Startup banner for the smallestai CLI. + +Concentric rings (left-aligned, gently pulsing) above the SMALLEST AI wordmark, +in the brand blue #3B82F6. The pulse only runs on an interactive TTY; piped, +CI, NO_COLOR, or SMALLESTAI_NO_ANIM output gets a single static frame. Narrow +terminals fall back to a compact text wordmark so nothing wraps. +""" + +from __future__ import annotations + +import math +import os +import shutil +import sys +import time + +_BRAND = (59, 130, 246) # #3B82F6 + +_WORDMARK = [ + r"███████╗███╗ ███╗ █████╗ ██╗ ██╗ ███████╗███████╗████████╗ █████╗ ██╗", + r"██╔════╝████╗ ████║██╔══██╗██║ ██║ ██╔════╝██╔════╝╚══██╔══╝ ██╔══██╗██║", + r"███████╗██╔████╔██║███████║██║ ██║ █████╗ ███████╗ ██║ ███████║██║", + r"╚════██║██║╚██╔╝██║██╔══██║██║ ██║ ██╔══╝ ╚════██║ ██║ ██╔══██║██║", + r"███████║██║ ╚═╝ ██║██║ ██║███████╗███████╗███████╗███████║ ██║ ██║ ██║██║", + r"╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝", +] +_WORDMARK_WIDTH = 83 +_INDENT = " " +_MIN_WIDTH = _WORDMARK_WIDTH + 4 # leave a little breathing room + +_TAGLINE = "Build, deploy, and run voice agents and speech models." + +# Braille rasterisation: 2x4 dots per glyph, mapped to the Unicode braille block. +_BR = [0x1, 0x2, 0x4, 0x40, 0x8, 0x10, 0x20, 0x80] +_OFF = [(0, 0), (0, 1), (0, 2), (0, 3), (1, 0), (1, 1), (1, 2), (1, 3)] +_RING_COLS, _RING_ROWS = 16, 7 +_BASE_RADII = (1.0, 0.62, 0.26) + +_RESET = "\033[0m" +_HIDE = "\033[?25l" +_SHOW = "\033[?25h" + + +def _fg(rgb: tuple[int, int, int]) -> str: + return f"\033[38;2;{rgb[0]};{rgb[1]};{rgb[2]}m" + + +def _rings(radii: tuple[float, ...], thick: float = 0.045) -> list[str]: + cols, rows = _RING_COLS, _RING_ROWS + w, h = cols * 2, rows * 4 + cx, cy = (w - 1) / 2, (h - 1) / 2 + rr = min(cx, cy) + grid = [[0] * w for _ in range(h)] + for y in range(h): + for x in range(w): + d = math.hypot((x - cx) / rr, (y - cy) / rr) + if any(abs(d - r) < thick for r in radii): + grid[y][x] = 1 + lines = [] + for r in range(rows): + line = "".join( + chr(0x2800 + sum(_BR[i] for i, (a, b) in enumerate(_OFF) if grid[r * 4 + b][c * 2 + a])) or " " + for c in range(cols) + ) + lines.append(line) + return lines + + +def _pulse_radii(t: float) -> tuple[float, ...]: + s = 0.28 * math.sin(t * 2 * math.pi) + return tuple(min(1.02, r + s) for r in _BASE_RADII) + + +def _color_enabled() -> bool: + return sys.stdout.isatty() and not os.environ.get("NO_COLOR") + + +def _animate_enabled() -> bool: + return ( + sys.stdout.isatty() + and not os.environ.get("NO_COLOR") + and not os.environ.get("CI") + and not os.environ.get("SMALLESTAI_NO_ANIM") + ) + + +def _term_width() -> int: + try: + return shutil.get_terminal_size((80, 24)).columns + except Exception: + return 80 + + +def print_banner() -> None: + """Render the rings + wordmark to stdout (animated on an interactive TTY).""" + color = _color_enabled() + blue = _fg(_BRAND) if color else "" + reset = _RESET if color else "" + out = sys.stdout + + if _term_width() < _MIN_WIDTH: + out.write(f"\n{_INDENT}{blue}SMALLEST AI{reset}\n") + out.flush() + return + + ring_static = _rings(_BASE_RADII) + + if _animate_enabled(): + out.write(_HIDE) + try: + for line in ring_static: + out.write(f"{_INDENT}{blue}{line}{reset}\n") + fps = 20 + for f in range(1, int(1.2 * fps) + 1): + out.write(f"\033[{_RING_ROWS}A") + for line in _rings(_pulse_radii(f / fps)): + out.write(f"\r{_INDENT}{blue}{line}{reset}\033[K\n") + out.flush() + time.sleep(1 / fps) + out.write(f"\033[{_RING_ROWS}A") + for line in ring_static: + out.write(f"\r{_INDENT}{blue}{line}{reset}\033[K\n") + finally: + out.write(_SHOW + reset) + else: + for line in ring_static: + out.write(f"{_INDENT}{blue}{line}{reset}\n") + + out.write("\n") + for w in _WORDMARK: + out.write(f"{_INDENT}{blue}{w}{reset}\n") + out.flush() diff --git a/src/smallestai/cli/main.py b/src/smallestai/cli/main.py index 9714214c..c2b098f3 100644 --- a/src/smallestai/cli/main.py +++ b/src/smallestai/cli/main.py @@ -7,6 +7,7 @@ from smallestai.cli.agent_crew import initialise_agent_crew_app from smallestai.cli.agents import initialise_agents_app from smallestai.cli.auth import initialise_auth_app +from smallestai.cli.banner import print_banner from smallestai.cli.calls import initialise_calls_app from smallestai.cli.campaigns import initialise_campaigns_app from smallestai.cli.lib.atoms import AtomsAPIClient @@ -20,29 +21,21 @@ app = typer.Typer(help="SmallestAI CLI", no_args_is_help=False, rich_markup_mode="rich") -_BANNER = r"""[bold magenta] - ███████╗███╗ ███╗ █████╗ ██╗ ██╗ ███████╗███████╗████████╗ - ██╔════╝████╗ ████║██╔══██╗██║ ██║ ██╔════╝██╔════╝╚══██╔══╝ - ███████╗██╔████╔██║███████║██║ ██║ █████╗ ███████╗ ██║ - ╚════██║██║╚██╔╝██║██╔══██║██║ ██║ ██╔══╝ ╚════██║ ██║ - ███████║██║ ╚═╝ ██║██║ ██║███████╗███████╗███████╗███████║ ██║ - ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚══════╝ ╚═╝[/bold magenta]""" - _COMMANDS = [ ("agent-crew", "Init, deploy, and manage crew (custom-LLM) voice agents"), ("agents", "Create, inspect, and call voice agents"), ("calls", "Inspect call logs, transcripts, and recordings"), ("campaigns", "Manage outbound calling campaigns"), ("phone-numbers", "Search, rent, and manage phone numbers"), - ("waves", "Text-to-speech, speech-to-text, and voices"), + ("models", "Text-to-speech, speech-to-text, and voices"), ("mcp", "Set up the Smallest AI MCP server for Cursor / Claude"), ("auth", "Log in and manage credentials"), ] def _print_welcome() -> None: - console.print(_BANNER) - console.print(" [dim]Build, deploy, and run voice agents and speech models.[/dim]\n") + print_banner() + console.print("\n [dim]Build, deploy, and run voice agents and speech models.[/dim]\n") table = Table(show_header=False, box=None, padding=(0, 2, 0, 2)) table.add_column(style="bold cyan", no_wrap=True) table.add_column(style="white") @@ -79,8 +72,10 @@ def _root(ctx: typer.Context) -> None: calls_app = initialise_calls_app(auth_client) app.add_typer(calls_app, name="calls") -waves_app = initialise_waves_app(auth_client) -app.add_typer(waves_app, name="waves") +models_app = initialise_waves_app(auth_client) +app.add_typer(models_app, name="models") +# Back-compat: keep the old `waves` name working, hidden from help. +app.add_typer(models_app, name="waves", hidden=True) campaigns_app = initialise_campaigns_app(auth_client) app.add_typer(campaigns_app, name="campaigns") diff --git a/tests/custom/test_cli_banner_mcp.py b/tests/custom/test_cli_banner_mcp.py index 99c26f04..c08ae417 100644 --- a/tests/custom/test_cli_banner_mcp.py +++ b/tests/custom/test_cli_banner_mcp.py @@ -11,8 +11,8 @@ def test_bare_cli_shows_banner_and_commands_not_error(): result = runner.invoke(app, []) assert result.exit_code == 0 assert "Missing command" not in result.output - # command list is present - for name in ("agent-crew", "agents", "calls", "waves", "mcp"): + # command list is present (speech/voices group is now "models") + for name in ("agent-crew", "agents", "calls", "models", "mcp"): assert name in result.output diff --git a/tests/custom/test_cli_banner_render.py b/tests/custom/test_cli_banner_render.py new file mode 100644 index 00000000..d26b28c0 --- /dev/null +++ b/tests/custom/test_cli_banner_render.py @@ -0,0 +1,44 @@ +"""Banner rendering: ring rasterisation, static/compact/animated paths.""" + +import smallestai.cli.banner as banner + + +def test_rings_shape_and_width(): + lines = banner._rings(banner._BASE_RADII) + assert len(lines) == banner._RING_ROWS + assert all(len(line) == banner._RING_COLS for line in lines) + + +def test_static_banner_has_wordmark(capsys, monkeypatch): + # Wide, non-animated: single static frame, full wordmark. + monkeypatch.setattr(banner, "_term_width", lambda: 120) + monkeypatch.setattr(banner, "_animate_enabled", lambda: False) + banner.print_banner() + out = capsys.readouterr().out + assert banner._WORDMARK[0] in out + + +def test_compact_fallback_on_narrow(capsys, monkeypatch): + monkeypatch.setattr(banner, "_term_width", lambda: 60) + banner.print_banner() + out = capsys.readouterr().out + assert "SMALLEST AI" in out + assert banner._WORDMARK[0] not in out # no big figlet when narrow + + +def test_animated_path_runs(capsys, monkeypatch): + # Force the animated branch but no real sleeping. + monkeypatch.setattr(banner, "_term_width", lambda: 120) + monkeypatch.setattr(banner, "_animate_enabled", lambda: True) + monkeypatch.setattr(banner, "_color_enabled", lambda: True) + monkeypatch.setattr(banner.time, "sleep", lambda _s: None) + banner.print_banner() + out = capsys.readouterr().out + assert banner._WORDMARK[0] in out + assert "\033[?25l" in out and "\033[?25h" in out # cursor hidden then restored + + +if __name__ == "__main__": + import pytest + + pytest.main([__file__, "-v"])