Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f6f7a0f75 | |||
| 7880dec565 | |||
| ff71530b1e | |||
| 073eac5d92 |
@@ -0,0 +1,127 @@
|
||||
# Omnigent CLI output contract
|
||||
|
||||
This is the contract every `omnigent` command follows when it writes to a
|
||||
terminal, so the whole CLI reads as one coherent, branded product. The
|
||||
runtime lives in two small modules:
|
||||
|
||||
- **`omnigent/inner/ui.py`** — the styling layer: shared consoles, the
|
||||
brand palette/theme, and the status/structure helpers. This is the
|
||||
module command code should import.
|
||||
- **`omnigent/inner/wordmark.py`** — the brand art: the Otto + "omnigent"
|
||||
wordmark lockup and the compact one-line brandmark. Imported by `ui`.
|
||||
|
||||
The interactive REPL header keeps its own builder
|
||||
(`omnigent/inner/banner.py`) — that's the live-session box, not part of
|
||||
this non-interactive contract.
|
||||
|
||||
## The one rule: stdout is data, stderr is decoration
|
||||
|
||||
Everything else follows from this:
|
||||
|
||||
- **stdout** carries machine-readable output — IDs, paths, config dumps,
|
||||
the `version` string, anything a script might parse. Use `ui.console`
|
||||
(or `click.echo`) for it.
|
||||
- **stderr** carries decoration and diagnostics — warnings, errors, the
|
||||
brand banner, spinners, progress. Use `ui.err_console` / `ui.warn` /
|
||||
`ui.error` / the banner helpers.
|
||||
|
||||
So `omnigent version | cat`, `omnigent config list | jq`, and piped
|
||||
one-shot output stay byte-clean, while the human at a terminal still gets
|
||||
color and branding on stderr.
|
||||
|
||||
**Never** hand-roll raw ANSI escapes or call `click.secho(fg=...)` in new
|
||||
code. **Never** print the banner or status decoration to stdout.
|
||||
|
||||
## Palette
|
||||
|
||||
One brand accent; semantic colors stay conventional. Defined as a
|
||||
`rich.theme.Theme` in `ui.py`:
|
||||
|
||||
| Token | Color | Use |
|
||||
| -------------- | ------------------ | ------------------------------------ |
|
||||
| `omni.accent` | `#F43BA6` magenta | Brand — wordmark, headers, `==>`, spinner |
|
||||
| `omni.success` | green | Success / done |
|
||||
| `omni.warning` | yellow | Warnings (stderr) |
|
||||
| `omni.error` | red | Errors (stderr) |
|
||||
| `omni.info` | cyan | Informational |
|
||||
| `omni.muted` | dim | Metadata, secondary text |
|
||||
|
||||
`#F43BA6` is Otto's magenta — the single source is
|
||||
`omnigent.inner.mascots.MASCOT_ART_COLOR`, re-exported as `ui.ACCENT`.
|
||||
The `scripts/install_oss.sh` installer mirrors the same accent
|
||||
(`\033[38;2;244;59;166m`) so the installer and the tool agree.
|
||||
|
||||
## Helper API (`omnigent.inner.ui`)
|
||||
|
||||
Status lines — consistent glyph + color, correct stream:
|
||||
|
||||
```python
|
||||
ui.step("Installing Omnigent") # ==> accent (stdout)
|
||||
ui.success("Verified omnigent") # ✓ green (stdout)
|
||||
ui.info("Using ~/.omnigent") # · dim (stdout)
|
||||
ui.warn("tmux not found") # ! yellow (stderr)
|
||||
ui.error("uv is required") # ✗ red (stderr)
|
||||
```
|
||||
|
||||
Messages are emitted verbatim (never reparsed as rich markup), so a
|
||||
message containing `[...]` is safe.
|
||||
|
||||
Structure:
|
||||
|
||||
```python
|
||||
ui.header("Configured credentials") # bold accent section header
|
||||
ui.kv("Session", "New session") # aligned label / value row
|
||||
ui.rule("Setup") # horizontal accent rule
|
||||
tbl = ui.table(title="Hosts"); ...; ui.console.print(tbl) # branded Table
|
||||
ui.console.print(ui.panel(body, title="Note")) # branded Panel
|
||||
```
|
||||
|
||||
Raw streams when you need them: `ui.console` (stdout), `ui.err_console`
|
||||
(stderr).
|
||||
|
||||
## When to show the banner
|
||||
|
||||
Banner output is drawn on stderr and TTY-gated by `ui.show_banner()` (a
|
||||
no-op off a TTY or when `OMNIGENT_NO_BANNER` is set):
|
||||
|
||||
- **Full lockup** — `ui.print_landing(...)` — Otto + wordmark, optional
|
||||
gradient / tagline / epilogue. The hero moment, reserved for the few
|
||||
landing surfaces:
|
||||
- `omnigent --help` (the top-level group, via `_OmnigentCLI.format_help`)
|
||||
- `omnigent setup` (first-run experience)
|
||||
- the installer banner
|
||||
- **Nothing** — every other command. Regular commands (`version`,
|
||||
`upgrade`, `server status`, `config list`, …) print their output
|
||||
unbranded so the CLI stays quiet and scriptable. We deliberately do
|
||||
*not* sprinkle a brandmark on individual commands.
|
||||
|
||||
A compact one-line brandmark helper (`ui.print_brandmark(subtitle=...)`,
|
||||
`✦ omnigent`) exists for opt-in use, but is intentionally **not** wired
|
||||
onto any command today — add it only if a specific surface clearly wants
|
||||
light branding.
|
||||
|
||||
The bare `omnigent` invocation on a TTY launches the REPL (its own
|
||||
branded header); it only falls back to `--help` when non-interactive, so
|
||||
the landing banner naturally appears there.
|
||||
|
||||
## Gating & environment
|
||||
|
||||
| Condition | Effect |
|
||||
| ---------------------------- | ------------------------------------------ |
|
||||
| stdout/stderr not a TTY | No banner; rich emits no color (data clean)|
|
||||
| `NO_COLOR` set | rich renders monochrome (art still shows) |
|
||||
| `OMNIGENT_NO_BANNER` truthy | No banner/brandmark even on a TTY |
|
||||
| `OMNIGENT_NO_SPINNER` truthy | No startup spinner (pre-existing) |
|
||||
|
||||
## Adding a new command — checklist
|
||||
|
||||
1. `from omnigent.inner import ui` (import lazily inside the command body
|
||||
if the module is import-cost sensitive).
|
||||
2. Print **data** to stdout via `ui.console.print` / `click.echo`.
|
||||
3. Print **status** via `ui.step/success/info`; **problems** via
|
||||
`ui.warn/error` (these go to stderr automatically).
|
||||
4. Build tables/panels with `ui.table()` / `ui.panel()`.
|
||||
5. Add a banner only if the command is a landing/first-run surface
|
||||
(`print_landing`) or a read-only branded command (`print_brandmark`).
|
||||
Leave it off scripted/data commands.
|
||||
6. No raw ANSI, no `click.secho(fg=...)`, no decoration on stdout.
|
||||
@@ -177,6 +177,8 @@ def runner_startup_progress(
|
||||
from rich.live import Live
|
||||
from rich.spinner import Spinner
|
||||
|
||||
from omnigent.inner.mascots import MASCOT_ART_COLOR
|
||||
|
||||
# ``Console(stderr=True)`` keeps the spinner off stdout so piped
|
||||
# one-shot output (``omnigent run … -p "…"``) stays clean.
|
||||
# ``transient=True`` erases the spinner line on stop. We drive a
|
||||
@@ -190,7 +192,7 @@ def runner_startup_progress(
|
||||
# and a CPR handshake — isn't disturbed by a stream proxy being
|
||||
# torn down a frame before the prompt's first paint.
|
||||
console = Console(stderr=True)
|
||||
spinner = Spinner("dots", text=initial_message)
|
||||
spinner = Spinner("dots", text=initial_message, style=MASCOT_ART_COLOR)
|
||||
live = Live(
|
||||
spinner,
|
||||
console=console,
|
||||
|
||||
+43
-13
@@ -43,6 +43,7 @@ from omnigent.host.local_server import (
|
||||
stop_local_omnigent_server,
|
||||
stop_untracked_local_server,
|
||||
)
|
||||
from omnigent.inner import ui
|
||||
from omnigent.onboarding.sandboxes import available_providers as _sandbox_providers
|
||||
from omnigent.onboarding.ucode_setup import (
|
||||
build_ucode_configure_command,
|
||||
@@ -507,7 +508,7 @@ def _resolve_first_run_plan() -> _FirstRunPlan | None:
|
||||
|
||||
plan = _pick_first_run_harness()
|
||||
if plan is None:
|
||||
click.secho("Found no harnesses configured.", fg="yellow", err=True)
|
||||
ui.warn("Found no harnesses configured.")
|
||||
_run_configure_harnesses_interactive()
|
||||
plan = _pick_first_run_harness()
|
||||
return plan
|
||||
@@ -1107,7 +1108,34 @@ def _print_version_callback(ctx: click.Context, _param: click.Parameter, value:
|
||||
ctx.exit()
|
||||
|
||||
|
||||
@click.group()
|
||||
class _OmnigentCLI(click.Group):
|
||||
"""Top-level group that prints the brand lockup above its help.
|
||||
|
||||
The Otto + wordmark lockup is drawn on stderr (decoration) and is
|
||||
TTY-gated by :func:`omnigent.inner.ui.show_banner`, so ``omnigent
|
||||
--help`` shows the banner interactively while piped/CI help stays
|
||||
clean. Only the top-level group overrides help; subcommand help
|
||||
(``omnigent run --help``) is untouched.
|
||||
"""
|
||||
|
||||
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
||||
from omnigent.inner import ui
|
||||
|
||||
if ui.show_banner():
|
||||
import importlib.metadata
|
||||
|
||||
try:
|
||||
version = importlib.metadata.version("omnigent")
|
||||
except importlib.metadata.PackageNotFoundError: # pragma: no cover
|
||||
version = ""
|
||||
epilogue = [("Get started", "omnigent setup")]
|
||||
if version:
|
||||
epilogue.insert(0, ("Version", version))
|
||||
ui.print_landing(tagline="all your agents, one cli", epilogue=epilogue)
|
||||
super().format_help(ctx, formatter)
|
||||
|
||||
|
||||
@click.group(cls=_OmnigentCLI)
|
||||
@click.option(
|
||||
"--version",
|
||||
is_flag=True,
|
||||
@@ -6801,7 +6829,8 @@ def _warn_missing_harness_dependencies() -> None:
|
||||
``codex`` do need both, hence the prominent notice.
|
||||
|
||||
:returns: None. Side effect: writes a yellow warning block to stderr
|
||||
via :func:`click.secho` when one or more dependencies are missing.
|
||||
via :mod:`omnigent.inner.ui` when one or more dependencies are
|
||||
missing.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
node_problem = _node_dependency_problem()
|
||||
@@ -6815,20 +6844,16 @@ def _warn_missing_harness_dependencies() -> None:
|
||||
)
|
||||
if not problems:
|
||||
return
|
||||
click.secho(
|
||||
"\n⚠ External tooling needed for some harnesses is missing or outdated:",
|
||||
fg="yellow",
|
||||
bold=True,
|
||||
err=True,
|
||||
)
|
||||
ui.err_console.print()
|
||||
ui.warn("External tooling needed for some harnesses is missing or outdated:")
|
||||
for problem in problems:
|
||||
click.secho(f" • {problem}", fg="yellow", err=True)
|
||||
click.secho(
|
||||
ui.err_console.print(f" • {problem}", style="omni.warning", markup=False)
|
||||
ui.err_console.print(
|
||||
"You can still configure credentials — the pure-Python openai-agents harness "
|
||||
"runs without these — but install them before `omnigent claude` / "
|
||||
"`omnigent codex` or the Pi harness.\n",
|
||||
fg="yellow",
|
||||
err=True,
|
||||
style="omni.warning",
|
||||
markup=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -8652,6 +8677,11 @@ def setup(internal_beta: bool) -> None:
|
||||
``omnigent config list``.) Pass ``--internal-beta`` to configure
|
||||
Databricks internal-beta defaults and authentication instead.
|
||||
"""
|
||||
from omnigent.inner import ui
|
||||
|
||||
# Brand lockup at the top of the first-run experience (TTY-gated).
|
||||
ui.print_landing(tagline="all your agents, one cli")
|
||||
|
||||
if internal_beta:
|
||||
# The internal-beta workspace defaults are excluded from the public OSS
|
||||
# build. Fail loud with a clear message instead of an ImportError deep
|
||||
|
||||
+10
-4
@@ -17,6 +17,7 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from omnigent.inner import ui
|
||||
from omnigent.onboarding.sandboxes import (
|
||||
SandboxLauncher,
|
||||
available_providers,
|
||||
@@ -139,9 +140,12 @@ def _print_ready_banner(provider: str, sandbox_id: str, server_url: str) -> None
|
||||
:param server_url: Server URL for the connect hint (``--server``
|
||||
is required on create, so it is always known here).
|
||||
"""
|
||||
click.secho("\n✓ Sandbox ready.\n", fg="green", bold=True)
|
||||
click.echo(f"Sandbox: {sandbox_id} (provider: {provider})")
|
||||
click.echo(f"Server: {server_url}\n")
|
||||
ui.console.print()
|
||||
ui.success("Sandbox ready.")
|
||||
ui.console.print()
|
||||
ui.kv("Sandbox", f"{sandbox_id} (provider: {provider})")
|
||||
ui.kv("Server", server_url)
|
||||
ui.console.print()
|
||||
click.echo("To register the sandbox as a host with your server:")
|
||||
click.echo(
|
||||
f" omnigent sandbox connect --provider {provider} --sandbox-id {sandbox_id} "
|
||||
@@ -335,7 +339,9 @@ def sandbox_auth(
|
||||
server_url=app_url,
|
||||
workspace=workspace,
|
||||
)
|
||||
click.secho("\n✓ Sandbox logged in.\n", fg="green", bold=True)
|
||||
ui.console.print()
|
||||
ui.success("Sandbox logged in.")
|
||||
ui.console.print()
|
||||
|
||||
|
||||
@sandbox.command("connect")
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Shared terminal-output styling for the Omnigent CLI.
|
||||
|
||||
This is the one place that owns the consoles, the brand palette, and the
|
||||
status / structure helpers that every command should print through, so
|
||||
that ``omnigent``'s output reads as one coherent product. See
|
||||
``designs/CLI_CONTRACT.md`` for the full contract.
|
||||
|
||||
Core rule — **stdout carries data, stderr carries decoration**:
|
||||
|
||||
* Machine-readable output (IDs, paths, config dumps, the ``version``
|
||||
string) goes to stdout via :data:`console`, so ``omnigent … | cat``
|
||||
stays clean.
|
||||
* Warnings, errors, and the brand banner go to stderr (via
|
||||
:data:`err_console` / the banner helpers) and are TTY-gated, so they
|
||||
never corrupt piped stdout.
|
||||
|
||||
Color is handled by rich: both consoles honor ``NO_COLOR`` and terminal
|
||||
capability automatically, so callers never emit raw ANSI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from rich.theme import Theme
|
||||
|
||||
from . import wordmark
|
||||
|
||||
#: Brand accent — Otto's magenta-pink, shared with the mascot and banner.
|
||||
ACCENT = wordmark.WORDMARK_COLOR
|
||||
|
||||
#: Env var that force-disables the brand banner even on a TTY. Mirrors the
|
||||
#: ``OMNIGENT_NO_SPINNER`` convention in :mod:`omnigent._runner_startup`.
|
||||
NO_BANNER_ENV_VAR = "OMNIGENT_NO_BANNER"
|
||||
|
||||
# Named styles, so call sites use semantic tokens ("omni.warning") rather
|
||||
# than hard-coded colors. Semantic colors stay conventional; only the
|
||||
# accent is brand-specific.
|
||||
OMNIGENT_THEME = Theme(
|
||||
{
|
||||
"omni.accent": ACCENT,
|
||||
"omni.success": "green",
|
||||
"omni.warning": "yellow",
|
||||
"omni.error": "bold red",
|
||||
"omni.info": "cyan",
|
||||
"omni.muted": "dim",
|
||||
}
|
||||
)
|
||||
|
||||
# ``highlight=False`` so rich never auto-recolors numbers / paths / URLs
|
||||
# inside our messages — CLI output must be predictable. File is resolved
|
||||
# lazily by rich, so these follow ``CliRunner`` / ``capsys`` stream swaps.
|
||||
#: Console for stdout — data and primary output.
|
||||
console = Console(theme=OMNIGENT_THEME, highlight=False)
|
||||
#: Console for stderr — status, warnings, errors, and the brand banner.
|
||||
err_console = Console(stderr=True, theme=OMNIGENT_THEME, highlight=False)
|
||||
|
||||
|
||||
def show_banner(*, isatty: bool | None = None, env: dict[str, str] | None = None) -> bool:
|
||||
"""
|
||||
Decide whether the brand banner / brandmark should be drawn.
|
||||
|
||||
The banner is decoration, so it only shows on an interactive stderr
|
||||
and can be force-disabled with ``OMNIGENT_NO_BANNER``. Color *within*
|
||||
the banner is a separate concern handled by rich (``NO_COLOR`` simply
|
||||
renders the art in monochrome).
|
||||
|
||||
:param isatty: Override for ``sys.stderr.isatty()`` (tests pass this
|
||||
to exercise both branches without a real PTY).
|
||||
:param env: Environment snapshot; defaults to ``os.environ``.
|
||||
:returns: ``True`` when the banner should be drawn.
|
||||
"""
|
||||
if isatty is None:
|
||||
isatty = sys.stderr.isatty()
|
||||
if not isatty:
|
||||
return False
|
||||
env = os.environ if env is None else env
|
||||
raw = str(env.get(NO_BANNER_ENV_VAR, "")).strip().lower()
|
||||
return raw not in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
# ── Status helpers ────────────────────────────────────────────────────
|
||||
# A consistent glyph + color per severity. ``step``/``success``/``info``
|
||||
# are normal status on stdout; ``warn``/``error`` are diagnostics on
|
||||
# stderr (always correct, never pollutes piped data). The message is
|
||||
# appended as plain Text so it is never reinterpreted as rich markup.
|
||||
|
||||
|
||||
def _emit(target: Console, glyph: str, style: str, message: str) -> None:
|
||||
"""
|
||||
Print ``<glyph> <message>`` with *glyph* styled, *message* plain.
|
||||
|
||||
:param target: Console to print to (stdout or stderr).
|
||||
:param glyph: Leading status glyph, e.g. ``"✓"``.
|
||||
:param style: Style name for the glyph, e.g. ``"omni.success"``.
|
||||
:param message: Plain message text (never parsed as markup).
|
||||
"""
|
||||
line = Text()
|
||||
line.append(f"{glyph} ", style=style)
|
||||
line.append(message)
|
||||
target.print(line)
|
||||
|
||||
|
||||
def step(message: str) -> None:
|
||||
"""Print an ``==>`` progress step (accent) to stdout."""
|
||||
_emit(console, "==>", "omni.accent", message)
|
||||
|
||||
|
||||
def success(message: str) -> None:
|
||||
"""Print a ``✓`` success line (green) to stdout."""
|
||||
_emit(console, "✓", "omni.success", message)
|
||||
|
||||
|
||||
def info(message: str) -> None:
|
||||
"""Print a dim ``·`` informational line to stdout."""
|
||||
_emit(console, "·", "omni.muted", message)
|
||||
|
||||
|
||||
def warn(message: str) -> None:
|
||||
"""Print a ``!`` warning (yellow) to stderr."""
|
||||
_emit(err_console, "!", "omni.warning", message)
|
||||
|
||||
|
||||
def error(message: str) -> None:
|
||||
"""Print a ``✗`` error (red) to stderr."""
|
||||
_emit(err_console, "✗", "omni.error", message)
|
||||
|
||||
|
||||
# ── Structure helpers ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def header(title: str) -> None:
|
||||
"""Print a bold accent section header to stdout."""
|
||||
console.print(Text(title, style="bold omni.accent"))
|
||||
|
||||
|
||||
def kv(label: str, value: str, *, label_width: int = 10) -> None:
|
||||
"""
|
||||
Print one aligned ``label value`` row (dim label, bold value).
|
||||
|
||||
:param label: Left-hand label, e.g. ``"Session"``.
|
||||
:param value: Right-hand value, e.g. ``"New session"``.
|
||||
:param label_width: Column width the label is padded to.
|
||||
"""
|
||||
line = Text()
|
||||
line.append(label.ljust(label_width), style="dim")
|
||||
line.append(value, style="bold")
|
||||
console.print(line)
|
||||
|
||||
|
||||
def rule(title: str = "") -> None:
|
||||
"""Print a horizontal accent rule (optionally titled) to stdout."""
|
||||
console.rule(title, style="omni.accent")
|
||||
|
||||
|
||||
def table(*, title: str | None = None, **kwargs: object) -> Table:
|
||||
"""
|
||||
Build a :class:`rich.table.Table` pre-styled with the brand palette.
|
||||
|
||||
Callers add columns/rows and then ``console.print(tbl)``. Centralizing
|
||||
construction keeps every table's header/border consistent.
|
||||
|
||||
:param title: Optional table title.
|
||||
:returns: A configured (empty) ``Table``.
|
||||
"""
|
||||
return Table(
|
||||
title=title,
|
||||
header_style="bold omni.accent",
|
||||
border_style="omni.muted",
|
||||
title_style="bold omni.accent",
|
||||
**kwargs, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def panel(renderable: object, *, title: str | None = None, **kwargs: object) -> Panel:
|
||||
"""
|
||||
Wrap *renderable* in a :class:`rich.panel.Panel` with the brand border.
|
||||
|
||||
:param renderable: Any rich renderable or string to box.
|
||||
:param title: Optional panel title.
|
||||
:returns: A configured ``Panel``.
|
||||
"""
|
||||
return Panel(
|
||||
renderable, # type: ignore[arg-type]
|
||||
title=title,
|
||||
border_style="omni.accent",
|
||||
**kwargs, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
# ── Brand banner ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def print_landing(
|
||||
*,
|
||||
epilogue: list[tuple[str, str]] | None = None,
|
||||
gradient: bool = True,
|
||||
tagline: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Print the full Otto + wordmark lockup (the hero banner), TTY-gated.
|
||||
|
||||
Drawn on stderr so it never lands in piped stdout. No-op when the
|
||||
banner is suppressed (non-TTY or ``OMNIGENT_NO_BANNER``).
|
||||
|
||||
:param epilogue: Optional aligned ``(label, value)`` rows beneath the
|
||||
art (e.g. version / next-step).
|
||||
:param gradient: Fade the wordmark magenta→pink (default on for the
|
||||
hero moment); falls back to flat accent on low-color terminals.
|
||||
:param tagline: Optional dim tagline under the art.
|
||||
:returns: None.
|
||||
"""
|
||||
if not show_banner():
|
||||
return
|
||||
wordmark.render_lockup(err_console, gradient=gradient, tagline=tagline, epilogue=epilogue)
|
||||
|
||||
|
||||
def print_brandmark(subtitle: str | None = None) -> None:
|
||||
"""
|
||||
Print the compact one-line brandmark (``✦ omnigent``), TTY-gated.
|
||||
|
||||
For non-interactive commands that want a branded header without the
|
||||
full banner. Drawn on stderr; no-op when the banner is suppressed.
|
||||
|
||||
:param subtitle: Optional dim trailing text, e.g. a version string.
|
||||
:returns: None.
|
||||
"""
|
||||
if not show_banner():
|
||||
return
|
||||
wordmark.render_compact(err_console, subtitle=subtitle)
|
||||
@@ -0,0 +1,234 @@
|
||||
"""The Omnigent brand wordmark and Otto lockup for CLI output.
|
||||
|
||||
A bold "ANSI-Shadow" block-letter ``omnigent`` wordmark — the canonical
|
||||
figlet font with one duplicate body row dropped (5 rows), so every letter
|
||||
stays legible and it sits exactly as tall as the Otto-the-starfish mascot
|
||||
from :mod:`omnigent.inner.mascots`, which it pairs with 1:1.
|
||||
|
||||
This module owns the *art* and its rendering onto a caller-supplied
|
||||
:class:`rich.console.Console`. The decision of *whether* to draw the
|
||||
banner (TTY gating, ``OMNIGENT_NO_BANNER``) lives one layer up in
|
||||
:mod:`omnigent.inner.ui`, which is the only module that should be imported
|
||||
by command code. Keeping the gate out of here avoids a circular import
|
||||
(``ui`` imports ``wordmark``) and keeps the art unit-testable in isolation.
|
||||
|
||||
The brand color is Otto's magenta-pink ``#F43BA6`` (see
|
||||
:data:`omnigent.inner.mascots.MASCOT_ART_COLOR`); the optional gradient
|
||||
fades it toward a lighter pink across the wordmark columns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
from .mascots import MASCOT_ART_COL_WIDTH, MASCOT_ART_COLOR, MASCOT_ART_LINES
|
||||
|
||||
# Flat brand accent — kept in sync with the mascot/banner border so the
|
||||
# wordmark, Otto, and the interactive REPL box all read as one color.
|
||||
WORDMARK_COLOR = MASCOT_ART_COLOR
|
||||
|
||||
# Gradient endpoints (magenta → soft pink). Used only when a caller asks
|
||||
# for ``gradient=True`` and the terminal supports enough colors; rich
|
||||
# downgrades or drops the color automatically otherwise.
|
||||
_GRADIENT_START = (0xF4, 0x3B, 0xA6) # #F43BA6
|
||||
_GRADIENT_END = (0xFF, 0x9F, 0xD6) # #FF9FD6
|
||||
|
||||
# Two-space gutter between Otto and the wordmark in the lockup.
|
||||
_GAP = " "
|
||||
# Left indent applied to every printed row so the banner doesn't hug the
|
||||
# terminal edge (matches the installer's two-space banner indent).
|
||||
_INDENT = " "
|
||||
|
||||
# Per-letter "ANSI-Shadow" glyphs — the canonical figlet font (as used by
|
||||
# NeonX and TAAG) with a single near-duplicate body row dropped, leaving 5
|
||||
# rows. This keeps the full-height, fully-legible letterforms while sitting
|
||||
# exactly as tall as Otto (5 rows), so the lockup pairs 1:1 with no unpaired
|
||||
# rows. Stored as a glyph map rather than a frozen multi-line blob so the
|
||||
# wordmark is regenerable and a missing letter fails loud at import. Each
|
||||
# glyph's rows are equal display width so columns stay aligned when letters
|
||||
# are concatenated.
|
||||
_GLYPH_ROWS = 5
|
||||
_GLYPHS: dict[str, tuple[str, ...]] = {
|
||||
"o": (" ██████╗ ", "██╔═══██╗", "██║ ██║", "╚██████╔╝", " ╚═════╝ "),
|
||||
"m": ("███╗ ███╗", "████╗ ████║", "██╔████╔██║", "██║ ╚═╝ ██║", "╚═╝ ╚═╝"),
|
||||
"n": ("███╗ ██╗", "████╗ ██║", "██╔██╗ ██║", "██║ ╚████║", "╚═╝ ╚═══╝"),
|
||||
"i": ("██╗", "██║", "██║", "██║", "╚═╝"),
|
||||
"g": (" ██████╗ ", "██╔════╝ ", "██║ ███╗", "╚██████╔╝", " ╚═════╝ "),
|
||||
"e": ("███████╗", "██╔════╝", "█████╗ ", "███████╗", "╚══════╝"),
|
||||
"t": ("████████╗", "╚══██╔══╝", " ██║ ", " ██║ ", " ╚═╝ "),
|
||||
}
|
||||
|
||||
_WORDMARK_TEXT = "omnigent"
|
||||
|
||||
|
||||
def _build_wordmark(word: str) -> tuple[str, ...]:
|
||||
"""
|
||||
Concatenate per-letter glyphs into the wordmark rows.
|
||||
|
||||
:param word: The text to render; every character must have a glyph
|
||||
in :data:`_GLYPHS`, e.g. ``"omnigent"``.
|
||||
:returns: The wordmark rows (top cap · identity · bottom · shadow).
|
||||
"""
|
||||
rows = ["" for _ in range(_GLYPH_ROWS)]
|
||||
for char in word:
|
||||
glyph = _GLYPHS[char]
|
||||
for i in range(_GLYPH_ROWS):
|
||||
rows[i] += glyph[i]
|
||||
return tuple(rows)
|
||||
|
||||
|
||||
#: The rows of the ``omnigent`` wordmark, as plain (uncolored) text.
|
||||
WORDMARK_LINES: tuple[str, ...] = _build_wordmark(_WORDMARK_TEXT)
|
||||
|
||||
# Which Otto row each wordmark row sits on. Otto and the wordmark are both
|
||||
# five rows tall, so they pair 1:1 — no unpaired rows on either side.
|
||||
_WORDMARK_ROW_FOR_OTTO_ROW = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4}
|
||||
|
||||
|
||||
def wordmark_lines() -> list[str]:
|
||||
"""
|
||||
Return the wordmark on its own (no mascot), as plain text rows.
|
||||
|
||||
:returns: The wordmark rows, e.g. for embedding in a doc or a bash
|
||||
banner.
|
||||
"""
|
||||
return list(WORDMARK_LINES)
|
||||
|
||||
|
||||
def lockup_lines() -> list[str]:
|
||||
"""
|
||||
Return the Otto + wordmark lockup as plain text rows (no color).
|
||||
|
||||
Otto sits on the left (5 rows × :data:`MASCOT_ART_COL_WIDTH` cells)
|
||||
with the 5-row wordmark aligned 1:1 beside it. Trailing whitespace is
|
||||
stripped so the plain form is clean for snapshots and docs.
|
||||
|
||||
:returns: Five rows of the composed lockup.
|
||||
"""
|
||||
out: list[str] = []
|
||||
for i, art in enumerate(MASCOT_ART_LINES):
|
||||
pad = " " * (MASCOT_ART_COL_WIDTH - cell_len(art))
|
||||
wm_index = _WORDMARK_ROW_FOR_OTTO_ROW.get(i)
|
||||
wm = WORDMARK_LINES[wm_index] if wm_index is not None else ""
|
||||
out.append(f"{_INDENT}{art}{pad}{_GAP}{wm}".rstrip())
|
||||
return out
|
||||
|
||||
|
||||
def _blend(start: tuple[int, int, int], end: tuple[int, int, int], t: float) -> str:
|
||||
"""
|
||||
Linearly interpolate two RGB triples into a ``#RRGGBB`` hex string.
|
||||
|
||||
:param start: RGB at ``t == 0``, e.g. ``(244, 59, 166)``.
|
||||
:param end: RGB at ``t == 1``.
|
||||
:param t: Position in ``[0, 1]``.
|
||||
:returns: Hex color, e.g. ``"#f43ba6"``.
|
||||
"""
|
||||
r = round(start[0] + (end[0] - start[0]) * t)
|
||||
g = round(start[1] + (end[1] - start[1]) * t)
|
||||
b = round(start[2] + (end[2] - start[2]) * t)
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
def _wordmark_row_text(row: str, total_width: int, *, gradient: bool) -> Text:
|
||||
"""
|
||||
Render one wordmark row as a styled :class:`rich.text.Text`.
|
||||
|
||||
:param row: The plain wordmark row.
|
||||
:param total_width: Full wordmark width, used as the gradient span so
|
||||
every row shares the same per-column color ramp.
|
||||
:param gradient: When ``True``, fade each column from magenta to pink;
|
||||
otherwise paint the whole row the flat brand accent.
|
||||
:returns: A styled ``Text`` for the row.
|
||||
"""
|
||||
if not gradient:
|
||||
return Text(row, style=WORDMARK_COLOR)
|
||||
text = Text()
|
||||
span = max(1, total_width - 1)
|
||||
for column, char in enumerate(row):
|
||||
if char == " ":
|
||||
text.append(" ")
|
||||
continue
|
||||
color = _blend(_GRADIENT_START, _GRADIENT_END, column / span)
|
||||
text.append(char, style=color)
|
||||
return text
|
||||
|
||||
|
||||
def render_lockup(
|
||||
console: Console,
|
||||
*,
|
||||
gradient: bool = False,
|
||||
tagline: str | None = None,
|
||||
epilogue: list[tuple[str, str]] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Print the Otto + wordmark lockup to *console*.
|
||||
|
||||
Color is applied via rich styles, so the console's own color settings
|
||||
(NO_COLOR, terminal capability) decide whether color actually renders;
|
||||
a no-color console prints the same art in monochrome.
|
||||
|
||||
:param console: Destination console (typically ``ui.err_console``).
|
||||
:param gradient: Fade the wordmark magenta→pink instead of flat accent.
|
||||
:param tagline: Optional dim line printed under the lockup, e.g.
|
||||
``"all your agents, one cli"``.
|
||||
:param epilogue: Optional aligned label/value rows printed beneath the
|
||||
art, e.g. ``[("Version", "0.4.2"), ("Next", "omnigent setup")]``.
|
||||
:returns: None.
|
||||
"""
|
||||
total_width = max(len(line) for line in WORDMARK_LINES)
|
||||
console.print()
|
||||
for i, art in enumerate(MASCOT_ART_LINES):
|
||||
pad = " " * (MASCOT_ART_COL_WIDTH - cell_len(art))
|
||||
line = Text(_INDENT)
|
||||
line.append(f"{art}{pad}", style=WORDMARK_COLOR)
|
||||
wm_index = _WORDMARK_ROW_FOR_OTTO_ROW.get(i)
|
||||
if wm_index is not None:
|
||||
line.append(_GAP)
|
||||
line.append_text(
|
||||
_wordmark_row_text(WORDMARK_LINES[wm_index], total_width, gradient=gradient)
|
||||
)
|
||||
console.print(line)
|
||||
if tagline:
|
||||
console.print(Text(f"{_INDENT}{tagline}", style="dim"))
|
||||
if epilogue:
|
||||
console.print()
|
||||
_print_epilogue(console, epilogue)
|
||||
console.print()
|
||||
|
||||
|
||||
def _print_epilogue(console: Console, rows: list[tuple[str, str]]) -> None:
|
||||
"""
|
||||
Print aligned ``label value`` rows (dim label, bold value).
|
||||
|
||||
:param console: Destination console.
|
||||
:param rows: ``(label, value)`` pairs; labels are left-padded to a
|
||||
common width so the values line up.
|
||||
:returns: None.
|
||||
"""
|
||||
label_width = max(cell_len(label) for label, _ in rows) + 3
|
||||
for label, value in rows:
|
||||
line = Text(_INDENT)
|
||||
line.append(label.ljust(label_width), style="dim")
|
||||
line.append(value, style="bold")
|
||||
console.print(line)
|
||||
|
||||
|
||||
def render_compact(console: Console, *, subtitle: str | None = None) -> None:
|
||||
"""
|
||||
Print the one-line brandmark: ``✦ omnigent <subtitle>``.
|
||||
|
||||
Used as a lightweight branded header on non-interactive commands that
|
||||
don't warrant the full lockup (``version``, ``status``, ``upgrade``…).
|
||||
|
||||
:param console: Destination console (typically ``ui.err_console``).
|
||||
:param subtitle: Optional dim trailing text, e.g. a version string.
|
||||
:returns: None.
|
||||
"""
|
||||
line = Text(_INDENT)
|
||||
line.append("✦ ", style=WORDMARK_COLOR)
|
||||
line.append("omnigent", style=f"bold {WORDMARK_COLOR}")
|
||||
if subtitle:
|
||||
line.append(f" {subtitle}", style="dim")
|
||||
console.print(line)
|
||||
@@ -46,6 +46,7 @@ from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
import click
|
||||
|
||||
from omnigent.inner import ui
|
||||
from omnigent.onboarding.sandboxes.base import (
|
||||
DEFAULT_HOST_IMAGE,
|
||||
RemoteCommandResult,
|
||||
@@ -407,11 +408,12 @@ class DaytonaSandboxLauncher(SandboxLauncher):
|
||||
try:
|
||||
handle.set_autostop_interval(_AUTO_STOP_DISABLED)
|
||||
except daytona.DaytonaError as exc:
|
||||
click.secho(
|
||||
ui.console.print(
|
||||
f" → warning: could not disable idle auto-stop on "
|
||||
f"'{sandbox_id}' ({exc}); the sandbox may stop after "
|
||||
"Daytona's idle timeout.",
|
||||
fg="yellow",
|
||||
style="omni.warning",
|
||||
markup=False,
|
||||
)
|
||||
else:
|
||||
click.echo(" → idle auto-stop disabled (sandbox lives until deleted)")
|
||||
|
||||
@@ -49,6 +49,7 @@ from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
import click
|
||||
|
||||
from omnigent.inner import ui
|
||||
from omnigent.onboarding.sandboxes.base import (
|
||||
RemoteCommandResult,
|
||||
RemoteProcess,
|
||||
@@ -548,11 +549,12 @@ class E2BSandboxLauncher(SandboxLauncher):
|
||||
# The requested lifetime exceeds this account's maximum (e.g. a
|
||||
# Hobby account's 1 h cap vs the 24 h default) and E2B rejected it
|
||||
# rather than clamping — retry once at the cap.
|
||||
click.secho(
|
||||
ui.console.print(
|
||||
f" → requested {timeout // 3600}h lifetime exceeds this E2B account's "
|
||||
f"maximum ({cap // 3600}h); retrying clamped to it (set "
|
||||
f"{MAX_LIFETIME_ENV_VAR} to request a specific lifetime).",
|
||||
fg="yellow",
|
||||
style="omni.warning",
|
||||
markup=False,
|
||||
)
|
||||
try:
|
||||
return Sandbox.create(
|
||||
@@ -598,10 +600,11 @@ class E2BSandboxLauncher(SandboxLauncher):
|
||||
try:
|
||||
handle.set_timeout(lifetime)
|
||||
except SandboxException as exc:
|
||||
click.secho(
|
||||
ui.console.print(
|
||||
f" → warning: could not extend the lifetime of '{sandbox_id}' "
|
||||
f"({exc}); the sandbox will stop at its current timeout.",
|
||||
fg="yellow",
|
||||
style="omni.warning",
|
||||
markup=False,
|
||||
)
|
||||
else:
|
||||
# set_timeout accepts an over-cap request without raising (unlike
|
||||
|
||||
@@ -33,7 +33,9 @@ console = Console()
|
||||
_GREEN = "\033[32m"
|
||||
_DIM = "\033[90m"
|
||||
_BOLD = "\033[1m"
|
||||
_CYAN = "\033[36m"
|
||||
# Brand accent — Otto's magenta-pink (#F43BA6), matching omnigent.inner.ui so
|
||||
# the setup picker's selection pointer reads as the same brand as the banner.
|
||||
_ACCENT = "\033[38;2;244;59;166m"
|
||||
_RESET = "\033[0m"
|
||||
_CHECK = f"{_GREEN}\u2713{_RESET}"
|
||||
_CROSS = f"{_DIM}\u2717{_RESET}"
|
||||
@@ -123,7 +125,7 @@ def _arrow_menu(
|
||||
sys.stdout.write(f"\033[{total_lines}A")
|
||||
|
||||
for i, label in enumerate(options):
|
||||
pointer = f"{_CYAN}>{_RESET}" if i == cursor else " "
|
||||
pointer = f"{_ACCENT}>{_RESET}" if i == cursor else " "
|
||||
if multi:
|
||||
check = f"{_GREEN}*{_RESET}" if i in selected else " "
|
||||
prefix = f" {pointer} {check} "
|
||||
|
||||
+23
-5
@@ -40,7 +40,7 @@ ESC=$(printf '\033')
|
||||
RESET=
|
||||
BOLD=
|
||||
DIM=
|
||||
CYAN=
|
||||
MAGENTA=
|
||||
GREEN=
|
||||
YELLOW=
|
||||
RED=
|
||||
@@ -54,19 +54,36 @@ init_style() {
|
||||
RESET="${ESC}[0m"
|
||||
BOLD="${ESC}[1m"
|
||||
DIM="${ESC}[2m"
|
||||
CYAN="${ESC}[36m"
|
||||
# Brand accent — Otto's magenta-pink (#F43BA6), matching the Python CLI
|
||||
# palette in omnigent/inner/ui.py so the installer and the tool agree.
|
||||
MAGENTA="${ESC}[38;2;244;59;166m"
|
||||
GREEN="${ESC}[32m"
|
||||
YELLOW="${ESC}[33m"
|
||||
RED="${ESC}[31m"
|
||||
fi
|
||||
}
|
||||
|
||||
# The Otto + "omnigent" wordmark lockup, printed once at the top of an
|
||||
# interactive install. Mirrors omnigent.inner.wordmark.lockup_lines(); the
|
||||
# whole lockup is painted in the brand magenta (flat — no gradient in sh).
|
||||
# Skipped off a TTY (use_terminal_ui) so piped/CI installs stay clean.
|
||||
print_banner() {
|
||||
use_terminal_ui || return 0
|
||||
printf '\n'
|
||||
printf '%s ⠀⠀⠀⢠⣿⡄⠀⠀⠀ ██████╗ ███╗ ███╗███╗ ██╗██╗ ██████╗ ███████╗███╗ ██╗████████╗%s\n' "$MAGENTA" "$RESET"
|
||||
printf '%s ⢴⣶⣶⠉⣿⠉⣶⣶⡦ ██╔═══██╗████╗ ████║████╗ ██║██║██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝%s\n' "$MAGENTA" "$RESET"
|
||||
printf '%s ⠀⠙⣿⣶⣿⣶⣿⠋⠀ ██║ ██║██╔████╔██║██╔██╗ ██║██║██║ ███╗█████╗ ██╔██╗ ██║ ██║%s\n' "$MAGENTA" "$RESET"
|
||||
printf '%s ⠀⢠⣿⡿⠿⢿⣿⡄⠀ ╚██████╔╝██║ ╚═╝ ██║██║ ╚████║██║╚██████╔╝███████╗██║ ╚████║ ██║%s\n' "$MAGENTA" "$RESET"
|
||||
printf '%s ⠀⠈⠁⠀⠀⠀⠈⠁⠀ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝%s\n' "$MAGENTA" "$RESET"
|
||||
printf '%s all your agents, one cli%s\n\n' "$DIM" "$RESET"
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf 'Usage: install_oss.sh [--non-interactive] [--verbose] [--version X] [--repo URL] [--extra NAME]\n'
|
||||
}
|
||||
|
||||
step() {
|
||||
printf '%s==>%s %s\n' "$CYAN" "$RESET" "$1"
|
||||
printf '%s==>%s %s\n' "$MAGENTA" "$RESET" "$1"
|
||||
}
|
||||
|
||||
verbose() {
|
||||
@@ -124,7 +141,7 @@ run_with_spinner() {
|
||||
frame=0
|
||||
while [ ! -f "$status_file" ]; do
|
||||
spinner="$(spinner_frame "$frame")"
|
||||
printf '\r\033[K%s%s%s %s%s%s' "$CYAN" "$spinner" "$RESET" "$BOLD" "$label" "$RESET"
|
||||
printf '\r\033[K%s%s%s %s%s%s' "$MAGENTA" "$spinner" "$RESET" "$BOLD" "$label" "$RESET"
|
||||
frame=$((frame + 1))
|
||||
sleep 0.1
|
||||
done
|
||||
@@ -593,7 +610,7 @@ print_next_steps() {
|
||||
|
||||
printf '\n%sOmnigent installed successfully.%s\n\n' "$BOLD" "$RESET"
|
||||
printf 'Start chatting — first run sets up a model and a local web UI:\n'
|
||||
printf ' %s%somnigent%s\n\n' "$command_prefix" "$CYAN" "$RESET"
|
||||
printf ' %s%somnigent%s\n\n' "$command_prefix" "$MAGENTA" "$RESET"
|
||||
printf 'Or launch a specific coding harness:\n'
|
||||
printf ' %somnigent claude # Claude Code\n' "$command_prefix"
|
||||
printf ' %somnigent codex # Codex\n\n' "$command_prefix"
|
||||
@@ -607,6 +624,7 @@ print_next_steps() {
|
||||
main() {
|
||||
init_style
|
||||
parse_args "$@"
|
||||
print_banner
|
||||
normalize_repo_url
|
||||
check_platform
|
||||
check_prerequisites
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for the shared CLI styling layer (``omnigent.inner.ui``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent.inner import ui
|
||||
|
||||
|
||||
def test_accent_is_brand_magenta() -> None:
|
||||
"""The shared accent is the Omnigent brand magenta."""
|
||||
|
||||
assert ui.ACCENT == "#F43BA6"
|
||||
|
||||
|
||||
def test_show_banner_requires_a_tty() -> None:
|
||||
"""The banner is decoration — never drawn off a TTY."""
|
||||
|
||||
assert ui.show_banner(isatty=False, env={}) is False
|
||||
assert ui.show_banner(isatty=True, env={}) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "YES", "on"])
|
||||
def test_show_banner_respects_no_banner_env(value: str) -> None:
|
||||
"""``OMNIGENT_NO_BANNER`` force-disables the banner even on a TTY."""
|
||||
|
||||
assert ui.show_banner(isatty=True, env={ui.NO_BANNER_ENV_VAR: value}) is False
|
||||
|
||||
|
||||
def test_warnings_and_errors_go_to_stderr(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Diagnostics print to stderr so piped stdout stays clean."""
|
||||
|
||||
ui.warn("tmux not found")
|
||||
ui.error("uv is required")
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert "tmux not found" in captured.err
|
||||
assert "uv is required" in captured.err
|
||||
|
||||
|
||||
def test_status_lines_go_to_stdout(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Normal status (step/success/info) prints to stdout."""
|
||||
|
||||
ui.step("Installing Omnigent")
|
||||
ui.success("Verified omnigent")
|
||||
ui.info("Using ~/.omnigent")
|
||||
captured = capsys.readouterr()
|
||||
assert "Installing Omnigent" in captured.out
|
||||
assert "✓ Verified omnigent" in captured.out
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
def test_status_lines_are_plain_off_tty(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Off a TTY (captured streams) output carries no ANSI escapes."""
|
||||
|
||||
ui.success("done")
|
||||
ui.error("nope")
|
||||
captured = capsys.readouterr()
|
||||
assert "\x1b[" not in captured.out
|
||||
assert "\x1b[" not in captured.err
|
||||
|
||||
|
||||
def test_message_with_brackets_is_not_treated_as_markup(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A message containing ``[...]`` is emitted verbatim, not as markup."""
|
||||
|
||||
ui.success("installed [databricks] extra")
|
||||
captured = capsys.readouterr()
|
||||
assert "[databricks]" in captured.out
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for the Omnigent brand wordmark and Otto lockup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Console
|
||||
|
||||
from omnigent.inner import wordmark
|
||||
from omnigent.inner.mascots import MASCOT_ART_COLOR, MASCOT_ART_LINES
|
||||
|
||||
|
||||
def test_wordmark_is_five_rows_of_equal_display_width() -> None:
|
||||
"""The wordmark renders as five columns-aligned rows (Otto's height)."""
|
||||
|
||||
assert len(wordmark.WORDMARK_LINES) == 5
|
||||
widths = {cell_len(line) for line in wordmark.WORDMARK_LINES}
|
||||
assert len(widths) == 1, f"wordmark rows misaligned: {widths}"
|
||||
|
||||
|
||||
def test_wordmark_uses_brand_color() -> None:
|
||||
"""The wordmark accent stays in sync with the mascot brand color."""
|
||||
|
||||
assert wordmark.WORDMARK_COLOR == MASCOT_ART_COLOR == "#F43BA6"
|
||||
|
||||
|
||||
def test_every_letter_in_omnigent_has_a_glyph() -> None:
|
||||
"""The glyph map covers every letter rendered, and only symbols."""
|
||||
|
||||
for char in "omnigent":
|
||||
assert char in wordmark._GLYPHS
|
||||
# The art is symbol-only — no letters or digits leak into the rows.
|
||||
assert all(not any(c.isalnum() for c in line) for line in wordmark.WORDMARK_LINES)
|
||||
|
||||
|
||||
def test_lockup_lines_pair_otto_with_wordmark() -> None:
|
||||
"""The lockup is Otto (5 rows) with the 5-row wordmark aligned 1:1."""
|
||||
|
||||
lines = wordmark.lockup_lines()
|
||||
assert len(lines) == len(MASCOT_ART_LINES) == 5
|
||||
# Every row pairs Otto with a wordmark row; the cap and body rows carry
|
||||
# block glyphs (the final row is the all-line-art drop shadow).
|
||||
assert "█" in lines[0]
|
||||
assert "█" in lines[2]
|
||||
# Plain text form carries no ANSI escapes.
|
||||
assert all("\x1b[" not in line for line in lines)
|
||||
|
||||
|
||||
def test_render_lockup_plain_console_has_no_ansi() -> None:
|
||||
"""A no-color console renders the art in monochrome (no escapes)."""
|
||||
|
||||
console = Console(no_color=True, width=120, file=_StringFile())
|
||||
wordmark.render_lockup(console)
|
||||
assert "\x1b[" not in console.file.getvalue() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_render_lockup_color_console_emits_ansi() -> None:
|
||||
"""A color terminal renders the lockup with ANSI color codes."""
|
||||
|
||||
console = Console(force_terminal=True, width=120, file=_StringFile())
|
||||
wordmark.render_lockup(console, gradient=True)
|
||||
assert "\x1b[" in console.file.getvalue() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_render_compact_includes_name() -> None:
|
||||
"""The compact brandmark prints the product name and any subtitle."""
|
||||
|
||||
console = Console(no_color=True, width=120, file=_StringFile())
|
||||
wordmark.render_compact(console, subtitle="0.4.2")
|
||||
out = console.file.getvalue() # type: ignore[attr-defined]
|
||||
assert "omnigent" in out
|
||||
assert "0.4.2" in out
|
||||
assert "✦" in out
|
||||
|
||||
|
||||
class _StringFile:
|
||||
"""Minimal in-memory text file for capturing rich Console output."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buf: list[str] = []
|
||||
|
||||
def write(self, text: str) -> int:
|
||||
self._buf.append(text)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None: # pragma: no cover - rich calls this
|
||||
pass
|
||||
|
||||
def getvalue(self) -> str:
|
||||
return "".join(self._buf)
|
||||
Reference in New Issue
Block a user