diff --git a/README.md b/README.md index 5e455bf..72af4d9 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/pdm.lock b/pdm.lock index fe5436c..fc1b702 100644 --- a/pdm.lock +++ b/pdm.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 477c8eb..387fa4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/plyngent/cli/display.py b/src/plyngent/cli/display.py index c111ed0..05e7f9c 100644 --- a/src/plyngent/cli/display.py +++ b/src/plyngent/cli/display.py @@ -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() diff --git a/src/plyngent/cli/slash.py b/src/plyngent/cli/slash.py index 1bb48dc..37b2f83 100644 --- a/src/plyngent/cli/slash.py +++ b/src/plyngent/cli/slash.py @@ -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 diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 44765c3..19569e5 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -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) diff --git a/tests/test_cli/test_display.py b/tests/test_cli/test_display.py index da82fdc..e3c3faa 100644 --- a/tests/test_cli/test_display.py +++ b/tests/test_cli/test_display.py @@ -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)