mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
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:
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user