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
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:
@@ -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 0x40–0x7E 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"]
|
||||
Reference in New Issue
Block a user