From fb5b9f187b3b485426a5f0e469242c501915bfe4 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 18 Jul 2026 13:02:46 +0800 Subject: [PATCH] core/cli: /history last and --full with Rich markdown --- README.md | 3 +- src/plyngent/cli/slash.py | 167 ++++++++++++++++++++++++--- tests/test_cli/test_repl_commands.py | 59 +++++++++- 3 files changed, 208 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 0afcf75..00dfafc 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,8 @@ Type `/help` in the REPL for the live list. Common ones: | Command | Purpose | |---------|---------| | `/status` | Provider, session, context/usage estimates | -| `/history [n]` | Recent messages | +| `/history [n\|last]` | Recent messages (default preview; `last`/`1` = full + markdown) | +| `/history --full` | Full bodies for the selected window | | `/sessions` | Sessions for this workspace | | `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) | | `/export [md\|json] [path]` | Transcript from DB (no secrets) | diff --git a/src/plyngent/cli/slash.py b/src/plyngent/cli/slash.py index cbfdfd9..19f0e37 100644 --- a/src/plyngent/cli/slash.py +++ b/src/plyngent/cli/slash.py @@ -35,6 +35,40 @@ _ON_OFF_CHOICES = ("on", "off") _YOLO_MODE_CHOICES = ("on", "off", "once") _EXPORT_FORMAT_CHOICES = ("md", "json") + +class HistoryLimitType(click.ParamType): + """``N`` (int >= 1) or the shortcut ``last`` (equivalent to ``1``).""" + + name: str = "history_limit" + + @override + def convert(self, value: object, param: click.Parameter | None, ctx: click.Context | None) -> int: + if isinstance(value, int): + if value < 1: + self.fail("must be >= 1", param, ctx) + return value + text = str(value).strip().lower() + if text == "last": + return 1 + try: + n = int(text, 10) + except ValueError: + self.fail("expected an integer N or 'last'", param, ctx) + if n < 1: + self.fail("must be >= 1", param, ctx) + return n + + @override + def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]: + del ctx, param + tokens = ("last", "1", "5", "10", "20") + return [ + CompletionItem(token) + for token in tokens + if incomplete == "" or token.startswith(incomplete.lower()) + ] + + HELP_FOOTER = ( "User messages are saved immediately. On API errors or Ctrl+C, partial\n" "assistant/tool output is discarded but the user message stays (so /retry\n" @@ -677,10 +711,7 @@ def models_cmd(state: ReplState, *, refresh: bool, persist: bool) -> None: # no if persist: try: path = state.persist_models_to_config(mode="catalog", catalog_ids=remote or ()) - click.echo( - f"persisted {len(state.provider.models)} model(s) for " - f"{state.provider_name!r} to {path}" - ) + click.echo(f"persisted {len(state.provider.models)} model(s) for {state.provider_name!r} to {path}") except (KeyError, ValueError, OSError) as exc: click.secho(f"error: could not persist models: {exc}", fg="red") return @@ -858,27 +889,65 @@ def rounds_cmd(state: ReplState, n: int | None) -> None: @slash.command("history") -@click.argument("n", required=False, type=int) +@click.argument("n", required=False, type=HistoryLimitType()) +@click.option( + "--full", + "mode_full", + is_flag=True, + default=False, + help="Print full message bodies (markdown for assistant when TTY).", +) +@click.option( + "--preview", + "mode_preview", + is_flag=True, + default=False, + help="Force short previews even for a single message.", +) @click.pass_obj -def history_cmd(state: ReplState, n: int | None) -> None: - """Show last n messages in this session (default 20).""" - limit = _DEFAULT_HISTORY_LINES if n is None else n - if limit < 1: - msg = "n must be >= 1" +def history_cmd( + state: ReplState, + n: int | None, + *, + mode_full: bool, + mode_preview: bool, +) -> None: + """Show recent messages (default: last 20 as short previews). + + ``/history last`` or ``/history 1`` shows the last message in full with Rich + markdown when available. Use ``--full`` for multiple messages in full; + ``--preview`` forces the short form even for a single message. + """ + if mode_full and mode_preview: + msg = "use only one of --full / --preview" raise click.UsageError(msg) + limit = _DEFAULT_HISTORY_LINES if n is None else n messages = state.agent.messages if not messages: click.echo("(no messages in this session)") return start = max(0, len(messages) - limit) - click.echo(f"session={state.session_id} messages={len(messages)} showing={len(messages) - start}") - for offset, message in enumerate(messages[start:]): - click.echo(_format_history_message(start + offset, message)) + slice_msgs = messages[start:] + # Full: --full, or single-message window (last / 1) unless --preview. + full = mode_full or (len(slice_msgs) == 1 and not mode_preview) + mode_tag = "full" if full else "preview" + click.echo(f"session={state.session_id} messages={len(messages)} showing={len(slice_msgs)} mode={mode_tag}") + for offset, message in enumerate(slice_msgs): + idx = start + offset + if full: + _print_history_message_full(idx, message) + else: + click.echo(_format_history_message(idx, message)) if state.agent.pending_retry_text is not None: - click.secho( - f"(pending retry) user: {_preview_content(state.agent.pending_retry_text)}", - fg="yellow", - ) + pending = state.agent.pending_retry_text + if full: + click.secho("(pending retry) user:", fg="yellow") + click.echo(pending) + else: + click.secho( + f"(pending retry) user: {_preview_content(pending)}", + fg="yellow", + ) @slash.command("todos") @@ -972,6 +1041,70 @@ def _preview_content(text: str | None) -> str: return text[:_CONTENT_PREVIEW] + "…" +def _print_history_assistant_full(index: int, message: AssistantChatMessage) -> None: + from plyngent.cli.display import markdown_render_available, print_markdown + + click.secho(f"{index}. assistant:", fg="cyan") + content = message.content + has_text = isinstance(content, str) and content.strip() + if has_text and isinstance(content, str): + if markdown_render_available(): + print_markdown(content, label="") + else: + click.echo(content) + reasoning = message.reasoning_content + has_reason = isinstance(reasoning, str) and reasoning.strip() + if has_reason and isinstance(reasoning, str): + click.secho("reasoning:", fg="bright_black") + click.echo(reasoning) + tool_calls = message.tool_calls + has_tools = tool_calls is not UNSET and bool(tool_calls) + if has_tools and tool_calls is not UNSET: + for call in tool_calls: + if isinstance(call, AssistantFunctionToolCall): + click.secho( + f" tool_call {call.id}: {call.function.name}({call.function.arguments})", + fg="yellow", + ) + else: + click.secho(f" tool_call custom id={call.id}", fg="yellow") + if not (has_text or has_reason or has_tools): + click.echo("(empty)") + click.echo() + + +def _print_history_message_full(index: int, message: AnyChatMessage) -> None: + """Print one history message with full body; Rich markdown for assistant text.""" + if isinstance(message, UserChatMessage): + click.secho(f"{index}. user:", fg="green") + click.echo(message.content or "") + click.echo() + return + if isinstance(message, DeveloperChatMessage): + click.secho(f"{index}. developer:", fg="blue") + click.echo(message.content or "") + click.echo() + return + if isinstance(message, SystemChatMessage): + click.secho(f"{index}. system:", fg="bright_black") + click.echo(message.content or "") + click.echo() + return + if isinstance(message, AssistantChatMessage): + _print_history_assistant_full(index, message) + return + if isinstance(message, ToolChatMessage): + click.secho(f"{index}. tool({message.tool_call_id}):", fg="magenta") + click.echo(message.content or "") + click.echo() + return + role = getattr(message, "role", type(message).__name__) + content = getattr(message, "content", "") + click.echo(f"{index}. {role}:") + click.echo(str(content)) + click.echo() + + def _format_history_message(index: int, message: AnyChatMessage) -> str: if isinstance(message, UserChatMessage): return f"{index}. user: {_preview_content(message.content)}" diff --git a/tests/test_cli/test_repl_commands.py b/tests/test_cli/test_repl_commands.py index 32982eb..a724fe6 100644 --- a/tests/test_cli/test_repl_commands.py +++ b/tests/test_cli/test_repl_commands.py @@ -109,9 +109,11 @@ async def test_help_command_usage_line(state: ReplState, capsys: pytest.CaptureF async def test_help_history_no_fake_options(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None: assert await handle_slash(state, "/help history") is True out = capsys.readouterr().out - assert "Usage: /history [N]" in out - assert "Options:" not in out - assert "--help" not in out + assert "Usage: /history" in out + # Real flags for full/preview are documented; Click's meta --help is not. + assert "--full" in out + assert "--preview" in out + assert " --help" not in out async def test_help_stream_clearer(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None: @@ -444,10 +446,61 @@ async def test_history(state: ReplState, capsys: pytest.CaptureFixture[str]) -> ] assert await handle_slash(state, "/history") is True out = capsys.readouterr().out + assert "mode=preview" in out assert "user: hello" in out assert "assistant: hi there" in out +async def test_history_last_full(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None: + from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage + + long_body = "# Title\n\n" + ("paragraph text. " * 20) + state.agent.messages = [ + UserChatMessage(content="hello"), + AssistantChatMessage(content=long_body), + ] + assert await handle_slash(state, "/history last") is True + out = capsys.readouterr().out + assert "mode=full" in out + assert "assistant:" in out + # Full mode should not use the 200-char preview ellipsis on the body alone + assert long_body[:50] in out or "Title" in out + + +async def test_history_one_full(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None: + from plyngent.lmproto.openai_compatible.model import UserChatMessage + + state.agent.messages = [UserChatMessage(content="only me")] + assert await handle_slash(state, "/history 1") is True + out = capsys.readouterr().out + assert "mode=full" in out + assert "only me" in out + + +async def test_history_one_preview_flag(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None: + from plyngent.lmproto.openai_compatible.model import UserChatMessage + + state.agent.messages = [UserChatMessage(content="x" * 250)] + assert await handle_slash(state, "/history 1 --preview") is True + out = capsys.readouterr().out + assert "mode=preview" in out + assert "…" in out + + +async def test_history_full_flag(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None: + from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage + + body = "full " * 100 + state.agent.messages = [ + UserChatMessage(content="u"), + AssistantChatMessage(content=body), + ] + assert await handle_slash(state, "/history 2 --full") is True + out = capsys.readouterr().out + assert "mode=full" in out + assert body[:40] in out + + async def test_rounds(state: ReplState) -> None: assert await handle_slash(state, "/rounds 40") is True assert state.max_rounds == 40