core/cli: Rich markdown for assistant text at end of turn

Stream plain tokens; on TTY re-render with Rich. /markdown and PLYNGENT_PLAIN.
This commit is contained in:
2026-07-15 22:21:18 +08:00
parent d267a5f869
commit 5d2f0c5539
7 changed files with 223 additions and 15 deletions
+98 -9
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import contextlib
import os
import sys
from contextvars import ContextVar
from typing import TYPE_CHECKING
@@ -25,8 +28,9 @@ if TYPE_CHECKING:
_TOOL_RESULT_PREVIEW = 120
_TOOL_ARGS_PREVIEW = 80
# Process/session display flag for tool result dumps (set from ReplState / slash).
# Process/session display flags (set from ReplState / slash).
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
_markdown_enabled: ContextVar[bool] = ContextVar("markdown_enabled", default=True)
def set_verbose_tool_results(enabled: bool) -> None: # noqa: FBT001
@@ -38,6 +42,25 @@ def get_verbose_tool_results() -> bool:
return _verbose_tool_results.get()
def set_markdown_enabled(enabled: bool) -> None: # noqa: FBT001
"""Enable or disable end-of-turn Rich markdown rendering."""
_ = _markdown_enabled.set(enabled)
def get_markdown_enabled() -> bool:
return _markdown_enabled.get()
def markdown_render_available() -> bool:
"""True when stdout is a TTY and plain mode is not forced via env."""
if os.environ.get("PLYNGENT_PLAIN", "").strip() in {"1", "true", "yes", "on"}:
return False
try:
return sys.stdout.isatty()
except (AttributeError, OSError, ValueError):
return False
def _preview(text: str, limit: int) -> str:
if len(text) <= limit:
return text
@@ -47,23 +70,64 @@ def _preview(text: str, limit: int) -> str:
def _echo_stream(text: str) -> None:
"""Write without newline and flush so assistant text appears token-by-token."""
click.echo(text, nl=False)
try:
import sys
with contextlib.suppress(OSError):
_ = sys.stdout.flush()
except OSError:
pass
def _clear_streamed_lines(line_count: int) -> None:
"""Move cursor up and clear the streamed plain-text region (TTY only)."""
if line_count <= 0:
return
# Clear current line, then each previous line of the streamed block.
for _ in range(line_count):
_ = sys.stdout.write("\r\033[2K\033[1A")
_ = sys.stdout.write("\r\033[2K")
with contextlib.suppress(OSError):
_ = sys.stdout.flush()
def _line_count_for_clear(label: str, body: str) -> int:
"""Approximate terminal lines used by ``label + body`` for cursor erase."""
if not body and not label:
return 0
text = label + body
# +1 for trailing newline after the stream block in render_events.
return text.count("\n") + 1
def print_markdown(text: str, *, label: str = "assistant: ") -> None:
"""Render *text* as markdown via Rich, with a cyan label."""
from rich.console import Console
from rich.markdown import Markdown
from rich.text import Text
console = Console(file=sys.stdout, highlight=False)
if label:
console.print(Text(label, style="cyan"), end="")
console.print(Markdown(text))
async def render_events( # noqa: C901, PLR0912, PLR0915
events: AsyncIterator[AgentEvent],
*,
verbose: bool | None = None,
markdown: bool | None = None,
) -> None:
"""Print agent events to the terminal (text deltas stream as they arrive)."""
"""Print agent events to the terminal (text deltas stream as they arrive).
When markdown is enabled and stdout is a TTY, the plain streamed assistant
text is replaced at end-of-turn with a Rich markdown render.
"""
show_full = get_verbose_tool_results() if verbose is None else verbose
use_markdown = get_markdown_enabled() if markdown is None else markdown
pretty = bool(use_markdown and markdown_render_available())
printed_reasoning = False
printed_text = False
assistant_buf: list[str] = []
# Tools/errors after assistant text: skip replace (would erase tool lines).
interrupted_by_other = False
async for event in events:
if isinstance(event, ReasoningDeltaEvent):
if not printed_reasoning:
@@ -78,8 +142,11 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
click.echo()
click.secho("assistant: ", fg="cyan", nl=False)
printed_text = True
assistant_buf.append(event.content)
_echo_stream(event.content)
elif isinstance(event, ToolCallEvent):
if printed_text:
interrupted_by_other = True
call = event.tool_call
if isinstance(call, AssistantFunctionToolCall):
args = _preview(call.function.arguments, _TOOL_ARGS_PREVIEW)
@@ -87,15 +154,18 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
else:
click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
elif isinstance(event, ToolResultEvent):
if printed_text:
interrupted_by_other = True
content = event.message.content
if show_full:
click.secho(f"[tool ok]\n{content}", fg="magenta")
else:
preview = _preview(content, _TOOL_RESULT_PREVIEW)
# Single-line summary; full result stays in the message history for the model.
one_line = preview.replace("\n", " ")
click.secho(f"[tool ok] {one_line}", fg="magenta")
elif isinstance(event, ErrorEvent):
if printed_text:
interrupted_by_other = True
suffix = ""
if event.source:
suffix += f" source={event.source}"
@@ -103,11 +173,15 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
suffix += " (fatal)"
click.secho(f"\n[error]{suffix} {event.message}", fg="bright_red")
elif isinstance(event, CancelledEvent):
if printed_text:
interrupted_by_other = True
if event.reason:
click.secho(f"\n[cancelled] {event.reason}", fg="yellow")
else:
click.secho("\n[cancelled]", fg="yellow")
elif isinstance(event, MaxRoundsEvent):
if printed_text:
interrupted_by_other = True
if event.continued:
click.secho(
f"\n[max rounds {event.rounds} reached — continuing with a higher allowance]",
@@ -116,11 +190,26 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
else:
click.secho(f"\n[max rounds reached: {event.rounds}]", fg="red")
elif isinstance(event, UsageEvent):
# Printed at end of turn as a summary; individual round usage is quiet.
_ = event
else:
# AssistantMessageEvent — text already shown via TextDeltaEvent.
_ = event
full_assistant = "".join(assistant_buf)
if pretty and printed_text and full_assistant.strip() and not interrupted_by_other:
# Replace plain stream with markdown (assistant block only).
lines = _line_count_for_clear("assistant: ", full_assistant)
# Also account for blank line before label when reasoning was shown.
if printed_reasoning:
lines += 1
_clear_streamed_lines(lines)
if printed_reasoning:
# Reasoning already printed above; leave it and only re-print assistant.
pass
print_markdown(full_assistant, label="assistant: ")
click.echo()
return
if printed_text or printed_reasoning:
click.echo()
click.echo()
+18
View File
@@ -734,6 +734,24 @@ def verbose_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
click.echo(f"verbose={'on' if enabled else 'off'}")
@slash.command("markdown")
@click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]")
@click.pass_obj
def markdown_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
"""Show or set end-of-turn Rich markdown for assistant text.
``on`` (default on TTY): stream plain tokens, then re-render as markdown.
``off``: leave streamed plain text as-is.
Non-TTY / ``PLYNGENT_PLAIN=1`` never pretty-prints.
"""
if enabled is None:
click.echo(f"markdown={'on' if state.markdown_enabled else 'off'}")
return
state.markdown_enabled = enabled
state.sync_display_flags()
click.echo(f"markdown={'on' if enabled else 'off'}")
@slash.command("rounds")
@click.argument("n", required=False, type=int)
@click.pass_obj
+4 -1
View File
@@ -40,6 +40,8 @@ class ReplState:
max_rounds: int = DEFAULT_MAX_ROUNDS
stream_enabled: bool = True
verbose: bool = False
# End-of-turn Rich markdown for assistant text (TTY only).
markdown_enabled: bool = True
# One-shot / scripts: never prompt to raise tool-loop limits.
interactive_limits: bool = True
# When False, skip destructive-tool confirms (e.g. --yes).
@@ -63,9 +65,10 @@ class ReplState:
self.sync_display_flags()
def sync_display_flags(self) -> None:
from plyngent.cli.display import set_verbose_tool_results
from plyngent.cli.display import set_markdown_enabled, set_verbose_tool_results
set_verbose_tool_results(self.verbose)
set_markdown_enabled(self.markdown_enabled)
def _workspace_key(self) -> str:
key = normalize_workspace(self.workspace)