chore: import upstream snapshot with attribution
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:45 +08:00
commit 4b6817381b
3933 changed files with 525247 additions and 0 deletions
@@ -0,0 +1,42 @@
"""Reusable terminal UI primitives (menus, TTY rendering, formatting)."""
from surfaces.interactive_shell.ui.components.choice_menu import (
print_valid_choice_list,
repl_choose_one,
repl_section_break,
repl_tty_interactive,
)
from surfaces.interactive_shell.ui.components.loaders import DEFAULT_LOADER_LABEL, llm_loader
from surfaces.interactive_shell.ui.components.rendering import (
print_repl_json,
print_repl_table,
refresh_welcome_poster,
repl_print,
repl_table,
)
from surfaces.interactive_shell.ui.components.time_format import (
format_repl_duration,
format_repl_timestamp,
)
from surfaces.interactive_shell.ui.components.token_format import (
_CHARS_PER_TOKEN,
format_token_count_short,
)
__all__ = [
"DEFAULT_LOADER_LABEL",
"_CHARS_PER_TOKEN",
"format_repl_duration",
"format_repl_timestamp",
"format_token_count_short",
"llm_loader",
"print_repl_json",
"print_repl_table",
"print_valid_choice_list",
"refresh_welcome_poster",
"repl_choose_one",
"repl_print",
"repl_section_break",
"repl_table",
"repl_tty_interactive",
]
@@ -0,0 +1,74 @@
"""ASCII splash art and art-selection logic for the startup screen.
Exported
--------
SPLASH_ART block font, 59 cols, solid ██ fills
SPLASH_ART_NARROW simpleBlock font, 72 cols, pure ASCII fallback
_FALLBACK_ART minimal art, 44 cols, last resort
_render_art(width) return the best-fit art string for a given terminal width
"""
from __future__ import annotations
import os
from platform.observability.render.figlet import render_figlet
# Pre-rendered during development and checked into this module as a static string.
# Colour codes are stripped; HIGHLIGHT is re-applied at render time.
SPLASH_ART = """\
██████╗ ██████╗ ███████╗███╗ ██╗███████╗██████╗ ███████╗
██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██╔══██╗██╔════╝
██║ ██║██████╔╝█████╗ ██╔██╗ ██║███████╗██████╔╝█████╗
██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║╚════██║██╔══██╗██╔══╝
╚██████╔╝██║ ███████╗██║ ╚████║███████║██║ ██║███████╗
╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝╚══════╝"""
SPLASH_ART_NARROW = """\
_|_| _|_|_| _|_|_|_| _| _| _|_|_| _|_|_| _|_|_|_|
_| _| _| _| _| _|_| _| _| _| _| _|
_| _| _|_|_| _|_|_| _| _| _| _|_| _|_|_| _|_|_|
_| _| _| _| _| _|_| _| _| _| _|
_|_| _| _|_|_|_| _| _| _|_|_| _| _| _|_|_|_|"""
_FALLBACK_ART = """\
___ ____ ____ _____
/ _ \\ _ __ ___ _ __ / ___|| _ \\| ____|
| | | | '_ \\ / _ \\ '_ \\ \\___ \\| |_) | _|
| |_| | |_) | __/ | | | ___) | _ <| |___
\\___/| .__/ \\___|_| |_||____/|_| \\_\\_____|
|_|"""
def _render_art(console_width: int = 80) -> str:
"""Return the splash art string for the given terminal width.
Priority: SPLASH_ART (grid, 34 cols) → SPLASH_ART_NARROW (simpleBlock, 72 cols)
→ _FALLBACK_ART (minimal, 44 cols). OPENSRE_FIGLET_FONT overrides the default
when pyfiglet is installed.
"""
custom_font = os.getenv("OPENSRE_FIGLET_FONT")
if custom_font:
rendered = render_figlet("OpenSRE", font=custom_font, max_line_width=console_width - 2)
if rendered:
return rendered
art_width = max(len(ln) for ln in SPLASH_ART.splitlines())
narrow_width = max(len(ln) for ln in SPLASH_ART_NARROW.splitlines())
fallback_width = max(len(ln) for ln in _FALLBACK_ART.splitlines())
if console_width >= art_width + 4:
return SPLASH_ART
if console_width >= narrow_width + 4:
return SPLASH_ART_NARROW
if console_width >= fallback_width + 4:
return _FALLBACK_ART
return _FALLBACK_ART
__all__ = [
"SPLASH_ART",
"SPLASH_ART_NARROW",
"_FALLBACK_ART",
"_render_art",
]
@@ -0,0 +1,285 @@
"""Interactive choice helpers for TTY-first REPL flows.
Inline menus render in the terminal scrollback (below the submitted command),
not as a separate prompt-toolkit full-screen dialog — important when the REPL
already runs under asyncio.
Each menu erases itself on exit (selection or Esc) so nested menus never
pile up — only the result output and the next level appear on screen.
"""
from __future__ import annotations
import os
import shutil
import sys
from typing import Literal
from rich.console import Console
from rich.markup import escape
import platform.terminal.theme as ui_theme
from surfaces.interactive_shell.ui.components.key_reader import read_key_unix, read_key_windows
_HINT = "↑↓/j/k/Tab Enter/Space Esc/q"
CRUMB_SEP = " "
# Blank line after the submitted slash line before the menu header (all pickers).
_MENU_LEADING_LINES = 1
_TERMINAL_NEWLINE = "\r\n"
MenuAction = Literal["up", "down", "enter", "cancel", "eof", "ignore"]
def repl_tty_interactive() -> bool:
"""Return True when stdin/stdout support an interactive picker UI."""
return bool(sys.stdin.isatty() and sys.stdout.isatty())
def ensure_tty_column_zero() -> None:
"""Reset the cursor column before Rich output when a TTY is active."""
if repl_tty_interactive():
reset_tty_column()
def prepare_repl_output_line() -> None:
"""Begin Rich output on a new line after inline menu I/O."""
if repl_tty_interactive():
sys.stdout.write(_TERMINAL_NEWLINE)
reset_tty_column()
def repl_section_break(console: Console) -> None:
"""Blank line + dim rule between an inline menu step and Rich output."""
prepare_repl_output_line()
console.print()
console.rule(characters="", style=str(ui_theme.DIM))
console.print()
# ── raw key reader ───────────────────────────────────────────────────────────
def _read_action() -> MenuAction:
"""Map a raw keypress to a menu action.
Delegates terminal I/O to :mod:`key_reader` and applies
choice_menu-specific overrides: Tab → ``"down"``,
right-arrow → ``"enter"``, left-arrow → ``"ignore"``.
"""
key = read_key_windows() if os.name == "nt" else read_key_unix()
if key == "tab":
return "down"
if key == "right":
return "enter"
if key == "left":
return "ignore"
return key # type: ignore[return-value]
def read_menu_action() -> MenuAction:
"""Read one normalized inline-menu action from stdin."""
return _read_action()
# ── rendering helpers ────────────────────────────────────────────────────────
def _cols() -> int:
return max(40, shutil.get_terminal_size(fallback=(80, 24)).columns)
def menu_columns() -> int:
"""Return the current terminal width floor used by inline menus."""
return _cols()
def _rule(width: int) -> str:
return "" * width
def _pad(sym: str, label: str, width: int) -> str:
content = f" {sym} {label}"
pad = width - len(content)
return content + (" " * pad if pad > 0 else "")
def _menu_height(crumb: str, labels: list[str]) -> int:
# leading, title, [crumb], rule, blank, choices, blank, hint
return _MENU_LEADING_LINES + 1 + (1 if crumb else 0) + 1 + 1 + len(labels) + 1 + 1
def write_menu_line(text: str = "") -> None:
"""Write one inline-menu line at column zero even while the terminal is in raw mode."""
if text:
sys.stdout.write(f"\r{text}{_TERMINAL_NEWLINE}")
return
sys.stdout.write(_TERMINAL_NEWLINE)
def _erase_menu_block(height: int) -> None:
if height:
sys.stdout.write(f"\r\x1b[{height}A\r\x1b[J")
reset_tty_column()
def reset_tty_column() -> None:
"""Return the cursor to column zero after inline menu I/O.
Menu rows are padded to the terminal width, so the cursor often ends on a
high column. Rich output that follows must start at column zero or tables
render as a diagonal block of leading whitespace.
"""
sys.stdout.write("\r")
sys.stdout.flush()
def erase_menu_lines(height: int) -> None:
"""Erase a previously-rendered inline menu block."""
_erase_menu_block(height)
def _draw_menu(
*,
title: str,
crumb: str,
labels: list[str],
index: int,
erase_lines: int,
) -> None:
out = sys.stdout
w = _cols()
if erase_lines:
_erase_menu_block(erase_lines)
for _ in range(_MENU_LEADING_LINES):
write_menu_line()
# title
write_menu_line(f"{ui_theme.PROMPT_ACCENT_ANSI}{title}{ui_theme.ANSI_RESET}")
# breadcrumb path
if crumb:
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{crumb}{ui_theme.ANSI_RESET}")
# separator below header
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_rule(w)}{ui_theme.ANSI_RESET}")
write_menu_line()
# choices
for i, label in enumerate(labels):
here = i == index
sym = ">" if here else " "
padded = _pad(sym, label, w)
if here:
write_menu_line(f"{ui_theme.MENU_SELECTION_ROW_ANSI}{padded}{ui_theme.ANSI_RESET}")
else:
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{padded}{ui_theme.ANSI_RESET}")
write_menu_line()
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_HINT}{ui_theme.ANSI_RESET}")
out.flush()
def _erase_menu(crumb: str, labels: list[str]) -> None:
"""Move cursor up to the start of this menu block and wipe it."""
height = _menu_height(crumb, labels)
_erase_menu_block(height)
sys.stdout.flush()
# ── picker loop ──────────────────────────────────────────────────────────────
def _pick(
*,
title: str,
crumb: str,
labels: list[str],
initial_index: int = 0,
) -> int | None:
"""Draw an inline menu, let user navigate, erase on exit. Returns index or None."""
if not labels:
return None
idx = initial_index % len(labels)
height = _menu_height(crumb, labels)
first = True
while True:
_draw_menu(
title=title,
crumb=crumb,
labels=labels,
index=idx,
erase_lines=0 if first else height,
)
first = False
action = _read_action()
if action == "enter":
_erase_menu(crumb, labels)
return idx
if action in ("cancel", "eof"):
_erase_menu(crumb, labels)
return None
if action == "ignore":
continue
if action == "up":
idx = (idx - 1) % len(labels)
elif action == "down":
idx = (idx + 1) % len(labels)
# ── public API ───────────────────────────────────────────────────────────────
def repl_choose_one(
*,
title: str,
choices: list[tuple[str, str]],
breadcrumb: str = "",
initial_value: str | None = None,
) -> str | None:
"""Show an inline erasing arrow-key menu; return selected value or None on Esc.
``breadcrumb`` is a slash-separated path shown dimly below the title, e.g.
``/model set``. Only call when :func:`repl_tty_interactive` is True.
"""
from surfaces.interactive_shell.ui.components.cpr_stdin import drain_stale_cpr_bytes
if not choices or not repl_tty_interactive():
return None
drain_stale_cpr_bytes()
crumb = breadcrumb
labels = [label for _value, label in choices]
initial_index = 0
if initial_value is not None:
for index, (value, _label) in enumerate(choices):
if value == initial_value:
initial_index = index
break
picked = _pick(title=title, crumb=crumb, labels=labels, initial_index=initial_index)
if picked is None:
return None
value = choices[picked][0]
return value if isinstance(value, str) else None
def print_valid_choice_list(
console: Console,
*,
title: str,
choices: list[str],
) -> None:
"""Print one choice per line for scan-friendly fallback/error messaging."""
if not choices:
return
console.print(f"[{ui_theme.SECONDARY}]{title}[/]")
for choice in choices:
console.print(f"[{ui_theme.SECONDARY}] - {escape(choice)}[/]")
__all__ = [
"CRUMB_SEP",
"erase_menu_lines",
"menu_columns",
"print_valid_choice_list",
"read_menu_action",
"repl_choose_one",
"ensure_tty_column_zero",
"prepare_repl_output_line",
"repl_section_break",
"repl_tty_interactive",
"reset_tty_column",
"write_menu_line",
]
@@ -0,0 +1,70 @@
"""CPR (cursor position report) stdin hygiene for the interactive REPL loop."""
from __future__ import annotations
import os
import re
import select
import sys
# A leaked cursor-position reply is ``ESC[row;colR`` (8-bit CSI ``\x9b`` too); when it
# leaks into the input stream the ESC and/or ``[`` introducer can be lost. The
# introducer-less branches below are constrained so they only fire on genuine CPR
# context. Without that constraint they can silently strip legitimate input such as
# ``5R3``, ``12;34R okay`` or ``12;34R5 nodes``.
_CPR_SEQUENCE_RE = re.compile(
r"(?:\x1b\[|\x9b)\d{1,4};\d{1,4}R" # ESC [ row ; col R (introducer present)
r"|\[\d{1,4};\d{1,4}R" # [row;colR without ESC (leaked into input)
r"|\d{1,4};\d{1,4}R(?=[\[\x1b\x9b]|\d{1,4};\d{1,4}R)" # bare row;colR before another fragment
r"|\d{1,4}R(?=\[|\x1b|\x9b|\d{1,4};\d{1,4}R)" # bare rowR before another fragment
r"|\d{1,4};\d{1,4}R$" # bare row;colR alone at end of the line
)
_CPR_ESCAPED_SEQUENCE_RE = re.compile(r"(?:\x1b\[|\x9b)\d{1,4};\d{1,4}R")
def drain_stale_cpr_bytes() -> None:
"""Discard CPR escape-sequence bytes left in stdin after prompt teardown.
When ``prompt_async`` returns, prompt_toolkit tears down its input-reader
thread. CPR responses (``ESC[row;colR``) that the bottom-toolbar refresh
sent but that arrived just after the reader stopped sit in the OS stdin
buffer and appear as literal keystrokes in the next prompt. This function
non-blockingly drains stdin between ``prompt_async`` calls on POSIX TTYs.
"""
if os.name == "nt" or not sys.stdin.isatty():
return
try:
fd = sys.stdin.fileno()
while select.select([fd], [], [], 0)[0]:
chunk = os.read(fd, 256)
if not chunk:
break
except OSError:
# Draining stdin is best-effort; ignore when the fd is not readable.
pass
def strip_cpr_sequences(text: str | None) -> str:
"""Remove terminal cursor-position replies that leaked into submitted text."""
if not text:
return ""
return _CPR_SEQUENCE_RE.sub("", text)
def strip_cpr_escape_sequences(text: str | None) -> str:
"""Remove only canonical escaped CPR sequences from text."""
if not text:
return ""
return _CPR_ESCAPED_SEQUENCE_RE.sub("", text)
def contains_cpr_sequence(text: str | None) -> bool:
return bool(text and _CPR_SEQUENCE_RE.search(text))
__all__ = [
"contains_cpr_sequence",
"drain_stale_cpr_bytes",
"strip_cpr_escape_sequences",
"strip_cpr_sequences",
]
@@ -0,0 +1,152 @@
"""Low-level terminal key reader for TTY-first interactive menus.
Shared between :mod:`choice_menu` (REPL inline picker) and
:mod:`feedback` (post-investigation rating prompt) so the raw-mode
terminal I/O lives in one place.
Return values from :func:`read_key_unix` / :func:`read_key_windows`:
``"up"``, ``"down"``, ``"enter"``, ``"cancel"``, ``"tab"``,
``"right"``, ``"left"``, ``"eof"``, ``"ignore"``.
"""
from __future__ import annotations
import contextlib
import os
import sys
def flush_stdin_unix() -> None:
"""Discard pending stdin bytes before raw-mode reading."""
with contextlib.suppress(Exception):
import termios
termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH) # type: ignore[attr-defined]
def restore_stdin_terminal() -> None:
"""Return stdin to canonical echo mode after Live/raw investigation UI.
Investigation progress uses a background Ctrl+O watcher that puts stdin in
non-canonical mode without echo. If nested watchers restore the wrong
snapshot, the shell prompt appears to accept input but characters are not
echoed. Call this after investigation UI teardown and before line prompts.
"""
if os.name == "nt" or not sys.stdin.isatty():
return
import termios
with contextlib.suppress(Exception):
fd = sys.stdin.fileno()
attrs = termios.tcgetattr(fd) # type: ignore[attr-defined]
# Restore cooked-mode flags a raw menu clears: ICRNL so Enter (CR) submits,
# OPOST for output newlines, ICANON/ECHO/ISIG for line editing and signals.
attrs[0] |= termios.BRKINT | termios.ICRNL | termios.IXON # type: ignore[attr-defined]
attrs[1] |= termios.OPOST # type: ignore[attr-defined]
attrs[3] |= termios.ICANON | termios.ECHO | termios.ISIG # type: ignore[attr-defined]
if hasattr(termios, "IEXTEN"):
attrs[3] |= termios.IEXTEN # type: ignore[attr-defined]
termios.tcsetattr(fd, termios.TCSADRAIN, attrs) # type: ignore[attr-defined]
termios.tcflush(fd, termios.TCIFLUSH) # type: ignore[attr-defined]
def read_key_unix(*, also_cancel: tuple[bytes, ...] = ()) -> str:
"""Read one logical keypress in raw mode; return a normalised key name.
Possible return values: ``"up"``, ``"down"``, ``"enter"``,
``"cancel"``, ``"tab"``, ``"right"``, ``"left"``, ``"eof"``,
``"ignore"``.
``also_cancel`` treats additional single-byte keys as ``"cancel"`` (e.g.
``(b"s", b"S")`` for an explicit skip shortcut).
"""
import select as _sel
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd) # type: ignore[attr-defined]
try:
tty.setraw(fd) # type: ignore[attr-defined]
ch = os.read(fd, 1)
if not ch:
return "eof"
b = ch[0]
if b in (3, 4) or ch in also_cancel: # Ctrl-C / Ctrl-D / caller shortcuts
return "cancel"
if b in (10, 13, 32): # LF / CR / Space
return "enter"
if b == 9: # Tab
return "tab"
if ch in (b"j", b"J"):
return "down"
if ch in (b"k", b"K"):
return "up"
if ch in (b"q", b"Q"):
return "cancel"
if b == 27: # ESC or arrow-key prefix
if _sel.select([fd], [], [], 0.1)[0]:
nxt = os.read(fd, 1)
if nxt == b"[" and _sel.select([fd], [], [], 0.1)[0]:
arr = os.read(fd, 1)
if arr == b"A":
return "up"
if arr == b"B":
return "down"
if arr == b"C":
return "right"
if arr == b"D":
return "left"
# Not an arrow key — drain the rest of the CSI sequence so
# bytes like "0;1R" from a CPR (ESC[row;colR) don't leak into
# the next read or the prompt buffer as literal characters.
# The VT/xterm spec defines 0x400x7E as valid CSI final bytes.
while arr and not (0x40 <= arr[0] <= 0x7E):
if not _sel.select([fd], [], [], 0)[0]:
break
arr = os.read(fd, 1)
return "cancel"
return "ignore"
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old) # type: ignore[attr-defined]
def read_key_windows(*, also_cancel: tuple[bytes, ...] = ()) -> str:
"""Read one logical keypress on Windows; return a normalised key name.
Possible return values: ``"up"``, ``"down"``, ``"enter"``,
``"cancel"``, ``"tab"``, ``"right"``, ``"left"``, ``"eof"``,
``"ignore"``.
``also_cancel`` treats additional single-byte keys as ``"cancel"``.
"""
import msvcrt # type: ignore[import,attr-defined]
ch = msvcrt.getch() # type: ignore[attr-defined]
if ch in (b"\x03", b"\x1b") or ch in also_cancel:
return "cancel"
if ch in (b"\r", b"\n", b" "):
return "enter"
if ch == b"\t":
return "tab"
if ch in (b"j", b"J"):
return "down"
if ch in (b"k", b"K"):
return "up"
if ch in (b"q", b"Q"):
return "cancel"
if ch in (b"\xe0", b"\x00"):
ch2 = msvcrt.getch() # type: ignore[attr-defined]
if ch2 == b"H":
return "up"
if ch2 == b"P":
return "down"
if ch2 == b"M":
return "right"
if ch2 == b"K":
return "left"
return "ignore"
return "ignore"
__all__ = ["flush_stdin_unix", "read_key_unix", "read_key_windows", "restore_stdin_terminal"]
@@ -0,0 +1,41 @@
"""Shared Rich loaders for interactive-shell LLM calls.
A quiet, dim spinner shows that an LLM call is in flight. Centralised so
every LLM-backed surface in the interactive shell (``cli_agent``,
``follow_up``) shares the same look.
"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from rich.console import Console
from platform.terminal.theme import SECONDARY
# Quiet, secondary-colour spinner — less visual noise than a bright accent.
_LOADER_COLOR = SECONDARY
_LOADER_SPINNER = "dots"
DEFAULT_LOADER_LABEL = "thinking"
@contextmanager
def llm_loader(console: Console, label: str = DEFAULT_LOADER_LABEL) -> Iterator[None]:
"""Show a dim spinner while an LLM call is in flight.
On non-terminal consoles (CI, captured output, piped stdout), the spinner is
skipped so captured logs stay clean — the wrapped call still runs unchanged.
"""
if not console.is_terminal:
yield
return
console.print()
text = f"[{_LOADER_COLOR}]{label}…[/{_LOADER_COLOR}]"
with console.status(text, spinner=_LOADER_SPINNER, spinner_style=_LOADER_COLOR):
yield
__all__ = ["DEFAULT_LOADER_LABEL", "llm_loader"]
@@ -0,0 +1,256 @@
"""REPL TTY plumbing: buffered print helpers and table factory.
Keeps cursor at column zero and normalises line endings under prompt_toolkit's
patch_stdout so Rich tables and JSON don't render as diagonal blocks.
Domain-specific table renderers live in :mod:`tables`.
"""
from __future__ import annotations
import io
import shutil
import sys
from collections.abc import Callable
from contextvars import ContextVar
from typing import Any
from rich import box
from rich.console import Console
from rich.markup import escape
from rich.table import Table
import platform.terminal.theme as ui_theme
_REPL_OUTPUT_PREPARED = ContextVar("_REPL_OUTPUT_PREPARED", default=False)
def _repl_output_already_prepared() -> bool:
"""Whether current call stack already prepared the TTY for Rich output."""
return _REPL_OUTPUT_PREPARED.get()
def _console_print_prepared(console: Console, *objects: Any, **kwargs: Any) -> None:
token = _REPL_OUTPUT_PREPARED.set(True)
try:
console.print(*objects, **kwargs)
finally:
_REPL_OUTPUT_PREPARED.reset(token)
def _repl_table_width(console: Console) -> int:
"""Best-effort terminal width for Rich tables after inline menu I/O."""
term_cols = shutil.get_terminal_size(fallback=(80, 24)).columns
# Keep one safety column to avoid right-edge auto-wrap artifacts in some
# terminals (first-char clipping / duplicate right border when a row lands
# exactly on the terminal width).
return max(40, min(console.width, term_cols) - 1)
def _prepare_tty_for_rich(console: Console) -> int:
"""Return the width Rich should render at.
prepare_repl_output_line() (which writes \\r\\n) is intentionally NOT called
here. Under patch_stdout(raw=True), that extra newline causes the bottom
toolbar text to flush into the output stream before the table renders. Slash
commands start after the user presses Enter, so the cursor is already on a
fresh line; no extra line-feed is needed.
"""
return _repl_table_width(console)
def _normalize_repl_line_endings(text: str) -> str:
"""Convert Rich output to ``\\r\\n`` so each line starts at column zero."""
return text.replace("\r\n", "\n").replace("\n", "\r\n")
def _write_repl_tty_buffered(
*,
width: int,
leading_blank: bool,
render_to_buffer: Callable[[Console], None],
) -> None:
"""Render Rich output to a buffer and write it in one TTY-safe stdout call."""
buf = io.StringIO()
buf_console = Console(
file=buf,
force_terminal=True,
highlight=False,
width=width,
)
render_to_buffer(buf_console)
rendered = _normalize_repl_line_endings(buf.getvalue())
if leading_blank:
rendered = "\r\n" + rendered
token = _REPL_OUTPUT_PREPARED.set(True)
try:
sys.stdout.write(rendered)
sys.stdout.flush()
finally:
_REPL_OUTPUT_PREPARED.reset(token)
def print_repl_table(console: Console, table: Table, *, width: int | None = None) -> None:
"""Print a Rich table using REPL-safe TTY width.
When the console writes to sys.stdout (the real REPL path), tables are
rendered into a string buffer first and written in a single sys.stdout.write
call with explicit \\r\\n line endings. This prevents the diagonal-render
artifact that occurs under prompt_toolkit's patch_stdout: each table row is
a separate Rich write, and if the terminal or proxy does not convert \\n to
\\r\\n, every row starts where the previous one ended instead of column zero.
When the console writes to a non-TTY stdout (piped output) or to a
different file (e.g. a StringIO in tests), the normal console.print path
is used — preserving the caller's color_system and avoiding ANSI pollution
in piped output.
"""
leading_blank = width is None
width = width if width is not None else _prepare_tty_for_rich(console)
if console.file is sys.stdout and sys.stdout.isatty():
_write_repl_tty_buffered(
width=width,
leading_blank=leading_blank,
render_to_buffer=lambda buf_console: buf_console.print(table),
)
else:
if leading_blank:
_console_print_prepared(console)
_console_print_prepared(console, table, width=width)
def print_repl_json(console: Console, json_str: str) -> None:
"""Print JSON via Rich using REPL-safe \\r\\n line endings.
Mirrors the buffered-write approach in :func:`print_repl_table` to prevent
the diagonal-render artifact under prompt_toolkit's patch_stdout: bare
``\\n`` from Rich does not imply a carriage-return, so each JSON line would
start at the column where the previous one ended. Rendering to a buffer
and normalising to ``\\r\\n`` ensures every line begins at column zero.
The leading blank is included in the same write call to avoid a stale CPR
sequence being left in stdin by a prompt_toolkit toolbar flush.
"""
width = _prepare_tty_for_rich(console)
if console.file is sys.stdout and sys.stdout.isatty():
_write_repl_tty_buffered(
width=width,
leading_blank=True,
render_to_buffer=lambda buf_console: buf_console.print_json(json_str),
)
else:
token = _REPL_OUTPUT_PREPARED.set(True)
try:
console.print_json(json_str)
finally:
_REPL_OUTPUT_PREPARED.reset(token)
def repl_print(console: Console, *objects: Any, **kwargs: Any) -> None:
"""Print via Rich after resetting the TTY column (inline-menu safe)."""
from surfaces.interactive_shell.ui.components.choice_menu import prepare_repl_output_line
prepare_repl_output_line()
_console_print_prepared(console, *objects, **kwargs)
def _repl_write_buffer(rendered: str) -> None:
"""Flush pre-rendered Rich output with CRLF line endings (patch_stdout safe)."""
from surfaces.interactive_shell.ui.components.cpr_stdin import strip_cpr_escape_sequences
normalized = strip_cpr_escape_sequences(rendered.replace("\r\n", "\n").replace("\n", "\r\n"))
token = _REPL_OUTPUT_PREPARED.set(True)
try:
sys.stdout.write(normalized)
sys.stdout.flush()
finally:
_REPL_OUTPUT_PREPARED.reset(token)
def repl_clear_screen() -> None:
"""Clear the terminal scrollback when the REPL runs under patch_stdout."""
if not sys.stdout.isatty():
return
sys.stdout.write("\x1b[2J\x1b[H")
sys.stdout.flush()
def _theme_notice_line(theme_notice: str) -> str:
"""REPL-safe ``theme set: <name>`` using the active palette (not stale imports)."""
return (
f"{ui_theme.HIGHLIGHT_ANSI}theme set: {escape(theme_notice)}{ui_theme.ANSI_RESET}\r\n\r\n"
)
def repl_render_launch_poster(
console: Console,
*,
session: object = None,
theme_notice: str | None = None,
) -> None:
"""Render splash + welcome panel using REPL-safe CRLF writes."""
from surfaces.interactive_shell.ui import banner as banner_module
if console.file is sys.stdout and sys.stdout.isatty():
width = _prepare_tty_for_rich(console)
buf = io.StringIO()
buf_console = Console(
file=buf,
force_terminal=True,
highlight=False,
color_system="truecolor",
legacy_windows=False,
width=width,
)
banner_module.render_splash(buf_console, first_run=False)
banner_module.render_ready_box(buf_console, session=session)
prefix = _theme_notice_line(theme_notice) if theme_notice else ""
_repl_write_buffer(prefix + buf.getvalue())
return
if theme_notice:
_console_print_prepared(
console,
f"[{ui_theme.HIGHLIGHT}]theme set:[/] {escape(theme_notice)}",
)
banner_module.render_splash(console, first_run=False)
banner_module.render_ready_box(console, session=session)
def refresh_welcome_poster(
console: Console,
*,
session: object = None,
theme_notice: str | None = None,
) -> None:
"""Clear scrollback and redraw splash art + welcome panel with the active theme."""
from surfaces.interactive_shell.ui.components.cpr_stdin import drain_stale_cpr_bytes
repl_clear_screen()
# ``repl_clear_screen`` can trigger a toolbar DSR/CPR exchange; drain before writing.
drain_stale_cpr_bytes()
repl_render_launch_poster(console, session=session, theme_notice=theme_notice)
def repl_table(**kwargs: Any) -> Table:
"""Minimal outer borders — closer to Claude Code than full ASCII grids."""
opts: dict[str, Any] = {
"box": box.MINIMAL_HEAVY_HEAD,
"show_edge": False,
"pad_edge": False,
"title_justify": "left",
}
opts.update(kwargs)
return Table(**opts)
__all__ = [
"_repl_output_already_prepared",
"_repl_table_width",
"print_repl_json",
"print_repl_table",
"refresh_welcome_poster",
"repl_clear_screen",
"repl_print",
"repl_render_launch_poster",
"repl_table",
]
@@ -0,0 +1,68 @@
"""Shared timestamp and duration formatting for REPL tables and menus."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Literal
def _fmt_timing(elapsed_ms: int) -> str:
"""Format a millisecond count as a short elapsed string: ``42ms`` or ``1.3s``."""
return f"{elapsed_ms / 1000:.1f}s" if elapsed_ms >= 1000 else f"{elapsed_ms}ms"
def _elapsed_hms(seconds: float) -> str:
"""Format seconds as ``M:SS`` for live-display rows."""
m = int(seconds // 60)
s = int(seconds % 60)
return f"{m}:{s:02d}"
ReplTimestampStyle = Literal["table", "compact", "utc"]
def format_repl_duration(duration_secs: int | None) -> str:
"""Format a duration in seconds for REPL session/task tables."""
if duration_secs is None:
return ""
if duration_secs < 60:
return f"{duration_secs}s"
if duration_secs < 3600:
return f"{duration_secs // 60}m {duration_secs % 60}s"
hours = duration_secs // 3600
minutes = (duration_secs % 3600) // 60
return f"{hours}h {minutes}m"
def format_repl_timestamp(
value: str | datetime | int | float | None,
*,
style: ReplTimestampStyle = "table",
fallback: str = "",
) -> str:
"""Format ISO strings, datetimes, or unix timestamps for REPL display."""
if value is None:
return fallback
if isinstance(value, datetime):
dt = value
elif isinstance(value, (int, float)):
dt = datetime.fromtimestamp(float(value), tz=UTC)
else:
raw = value.strip()
if not raw:
return fallback
try:
dt = datetime.fromisoformat(raw)
except ValueError:
return raw[:16] if raw else fallback
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
if style == "utc":
return dt.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
local = dt.astimezone()
if style == "compact":
return local.strftime("%m-%d %H:%M")
return local.strftime("%Y-%m-%d %H:%M")
@@ -0,0 +1,22 @@
"""Token-count constants and short-form formatter.
Shared between the streaming renderer (ui/streaming.py) and the spinner state
(runtime/state.py) so both display the same ``1.2k`` format and the same
chars-per-token heuristic.
"""
from __future__ import annotations
# Approximate characters per token used to estimate token counts from byte
# lengths without waiting for the API to return exact usage.
_CHARS_PER_TOKEN = 4
def format_token_count_short(token_count: int) -> str:
"""Format a token count as a short string — ``42`` / ``1.2k`` / ``5.2k``."""
if token_count >= 1000:
return f"{token_count / 1000:.1f}k"
return str(token_count)
__all__ = ["_CHARS_PER_TOKEN", "format_token_count_short"]