diff --git a/src/plyngent/cli/display.py b/src/plyngent/cli/display.py index 0de6ebb..067ebc4 100644 --- a/src/plyngent/cli/display.py +++ b/src/plyngent/cli/display.py @@ -7,6 +7,7 @@ import re import shlex import sys from contextvars import ContextVar +from dataclasses import dataclass from typing import TYPE_CHECKING, Literal, cast import click @@ -31,32 +32,6 @@ if TYPE_CHECKING: _TOOL_RESULT_PREVIEW = 120 _TOOL_ARGS_PREVIEW = 80 -# Tool calls that render as a single pretty summary line instead of -# ``[tool]`` / ``[tool result]``. -_PRETTY_TOOLS = frozenset( - { - "read_file", - "todo_push", - "todo_update", - "tree", - "listdir", - "glob_paths", - "grep_files", - "run_argv", - "run_argv_batch", - "fetch", - "edit_replace", - "edit_lineno", - "write_file", - "copy_path", - "move_path", - "delete_path", - "vcs_status", - "vcs_log", - "wait", - } -) - # 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) @@ -82,8 +57,8 @@ 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.""" +def interactive_terminal() -> bool: + """True when stdout is a terminal we may write mid-line and erase on.""" if os.environ.get("PLYNGENT_PLAIN", "").strip() in {"1", "true", "yes", "on"}: return False try: @@ -92,6 +67,11 @@ def markdown_render_available() -> bool: return False +def markdown_render_available() -> bool: + """True when stdout is a TTY and plain mode is not forced via env.""" + return interactive_terminal() + + def _preview(text: str, limit: int) -> str: if len(text) <= limit: return text @@ -137,14 +117,19 @@ def _json_list_arg(args_json: str, key: str) -> list[str] | None: return None -def _pretty_line(prefix: str, *segments: tuple[str, str | None]) -> str: - """Style a pretty tool summary: yellow ``* Verb`` prefix + colored detail segments. +def _pretty_prefix(text: str) -> str: + """Style the leading ``* Verb 'target' `` segment of a pretty tool line.""" + return click.style(text, fg="yellow") + + +def _pretty_segments(*segments: tuple[str, str | None]) -> str: + """Style pretty-line detail segments. ``segments`` are ``(text, fg)`` pairs; ``fg=None`` leaves a segment uncolored and ``"dim"`` renders it dim. click.style emits ANSI codes only on a TTY, so non-TTY output stays plain. """ - parts = [click.style(prefix, fg="yellow")] + parts: list[str] = [] for text, fg in segments: if fg == "dim": parts.append(click.style(text, dim=True)) @@ -153,25 +138,51 @@ def _pretty_line(prefix: str, *segments: tuple[str, str | None]) -> str: return "".join(parts) -def _read_file_pretty(args_json: str, result: str) -> str: - """One-line summary for a ``read_file`` call: ``* Read 'path' L1-4 (done)``.""" +@dataclass(frozen=True, slots=True) +class PrettyLine: + """Split renderer for one pretty tool call. + + ``prefix`` is written as soon as the call starts — a blocking tool call is + then visible while it runs — and ``detail`` is appended once its result + lands. Together they equal the single line the whole-line path prints. + """ + + prefix: Callable[[str], str] + detail: Callable[[str, str], str] + + +def _read_file_prefix(args_json: str) -> str: + """``* Read 'path' ``: known before the result, so it can lead the line.""" path = _json_str_arg(args_json, "path") or "?" + return _pretty_prefix(f"* Read '{path}' ") + + +def _read_file_detail(_args_json: str, result: str) -> str: + """``read_file`` outcome: range/done, or which failure kind occurred.""" if result.startswith("error: file not found"): - return _pretty_line(f"* Read '{path}' ", ("(file not found)", "red")) + return _pretty_segments(("(file not found)", "red")) if result.startswith("error: not a file"): - return _pretty_line(f"* Read '{path}' ", ("(not a file)", "red")) + return _pretty_segments(("(not a file)", "red")) if result.startswith("error:"): - return _pretty_line(f"* Read '{path}' ", ("(error)", "red")) - first = result.splitlines()[0] if result.splitlines() else "" + return _pretty_segments(("(error)", "red")) + lines = result.splitlines() + first = lines[0] if lines else "" if first.startswith("L") and "-" in first: - return _pretty_line(f"* Read '{path}' ", (f"{first} ", "dim"), ("(done)", "green")) - return _pretty_line(f"* Read '{path}' ", ("(done)", "green")) + return _pretty_segments((f"{first} ", "dim"), ("(done)", "green")) + return _pretty_segments(("(done)", "green")) -def _todo_pretty(name: str, result: str) -> str: - """Summary for todo push/update: header + the rendered todo stack.""" +def _todo_line(name: str) -> PrettyLine: + """``todo_push`` / ``todo_update``: header first, rendered stack as the detail.""" label = "Todo Push" if name == "todo_push" else "Todo Update" - return _pretty_line(f"* {label}:\n{result}") + + def prefix(_args_json: str) -> str: + return click.style(f"* {label}:", fg="yellow") + + def detail(_args_json: str, result: str) -> str: + return click.style(f"\n{result}", fg="yellow") + + return PrettyLine(prefix=prefix, detail=detail) def _tree_line_stats(fmt: str, line: str) -> tuple[str, int] | None: @@ -202,15 +213,19 @@ def _tree_line_stats(fmt: str, line: str) -> tuple[str, int] | None: return kind, depth -def _tree_pretty(args_json: str, result: str) -> str: - """One-line summary for a ``tree`` call: ``* Tree 'src' (3 dirs, 24 files, depth 3)``. +def _tree_prefix(args_json: str) -> str: + path = _json_str_arg(args_json, "path") or "." + return _pretty_prefix(f"* Tree '{path}' ") + + +def _tree_detail(args_json: str, result: str) -> str: + """``tree`` outcome: dir/file counts, plus depth when the format has one. Flat trees have no depth, so the depth part is omitted for ``format=flat``. """ - path = _json_str_arg(args_json, "path") or "." - fmt = _json_str_arg(args_json, "format") or "markdown" if result.startswith("error:"): - return f"* Tree '{path}' (error)" + return _pretty_segments(("(error)", "red")) + fmt = _json_str_arg(args_json, "format") or "markdown" dirs = 0 files = 0 depth = 0 @@ -225,17 +240,21 @@ def _tree_pretty(args_json: str, result: str) -> str: files += 1 depth = max(depth, line_depth) if depth: - return _pretty_line(f"* Tree '{path}' ", (f"({dirs} dirs, {files} files, depth {depth})", None)) - return _pretty_line(f"* Tree '{path}' ", (f"({dirs} dirs, {files} files)", None)) + return _pretty_segments((f"({dirs} dirs, {files} files, depth {depth})", None)) + return _pretty_segments((f"({dirs} dirs, {files} files)", None)) -def _listdir_pretty(args_json: str, result: str) -> str: - """One-line summary for a ``listdir`` call: ``* List 'path' (3 dirs, 5 files)``.""" +def _listdir_prefix(args_json: str) -> str: path = _json_str_arg(args_json, "path") or "." + return _pretty_prefix(f"* List '{path}' ") + + +def _listdir_detail(_args_json: str, result: str) -> str: + """``listdir`` outcome: dir/file counts, or empty/error.""" if result.startswith("error:"): - return _pretty_line(f"* List '{path}' ", (f"({result})", "red")) + return _pretty_segments((f"({result})", "red")) if result == "(empty)": - return _pretty_line(f"* List '{path}' ", ("(empty)", "dim")) + return _pretty_segments(("(empty)", "dim")) dirs = 0 files = 0 for line in result.splitlines(): @@ -243,46 +262,55 @@ def _listdir_pretty(args_json: str, result: str) -> str: dirs += 1 elif line.startswith("file\t"): files += 1 - return _pretty_line(f"* List '{path}' ", (f"({dirs} dirs, {files} files)", None)) + return _pretty_segments((f"({dirs} dirs, {files} files)", None)) -def _glob_pretty(args_json: str, result: str) -> str: - """One-line summary for a ``glob_paths`` call: ``* Glob '**/*.py' in '.' (12 paths)``.""" +def _glob_prefix(args_json: str) -> str: pattern = _json_str_arg(args_json, "pattern") or "?" path = _json_str_arg(args_json, "path") or "." + return _pretty_prefix(f"* Glob '{pattern}' in '{path}' ") + + +def _glob_detail(_args_json: str, result: str) -> str: + """``glob_paths`` outcome: match count, or none/error.""" if result.startswith("error:"): - return _pretty_line(f"* Glob '{pattern}' in '{path}' ", (f"({result})", "red")) + return _pretty_segments((f"({result})", "red")) if result == "(no matches)": - return _pretty_line(f"* Glob '{pattern}' in '{path}' ", ("(no matches)", "dim")) + return _pretty_segments(("(no matches)", "dim")) count = sum(1 for line in result.splitlines() if line and not line.startswith("...[truncated")) unit = "path" if count == 1 else "paths" - return _pretty_line(f"* Glob '{pattern}' in '{path}' ", (f"({count} {unit})", None)) + return _pretty_segments((f"({count} {unit})", None)) -def _grep_pretty(args_json: str, result: str) -> str: - """One-line summary for a ``grep_files`` call: ``* Grep 'pat' in '.' (12 matches in 3 files)``.""" +def _grep_prefix(args_json: str) -> str: pattern = _json_str_arg(args_json, "pattern") or "?" path = _json_str_arg(args_json, "path") or "." + return _pretty_prefix(f"* Grep '{pattern}' in '{path}' ") + + +def _grep_detail(_args_json: str, result: str) -> str: + """``grep_files`` outcome: matches and file count, or none/error.""" if result.startswith("error:"): - return _pretty_line(f"* Grep '{pattern}' in '{path}' ", (f"({result})", "red")) + return _pretty_segments((f"({result})", "red")) if result == "(no matches)": - return _pretty_line(f"* Grep '{pattern}' in '{path}' ", ("(no matches)", "dim")) + return _pretty_segments(("(no matches)", "dim")) matches = [line for line in result.splitlines() if line and not line.startswith("...[truncated")] files = len({line.split(":", 1)[0] for line in matches}) match_unit = "match" if len(matches) == 1 else "matches" file_unit = "file" if files == 1 else "files" - return _pretty_line( - f"* Grep '{pattern}' in '{path}' ", - (f"({len(matches)} {match_unit} in {files} {file_unit})", None), - ) + return _pretty_segments((f"({len(matches)} {match_unit} in {files} {file_unit})", None)) -def _run_argv_pretty(args_json: str, result: str) -> str: - """One-line summary for a ``run_argv`` call: ``* Run $ git status --short (exit code 0)``.""" +def _run_argv_prefix(args_json: str) -> str: argv = _json_list_arg(args_json, "argv") cmd = shlex.join(argv) if argv else "?" + return _pretty_prefix(f"* Run $ {cmd} ") + + +def _run_argv_detail(_args_json: str, result: str) -> str: + """``run_argv`` outcome: exit code, timeout, or error.""" if result.startswith("error:"): - return _pretty_line(f"* Run $ {cmd} ", (f"({result})", "red")) + return _pretty_segments((f"({result})", "red")) fields: dict[str, str] = {} for line in result.splitlines(): if line.startswith("--- "): @@ -291,25 +319,25 @@ def _run_argv_pretty(args_json: str, result: str) -> str: key, _, value = line.partition("=") fields[key] = value if fields.get("timed_out") == "true": - return _pretty_line(f"* Run $ {cmd} ", ("(timed out)", "red")) + return _pretty_segments(("(timed out)", "red")) code = fields.get("exit_code", "") fg = "green" if code == "0" else "red" - return _pretty_line(f"* Run $ {cmd} ", (f"(exit code {code or 'killed'})", fg)) + return _pretty_segments((f"(exit code {code or 'killed'})", fg)) -def _run_argv_batch_pretty(_args_json: str, result: str) -> str: - """One-line summary for a ``run_argv_batch`` call: ``* Run batch (done)``.""" +def _run_argv_batch_prefix(_args_json: str) -> str: + return _pretty_prefix("* Run batch ") + + +def _run_argv_batch_detail(_args_json: str, result: str) -> str: + """``run_argv_batch`` outcome: done, stopped early, or error.""" if result.startswith("error:"): - return _pretty_line("* Run batch ", (f"({result})", "red")) + return _pretty_segments((f"({result})", "red")) lines = result.splitlines() head = lines[0] if lines else "" - stopped_early = False - for part in head.split(): - if part == "stopped_early=true": - stopped_early = True - if stopped_early: - return _pretty_line("* Run batch ", ("(stopped early)", "yellow")) - return _pretty_line("* Run batch ", ("(done)", "green")) + if any(part == "stopped_early=true" for part in head.split()): + return _pretty_segments(("(stopped early)", "yellow")) + return _pretty_segments(("(done)", "green")) def _http_status_fg(status: str) -> str | None: @@ -319,12 +347,16 @@ def _http_status_fg(status: str) -> str | None: return None -def _fetch_pretty(args_json: str, result: str) -> str: - """One-line summary for a ``fetch`` call: ``* Fetch GET https://… (200)``.""" +def _fetch_prefix(args_json: str) -> str: method = _json_str_arg(args_json, "method") or "GET" url = _json_str_arg(args_json, "url") or "?" + return _pretty_prefix(f"* Fetch {method} {url} ") + + +def _fetch_detail(_args_json: str, result: str) -> str: + """``fetch`` outcome: HTTP status, or error.""" if result.startswith("error:"): - return _pretty_line(f"* Fetch {method} {url} ", (f"({result})", "red")) + return _pretty_segments((f"({result})", "red")) status = "" for line in result.splitlines(): if line.startswith("--- "): @@ -332,7 +364,7 @@ def _fetch_pretty(args_json: str, result: str) -> str: if line.startswith("status="): status = line.partition("=")[2] break - return _pretty_line(f"* Fetch {method} {url} ", (f"({status or 'done'})", _http_status_fg(status))) + return _pretty_segments((f"({status or 'done'})", _http_status_fg(status))) _MUTATOR_VERBS: dict[str, str] = { @@ -345,83 +377,107 @@ _MUTATOR_VERBS: dict[str, str] = { } -def _mutator_pretty(name: str, args_json: str, result: str) -> str: - """One-line summary for a file mutation: ``* Wrote 'path' (120 chars)`` / ``* Edit 'path' (done)``.""" - verb = _MUTATOR_VERBS[name] +def _mutator_prefix(name: str, args_json: str) -> str: if name in {"copy_path", "move_path"}: src = _json_str_arg(args_json, "src") or "?" dst = _json_str_arg(args_json, "dst") or "?" target = f"'{src}' → '{dst}'" else: - path = _json_str_arg(args_json, "path") or "?" - target = f"'{path}'" + target = f"'{_json_str_arg(args_json, 'path') or '?'}'" + return _pretty_prefix(f"* {_MUTATOR_VERBS[name]} {target} ") + + +def _mutator_detail(name: str, result: str) -> str: + """File mutation outcome: written size, done, or error.""" if result.startswith("error:"): - return _pretty_line(f"* {verb} {target} ", (f"({result})", "red")) + return _pretty_segments((f"({result})", "red")) if name == "write_file": # write_file keeps its brief detail: ``wrote 120 characters to path``. match = re.match(r"wrote (\d+) characters to (.+)$", result) if match: - chars, path = match.groups() - return _pretty_line(f"* Write '{path}' ", (f"({chars} chars)", None)) - return _pretty_line(f"* {verb} {target} ", ("(done)", "green")) + chars, _path = match.groups() + return _pretty_segments((f"({chars} chars)", None)) + return _pretty_segments(("(done)", "green")) -def _vcs_status_pretty(_args_json: str, result: str) -> str: - """One-line summary for ``vcs_status``: ``* VCS Status (done)``.""" +def _mutator_line(name: str) -> PrettyLine: + return PrettyLine( + prefix=lambda args: _mutator_prefix(name, args), + detail=lambda _args, result: _mutator_detail(name, result), + ) + + +def _vcs_status_prefix(_args_json: str) -> str: + return _pretty_prefix("* VCS Status ") + + +def _vcs_status_detail(_args_json: str, result: str) -> str: + """``vcs_status`` outcome: done, or error.""" if result.startswith("error:"): - return _pretty_line("* VCS Status ", (f"({result})", "red")) - return _pretty_line("* VCS Status ", ("(done)", "green")) + return _pretty_segments((f"({result})", "red")) + return _pretty_segments(("(done)", "green")) -def _vcs_log_pretty(_args_json: str, result: str) -> str: - """One-line summary for ``vcs_log``: ``* VCS Log (5 commits)``.""" +def _vcs_log_prefix(_args_json: str) -> str: + return _pretty_prefix("* VCS Log ") + + +def _vcs_log_detail(_args_json: str, result: str) -> str: + """``vcs_log`` outcome: commit count, none, or error.""" if result.startswith("error:"): - return _pretty_line("* VCS Log ", (f"({result})", "red")) + return _pretty_segments((f"({result})", "red")) if result == "(no commits)": - return _pretty_line("* VCS Log ", ("(no commits)", "dim")) + return _pretty_segments(("(no commits)", "dim")) commits = [line for line in result.splitlines() if line.strip()] unit = "commit" if len(commits) == 1 else "commits" - return _pretty_line("* VCS Log ", (f"({len(commits)} {unit})", None)) + return _pretty_segments((f"({len(commits)} {unit})", None)) -def _wait_pretty(_args_json: str, result: str) -> str: - """One-line summary for a ``wait`` call: ``* Wait (5s)`` / ``* Wait (disturbed)``.""" +def _wait_prefix(_args_json: str) -> str: + return _pretty_prefix("* Wait ") + + +def _wait_detail(_args_json: str, result: str) -> str: + """``wait`` outcome: slept duration, disturbed, or error.""" if result.startswith("error:"): - return _pretty_line("* Wait ", (f"({result})", "red")) + return _pretty_segments((f"({result})", "red")) if result.startswith("waited "): - return _pretty_line("* Wait ", (f"({result.removeprefix('waited ')})", "green")) - return _pretty_line("* Wait ", ("(disturbed)", "yellow")) + return _pretty_segments((f"({result.removeprefix('waited ')})", "green")) + return _pretty_segments(("(disturbed)", "yellow")) -_PRETTY_BUILDERS: dict[str, Callable[[str, str], str]] = { - "read_file": _read_file_pretty, - "tree": _tree_pretty, - "todo_push": lambda _args, result: _todo_pretty("todo_push", result), - "todo_update": lambda _args, result: _todo_pretty("todo_update", result), - "listdir": _listdir_pretty, - "glob_paths": _glob_pretty, - "grep_files": _grep_pretty, - "run_argv": _run_argv_pretty, - "run_argv_batch": _run_argv_batch_pretty, - "fetch": _fetch_pretty, - "edit_replace": lambda args, result: _mutator_pretty("edit_replace", args, result), - "edit_lineno": lambda args, result: _mutator_pretty("edit_lineno", args, result), - "write_file": lambda args, result: _mutator_pretty("write_file", args, result), - "copy_path": lambda args, result: _mutator_pretty("copy_path", args, result), - "move_path": lambda args, result: _mutator_pretty("move_path", args, result), - "delete_path": lambda args, result: _mutator_pretty("delete_path", args, result), - "vcs_status": _vcs_status_pretty, - "vcs_log": _vcs_log_pretty, - "wait": _wait_pretty, +# Pretty tools: ``prefix`` prints as the call starts, ``detail`` when it lands. +_PRETTY_LINES: dict[str, PrettyLine] = { + "read_file": PrettyLine(prefix=_read_file_prefix, detail=_read_file_detail), + "tree": PrettyLine(prefix=_tree_prefix, detail=_tree_detail), + "todo_push": _todo_line("todo_push"), + "todo_update": _todo_line("todo_update"), + "listdir": PrettyLine(prefix=_listdir_prefix, detail=_listdir_detail), + "glob_paths": PrettyLine(prefix=_glob_prefix, detail=_glob_detail), + "grep_files": PrettyLine(prefix=_grep_prefix, detail=_grep_detail), + "run_argv": PrettyLine(prefix=_run_argv_prefix, detail=_run_argv_detail), + "run_argv_batch": PrettyLine(prefix=_run_argv_batch_prefix, detail=_run_argv_batch_detail), + "fetch": PrettyLine(prefix=_fetch_prefix, detail=_fetch_detail), + "edit_replace": _mutator_line("edit_replace"), + "edit_lineno": _mutator_line("edit_lineno"), + "write_file": _mutator_line("write_file"), + "copy_path": _mutator_line("copy_path"), + "move_path": _mutator_line("move_path"), + "delete_path": _mutator_line("delete_path"), + "vcs_status": PrettyLine(prefix=_vcs_status_prefix, detail=_vcs_status_detail), + "vcs_log": PrettyLine(prefix=_vcs_log_prefix, detail=_vcs_log_detail), + "wait": PrettyLine(prefix=_wait_prefix, detail=_wait_detail), } +_PRETTY_TOOLS = frozenset(_PRETTY_LINES) -def _pretty_tool_result(name: str, args_json: str, result: str) -> str | None: - """Pretty summary for a known tool call + result; None keeps the old style.""" - builder = _PRETTY_BUILDERS.get(name) - if builder is None: + +def _pretty_parts(name: str, args_json: str, result: str) -> tuple[str, str] | None: + """Prefix + detail for a known tool call; None keeps the ``[tool]`` style.""" + line = _PRETTY_LINES.get(name) + if line is None: return None - return builder(args_json, result) + return line.prefix(args_json), line.detail(args_json, result) def _echo_stream(text: str) -> None: @@ -452,6 +508,63 @@ def _line_count_for_clear(label: str, body: str) -> int: return text.count("\n") + 1 +class _PrettyToolStream: + """Pretty tool lines: prefix at call time, detail appended when it lands. + + Writing the prefix immediately keeps a blocking tool call visible; appending + the detail later reproduces the single whole-line write byte for byte, so + non-interactive output is unchanged. The calls of one batch are yielded + before their results, so a second call while a prefix is open (parallel + tools) cannot append its detail in order: that prefix is erased and the + batch falls back to whole lines. + """ + + _interactive: bool + _open: bool + _whole_lines: bool + + def __init__(self, *, interactive: bool) -> None: + self._interactive = interactive + self._open = False + self._whole_lines = False + + def start_call(self, prefix: str) -> None: + """Show *prefix* for a call that is about to run (cursor left mid-line).""" + if not (self._interactive and prefix) or self._whole_lines: + return + if self._open: + # Parallel batch: the open prefix could never receive its detail. + _clear_streamed_lines(1) + self._open = False + self._whole_lines = True + return + click.echo(f"\n{prefix}", nl=False) + with contextlib.suppress(OSError): + _ = sys.stdout.flush() + self._open = True + + def finish_call(self, detail: str) -> bool: + """Append *detail* to the open prefix; False when a whole line is needed.""" + if not (self._interactive and self._open): + return False + click.echo(f"{detail}\n", nl=False) + self._open = False + return True + + def close_line(self) -> None: + """End a dangling prefix line before other output takes the next line.""" + if not self._open: + return + click.echo() + self._open = False + self._whole_lines = True + + def end_batch(self) -> None: + """Reset batch-scoped state once every call of the batch has a result.""" + self.close_line() + self._whole_lines = False + + def print_markdown(text: str, *, label: str = "assistant:") -> None: """Render *text* as markdown via Rich; *label* on its own line when set.""" from rich.console import Console @@ -490,10 +603,15 @@ async def render_events( # noqa: C901, PLR0912, PLR0915 content source changes (reasoning ↔ assistant, or tools/errors), the assistant markdown buffer is flushed so streams do not mix and Rich can re-render completed assistant segments. + + Pretty tool calls print their ``* Verb 'target' `` prefix as soon as the call + starts, so a blocking tool is visible while it runs; the outcome is appended + when the result lands (same bytes as the whole-line fallback). """ 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()) + tool_lines = _PrettyToolStream(interactive=interactive_terminal() and not show_full) source: StreamSource | None = None assistant_buf: list[str] = [] @@ -521,6 +639,7 @@ async def render_events( # noqa: C901, PLR0912, PLR0915 return if source == "assistant": flush_assistant() + tool_lines.close_line() click.echo() click.secho("reasoning:", fg="bright_black") source = "reasoning" @@ -533,6 +652,7 @@ async def render_events( # noqa: C901, PLR0912, PLR0915 if source == "reasoning": click.echo() # end reasoning stream line source = None + tool_lines.close_line() click.echo() click.secho("assistant:", fg="cyan") source = "assistant" @@ -554,26 +674,37 @@ async def render_events( # noqa: C901, PLR0912, PLR0915 args = call.function.arguments pretty_tool = name in _PRETTY_TOOLS pending_tools.append((name, args, pretty_tool)) - if not pretty_tool: + if pretty_tool: + tool_lines.start_call(_PRETTY_LINES[name].prefix(args)) + else: + tool_lines.close_line() preview = _preview(args, _TOOL_ARGS_PREVIEW) click.secho(f"\n[tool] {name}({preview})", fg="yellow") else: + tool_lines.close_line() pending_tools.append(("custom", call.id, False)) click.secho(f"\n[tool] custom id={call.id}", fg="yellow") elif isinstance(event, ToolResultEvent): flush_assistant() content = event.message.content name, args, pretty_tool = pending_tools.pop(0) if pending_tools else ("", "", False) - pretty_line = _pretty_tool_result(name, args, content) if pretty_tool else None - if pretty_line is not None and not show_full: - click.echo(f"\n{pretty_line}") + parts = _pretty_parts(name, args, content) if pretty_tool else None + if parts is not None and not show_full: + prefix, detail = parts + if not tool_lines.finish_call(detail): + click.echo(f"\n{prefix}{detail}") elif show_full: + tool_lines.close_line() click.secho(f"[tool ok]\n{content}", fg="magenta") else: + tool_lines.close_line() preview = _preview_result(content, _TOOL_RESULT_PREVIEW) click.secho(f"[tool ok] {preview}", fg="magenta") + if not pending_tools: + tool_lines.end_batch() elif isinstance(event, ErrorEvent): flush_assistant() + tool_lines.close_line() suffix = "" if event.source: suffix += f" source={event.source}" @@ -582,12 +713,14 @@ async def render_events( # noqa: C901, PLR0912, PLR0915 click.secho(f"\n[error]{suffix} {event.message}", fg="bright_red") elif isinstance(event, CancelledEvent): flush_assistant() + tool_lines.close_line() if event.reason: click.secho(f"\n[cancelled] {event.reason}", fg="yellow") else: click.secho("\n[cancelled]", fg="yellow") elif isinstance(event, MaxRoundsEvent): flush_assistant() + tool_lines.close_line() if event.continued: click.secho( f"\n[max rounds {event.rounds} reached — continuing with a higher allowance]", @@ -602,6 +735,7 @@ async def render_events( # noqa: C901, PLR0912, PLR0915 _ = event # End-of-turn: flush any open assistant segment; close reasoning stream. + tool_lines.close_line() if assistant_buf or printed_assistant: flush_assistant() elif printed_reasoning: diff --git a/tests/test_cli/test_display.py b/tests/test_cli/test_display.py index f6d7824..0054391 100644 --- a/tests/test_cli/test_display.py +++ b/tests/test_cli/test_display.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from plyngent.agent import ReasoningDeltaEvent, TextDeltaEvent, ToolCallEvent, ToolResultEvent +from plyngent.agent import ErrorEvent, ReasoningDeltaEvent, TextDeltaEvent, ToolCallEvent, ToolResultEvent from plyngent.cli.display import ( get_markdown_enabled, markdown_render_available, @@ -558,3 +558,94 @@ async def test_pretty_wait_error(capsys: pytest.CaptureFixture[str]) -> None: await render_events(_aiter([_pretty_call("wait", '{"duration": -1}'), _result(content)])) out = capsys.readouterr().out assert "* Wait (error: duration must not be negative)" in out + + +async def test_pretty_prefix_shown_while_call_runs( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """An interactive terminal shows the prefix before the tool has a result.""" + monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True) + while_running: list[str] = [] + + async def events() -> AsyncIterator[AgentEvent]: + yield _read_call('{"path": "a.txt"}') + while_running.append(capsys.readouterr().out) # tool still running here + yield _result("L1-4\none\ntwo\n") + + await render_events(events()) + assert while_running == ["\n* Read 'a.txt' "] + assert capsys.readouterr().out == "L1-4 (done)\n\n" + + +async def test_pretty_prefix_absent_when_not_a_tty(capsys: pytest.CaptureFixture[str]) -> None: + """Non-interactive output stays one whole-line write (pipes, logs, tests).""" + while_running: list[str] = [] + + async def events() -> AsyncIterator[AgentEvent]: + yield _read_call('{"path": "a.txt"}') + while_running.append(capsys.readouterr().out) + yield _result("L1-4\none\ntwo\n") + + await render_events(events()) + assert while_running == [""] + assert capsys.readouterr().out == "\n* Read 'a.txt' L1-4 (done)\n\n" + + +async def test_pretty_verbose_keeps_full_result_output( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Verbose mode stays a full log: no prefix line, whole result.""" + monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True) + set_verbose_tool_results(True) + try: + await render_events(_aiter([_read_call('{"path": "a.txt"}'), _result("L1-4\none\n")])) + finally: + set_verbose_tool_results(False) + out = capsys.readouterr().out + assert "[tool ok]\nL1-4\none\n" in out + assert "* Read 'a.txt' " not in out + + +async def test_pretty_parallel_batch_erases_open_prefix( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """A second call in the batch erases the open prefix; results print whole.""" + monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True) + await render_events( + _aiter( + [ + _pretty_call("read_file", '{"path": "a.txt"}'), + _pretty_call("read_file", '{"path": "b.txt"}'), + _result("L1-4\none\n"), + _result("L1-9\ntwo\n"), + ] + ) + ) + out = capsys.readouterr().out + assert "\x1b[2K\x1b[1A" in out # the in-flight prefix was erased + assert "* Read 'a.txt' L1-4 (done)\n" in out + assert "* Read 'b.txt' L1-9 (done)\n" in out + + +async def test_pretty_prefix_closed_before_error_line( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Another event ends an open prefix line instead of appending to it.""" + monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True) + await render_events( + _aiter( + [ + _read_call('{"path": "a.txt"}'), + ErrorEvent(message="boom", source="tool"), + _result("error: tool 'read_file' failed: boom"), + ] + ) + ) + out = capsys.readouterr().out + assert "* Read 'a.txt' \n" in out + assert "[error] source=tool boom\n" in out + assert "* Read 'a.txt' (error)\n" in out