mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/cli: /history last and --full with Rich markdown
This commit is contained in:
@@ -188,7 +188,8 @@ Type `/help` in the REPL for the live list. Common ones:
|
|||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `/status` | Provider, session, context/usage estimates |
|
| `/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 |
|
| `/sessions` | Sessions for this workspace |
|
||||||
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
|
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
|
||||||
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
|
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
|
||||||
|
|||||||
+147
-14
@@ -35,6 +35,40 @@ _ON_OFF_CHOICES = ("on", "off")
|
|||||||
_YOLO_MODE_CHOICES = ("on", "off", "once")
|
_YOLO_MODE_CHOICES = ("on", "off", "once")
|
||||||
_EXPORT_FORMAT_CHOICES = ("md", "json")
|
_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 = (
|
HELP_FOOTER = (
|
||||||
"User messages are saved immediately. On API errors or Ctrl+C, partial\n"
|
"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"
|
"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:
|
if persist:
|
||||||
try:
|
try:
|
||||||
path = state.persist_models_to_config(mode="catalog", catalog_ids=remote or ())
|
path = state.persist_models_to_config(mode="catalog", catalog_ids=remote or ())
|
||||||
click.echo(
|
click.echo(f"persisted {len(state.provider.models)} model(s) for {state.provider_name!r} to {path}")
|
||||||
f"persisted {len(state.provider.models)} model(s) for "
|
|
||||||
f"{state.provider_name!r} to {path}"
|
|
||||||
)
|
|
||||||
except (KeyError, ValueError, OSError) as exc:
|
except (KeyError, ValueError, OSError) as exc:
|
||||||
click.secho(f"error: could not persist models: {exc}", fg="red")
|
click.secho(f"error: could not persist models: {exc}", fg="red")
|
||||||
return
|
return
|
||||||
@@ -858,25 +889,63 @@ def rounds_cmd(state: ReplState, n: int | None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@slash.command("history")
|
@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
|
@click.pass_obj
|
||||||
def history_cmd(state: ReplState, n: int | None) -> None:
|
def history_cmd(
|
||||||
"""Show last n messages in this session (default 20)."""
|
state: ReplState,
|
||||||
limit = _DEFAULT_HISTORY_LINES if n is None else n
|
n: int | None,
|
||||||
if limit < 1:
|
*,
|
||||||
msg = "n must be >= 1"
|
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)
|
raise click.UsageError(msg)
|
||||||
|
limit = _DEFAULT_HISTORY_LINES if n is None else n
|
||||||
messages = state.agent.messages
|
messages = state.agent.messages
|
||||||
if not messages:
|
if not messages:
|
||||||
click.echo("(no messages in this session)")
|
click.echo("(no messages in this session)")
|
||||||
return
|
return
|
||||||
start = max(0, len(messages) - limit)
|
start = max(0, len(messages) - limit)
|
||||||
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(messages) - start}")
|
slice_msgs = messages[start:]
|
||||||
for offset, message in enumerate(messages[start:]):
|
# Full: --full, or single-message window (last / 1) unless --preview.
|
||||||
click.echo(_format_history_message(start + offset, message))
|
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:
|
if state.agent.pending_retry_text is not None:
|
||||||
|
pending = state.agent.pending_retry_text
|
||||||
|
if full:
|
||||||
|
click.secho("(pending retry) user:", fg="yellow")
|
||||||
|
click.echo(pending)
|
||||||
|
else:
|
||||||
click.secho(
|
click.secho(
|
||||||
f"(pending retry) user: {_preview_content(state.agent.pending_retry_text)}",
|
f"(pending retry) user: {_preview_content(pending)}",
|
||||||
fg="yellow",
|
fg="yellow",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -972,6 +1041,70 @@ def _preview_content(text: str | None) -> str:
|
|||||||
return text[:_CONTENT_PREVIEW] + "…"
|
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:
|
def _format_history_message(index: int, message: AnyChatMessage) -> str:
|
||||||
if isinstance(message, UserChatMessage):
|
if isinstance(message, UserChatMessage):
|
||||||
return f"{index}. user: {_preview_content(message.content)}"
|
return f"{index}. user: {_preview_content(message.content)}"
|
||||||
|
|||||||
@@ -109,8 +109,10 @@ 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:
|
async def test_help_history_no_fake_options(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
assert await handle_slash(state, "/help history") is True
|
assert await handle_slash(state, "/help history") is True
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Usage: /history [N]" in out
|
assert "Usage: /history" in out
|
||||||
assert "Options:" not 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
|
assert " --help" not in out
|
||||||
|
|
||||||
|
|
||||||
@@ -444,10 +446,61 @@ async def test_history(state: ReplState, capsys: pytest.CaptureFixture[str]) ->
|
|||||||
]
|
]
|
||||||
assert await handle_slash(state, "/history") is True
|
assert await handle_slash(state, "/history") is True
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
|
assert "mode=preview" in out
|
||||||
assert "user: hello" in out
|
assert "user: hello" in out
|
||||||
assert "assistant: hi there" 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:
|
async def test_rounds(state: ReplState) -> None:
|
||||||
assert await handle_slash(state, "/rounds 40") is True
|
assert await handle_slash(state, "/rounds 40") is True
|
||||||
assert state.max_rounds == 40
|
assert state.max_rounds == 40
|
||||||
|
|||||||
Reference in New Issue
Block a user