core/cli: /history last and --full with Rich markdown

This commit is contained in:
2026-07-18 13:02:46 +08:00
parent abcb345c0d
commit fb5b9f187b
3 changed files with 208 additions and 21 deletions
+2 -1
View File
@@ -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) |
+150 -17
View File
@@ -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)}"
+56 -3
View File
@@ -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