173 lines
6.5 KiB
Python
173 lines
6.5 KiB
Python
"""Startup banner builder for the Omnigent REPL and onboarding wizard.
|
|
|
|
Migrated from the legacy ``omnigent.inner.cli`` module during the
|
|
``--no-sessions-api`` sunset so that the Omnigent REPL and onboarding wizard can
|
|
continue rendering the mascot-art welcome box without pulling in
|
|
the legacy CLI.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from .mascots import (
|
|
MASCOT_ART_COL_WIDTH,
|
|
MASCOT_ART_COLOR,
|
|
random_mascot_lines,
|
|
)
|
|
|
|
_DIM = "\033[2m"
|
|
_BOLD = "\033[1m"
|
|
_RESET = "\033[0m"
|
|
_PROMPT_GRAY = "\033[38;5;244m"
|
|
|
|
_CLI_WELCOME_HINT_LINE = "ctrl-c: cancel · ctrl-d: quit"
|
|
|
|
|
|
def _ansi_truecolor_fg(hex_rgb: str) -> str:
|
|
"""``#RRGGBB`` → ANSI 24-bit foreground; invalid input falls back to gray."""
|
|
if len(hex_rgb) != 7 or hex_rgb[0] != "#":
|
|
return _PROMPT_GRAY
|
|
try:
|
|
r = int(hex_rgb[1:3], 16)
|
|
g = int(hex_rgb[3:5], 16)
|
|
b = int(hex_rgb[5:7], 16)
|
|
except ValueError:
|
|
return _PROMPT_GRAY
|
|
return f"\033[38;2;{r};{g};{b}m"
|
|
|
|
|
|
def _display_width(text: str) -> int:
|
|
"""Terminal display width of *text*.
|
|
|
|
:func:`rich.cells.cell_len` (rich >= 14, which omnigent requires) counts
|
|
an emoji forced to its wide presentation by a trailing VARIATION
|
|
SELECTOR-16 (U+FE0F) — e.g. the cli-config ``⚙️`` glyph — as the two
|
|
cells modern terminals render, so it already aligns the banner box.
|
|
(rich < 14 under-counted such glyphs as one cell and needed a ``+1 per
|
|
VS16`` correction; under rich 14 that correction became a double-count.)
|
|
|
|
:param text: The string to measure, e.g. ``"⚙️ my-gateway"``.
|
|
:returns: The estimated terminal column width, e.g. ``13``.
|
|
"""
|
|
from rich.cells import cell_len
|
|
|
|
return cell_len(text)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RenderedLines:
|
|
"""Paired plain / ANSI rendering of a block of REPL output."""
|
|
|
|
plain: str
|
|
ansi: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BannerLine:
|
|
"""One text row inside the startup banner box.
|
|
|
|
:param text: The row's text. Width is measured with
|
|
:func:`rich.cells.cell_len`, so emoji / wide glyphs (e.g. a
|
|
``🧱 Databricks`` credential label) pad and align correctly.
|
|
:param dim: Render the row dim (``True``, for metadata rows like the
|
|
model + credential, folder, or hint) or bold (``False``, used
|
|
for the title row). Only the title row passes ``False``.
|
|
"""
|
|
|
|
text: str
|
|
dim: bool
|
|
|
|
|
|
def startup_banner_strings(
|
|
agent_label: str,
|
|
*,
|
|
hint_line: str | None = None,
|
|
info_lines: list[BannerLine] | None = None,
|
|
mascot_lines: list[str] | tuple[str, ...] | None = None,
|
|
art_color: str = MASCOT_ART_COLOR,
|
|
) -> RenderedLines:
|
|
"""
|
|
Build the startup banner box as both plain and ANSI strings.
|
|
|
|
The box shows the mascot art to the left and one bold title row
|
|
(*agent_label*) plus any number of dim info rows to the right —
|
|
so the REPL can surface the working folder, model, credential, and
|
|
a one-line agent summary inside the same box (Claude-Code-style
|
|
header). The box always renders at least as many rows as the mascot
|
|
has, padding with blank rows so the full mascot shows even when
|
|
there is only a title.
|
|
|
|
:param agent_label: Bold title row, e.g. the agent name ``"polly"``.
|
|
:param hint_line: Dimmed hint shown as the single info row when
|
|
*info_lines* is not given. ``None`` uses the default hint;
|
|
``""`` omits the hint row (back-compat with the bare-banner
|
|
callers). Ignored when *info_lines* is provided.
|
|
:param info_lines: Explicit dim info rows shown beneath the title,
|
|
e.g. ``[BannerLine("multi-agent orchestrator", dim=True),
|
|
BannerLine("claude-sonnet-4-6 · Subscription", dim=True),
|
|
BannerLine("~/omnigent", dim=True)]``. ``None`` falls
|
|
back to the *hint_line* behavior.
|
|
:param mascot_lines: Mascot art to show to the left of the
|
|
text. ``None`` picks a random mascot.
|
|
:param art_color: Hex color for the mascot + box border, e.g.
|
|
``"#F43BA6"``.
|
|
:returns: The box as paired plain / ANSI strings.
|
|
"""
|
|
mascot = list(mascot_lines or random_mascot_lines())
|
|
left_pad = " "
|
|
art_w = MASCOT_ART_COL_WIDTH
|
|
gap = 2
|
|
|
|
# Row 0 is the bold title; the rest are dim info rows. When no explicit
|
|
# info rows are given, fall back to the single (optional) hint row.
|
|
rows: list[BannerLine] = [BannerLine(agent_label, dim=False)]
|
|
if info_lines is not None:
|
|
rows.extend(info_lines)
|
|
elif hint_line != "":
|
|
rows.append(BannerLine(hint_line or _CLI_WELCOME_HINT_LINE, dim=True))
|
|
# Pad with blank rows so every mascot row has a content row to sit on —
|
|
# otherwise a single-row banner would clip the mascot's lower half.
|
|
while len(rows) < len(mascot):
|
|
rows.append(BannerLine("", dim=True))
|
|
|
|
text_w = max(_display_width(r.text) for r in rows)
|
|
|
|
def _art_cell(i: int) -> str:
|
|
"""Return the mascot art for row *i*, padded/truncated to art_w cells."""
|
|
line = mascot[i] if i < len(mascot) else ""
|
|
pad = art_w - _display_width(line)
|
|
if pad >= 0:
|
|
return line + " " * pad
|
|
return line[:art_w]
|
|
|
|
def _content_line(i: int, row: BannerLine) -> RenderedLines:
|
|
"""Render one box row (mascot cell + gap + text), plain + ANSI."""
|
|
pad_right = text_w - _display_width(row.text)
|
|
art = _art_cell(i)
|
|
content = f"{left_pad}{art}{' ' * gap}{row.text}{' ' * pad_right}"
|
|
plain = f"│ {content} │"
|
|
text_prefix = _DIM if row.dim else _BOLD
|
|
ansi_content = (
|
|
f"{_ansi_truecolor_fg(art_color)}{left_pad}{art}{_RESET}"
|
|
f"{' ' * gap}"
|
|
f"{text_prefix}{row.text}{_RESET}"
|
|
f"{' ' * pad_right}"
|
|
)
|
|
ansi = (
|
|
f"{_ansi_truecolor_fg(art_color)}│{_RESET} "
|
|
f"{ansi_content}"
|
|
f" {_ansi_truecolor_fg(art_color)}│{_RESET}"
|
|
)
|
|
return RenderedLines(plain=plain, ansi=ansi)
|
|
|
|
content_width = len(left_pad) + art_w + gap + text_w
|
|
top = "╭" + ("─" * (content_width + 2)) + "╮"
|
|
bottom = "╰" + ("─" * (content_width + 2)) + "╯"
|
|
top_ansi = f"{_ansi_truecolor_fg(art_color)}{top}{_RESET}"
|
|
bottom_ansi = f"{_ansi_truecolor_fg(art_color)}{bottom}{_RESET}"
|
|
body = [_content_line(i, row) for i, row in enumerate(rows)]
|
|
plain_rows = [top, *(b.plain for b in body), bottom]
|
|
ansi_rows = [top_ansi, *(b.ansi for b in body), bottom_ansi]
|
|
return RenderedLines(plain="\n".join(plain_rows), ansi="\n".join(ansi_rows))
|