Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

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",
]