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
+2 -1
View File
@@ -121,7 +121,8 @@ Type `/help` in the REPL for the live list. Common ones:
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
| `/compact` | Soft-compact + model summary into a **new** session |
| `/stream` `/verbose` `/tools` `/rounds` | Toggles and limits |
| `/stream` `/verbose` `/markdown` `/tools` `/rounds` | Toggles and limits |
| `/retry` | Re-run incomplete last user turn (after error/cancel) |
| `/provider` `/model` | Switch without restarting |
| `/models` | List config + remote `GET /models` (`--refresh` bypasses cache) |
Generated
+42 -2
View File
@@ -5,7 +5,7 @@
groups = ["default", "dev"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
content_hash = "sha256:f244597f4e94cc6f75cb0e08bf337a7f09e7cc4e9444726be86e488247350a49"
content_hash = "sha256:b849e1647ce0ec47b61889ae0e66863acce020cd1f2e3deaeb9e2e92becf9da5"
[[metadata.targets]]
requires_python = "~=3.14"
@@ -225,6 +225,31 @@ files = [
{file = "jh2-5.0.13.tar.gz", hash = "sha256:f8c78cffb3a35c4410513c3eb7989de36028c84277c04f07c97909dd94c23a75"},
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
requires_python = ">=3.10"
summary = "Python port of markdown-it. Markdown parsing, done right!"
groups = ["default"]
dependencies = [
"mdurl~=0.1",
]
files = [
{file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"},
{file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"},
]
[[package]]
name = "mdurl"
version = "0.1.2"
requires_python = ">=3.7"
summary = "Markdown URL utilities"
groups = ["default"]
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "msgspec"
version = "0.21.1"
@@ -326,7 +351,7 @@ name = "pygments"
version = "2.20.0"
requires_python = ">=3.9"
summary = "Pygments is a syntax highlighting package written in Python."
groups = ["dev"]
groups = ["default", "dev"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
@@ -428,6 +453,21 @@ files = [
{file = "qh3-1.9.4.tar.gz", hash = "sha256:bd2ea9baf19656c544a48a56a195f2ac257cd973b566f5f2998fa3b7446281a1"},
]
[[package]]
name = "rich"
version = "15.0.0"
requires_python = ">=3.9.0"
summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
groups = ["default"]
dependencies = [
"markdown-it-py>=2.2.0",
"pygments<3.0.0,>=2.13.0",
]
files = [
{file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"},
{file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"},
]
[[package]]
name = "ruff"
version = "0.15.21"
+2 -1
View File
@@ -15,7 +15,8 @@ dependencies = [
"aiosqlite>=0.22.1",
"click>=8.4.2",
"awaitlet>=0.0.1",
"pywinpty>=2.0.0; sys_platform == \"win32\""
"pywinpty>=2.0.0; sys_platform == \"win32\"",
"rich>=13"
]
requires-python = ">=3.14,<4"
readme = "README.md"
+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)
+57 -1
View File
@@ -3,7 +3,14 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import ReasoningDeltaEvent, TextDeltaEvent, ToolResultEvent
from plyngent.cli.display import render_events, set_verbose_tool_results
from plyngent.cli.display import (
get_markdown_enabled,
markdown_render_available,
print_markdown,
render_events,
set_markdown_enabled,
set_verbose_tool_results,
)
from plyngent.lmproto.openai_compatible.model import ToolChatMessage
if TYPE_CHECKING:
@@ -20,6 +27,7 @@ async def _aiter(events: list[AgentEvent]) -> AsyncIterator[AgentEvent]:
async def test_render_reasoning_and_text(capsys: pytest.CaptureFixture[str]) -> None:
set_markdown_enabled(False)
await render_events(
_aiter(
[
@@ -33,12 +41,14 @@ async def test_render_reasoning_and_text(capsys: pytest.CaptureFixture[str]) ->
assert "think" in out
assert "assistant: " in out
assert "hello" in out
set_markdown_enabled(True)
async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]) -> None:
long = "x" * 200
msg = ToolChatMessage(content=long, tool_call_id="1")
set_verbose_tool_results(False)
set_markdown_enabled(False)
await render_events(_aiter([ToolResultEvent(message=msg)]))
out = capsys.readouterr().out
assert "" in out
@@ -49,3 +59,49 @@ async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]
out2 = capsys.readouterr().out
assert long in out2
set_verbose_tool_results(False)
set_markdown_enabled(True)
async def test_markdown_off_keeps_plain_stream(
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("plyngent.cli.display.markdown_render_available", lambda: True)
set_markdown_enabled(False)
await render_events(_aiter([TextDeltaEvent(content="**bold**")]))
out = capsys.readouterr().out
assert "**bold**" in out
set_markdown_enabled(True)
async def test_markdown_on_replaces_with_rich(
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("plyngent.cli.display.markdown_render_available", lambda: True)
set_markdown_enabled(True)
await render_events(_aiter([TextDeltaEvent(content="hello **world**")]), markdown=True)
out = capsys.readouterr().out
# Rich markdown renders emphasis; raw ** markers should not remain as the sole form.
assert "assistant:" in out or "assistant: " in out
assert "world" in out
def test_print_markdown_renders(capsys: pytest.CaptureFixture[str]) -> None:
print_markdown("# Title\n\n`code`", label="assistant: ")
out = capsys.readouterr().out
assert "Title" in out
assert "code" in out
def test_markdown_flags_roundtrip() -> None:
set_markdown_enabled(False)
assert get_markdown_enabled() is False
set_markdown_enabled(True)
assert get_markdown_enabled() is True
def test_markdown_render_available_respects_plain_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PLYNGENT_PLAIN", "1")
assert markdown_render_available() is False
monkeypatch.delenv("PLYNGENT_PLAIN", raising=False)