4b6817381b
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
257 lines
8.8 KiB
Python
257 lines
8.8 KiB
Python
"""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",
|
|
]
|