From 5b0c9be90b322dae68c0a87e7dceb885fa29f3e0 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Wed, 15 Jul 2026 13:03:55 +0800 Subject: [PATCH] core/cli: Click slash registry with awaitlet bridge Register REPL slash commands as a Click Group (params, UsageError, help). Dispatch via awaitlet.async_def so sync command bodies can awaitlet() async memory/confirm work. Completer and /help derive from the registry. --- CLAUDE.md | 20 +- pdm.lock | 20 +- pyproject.toml | 3 +- src/plyngent/cli/readline_setup.py | 32 +- src/plyngent/cli/repl.py | 456 +-------------------- src/plyngent/cli/slash.py | 551 ++++++++++++++++++++++++++ tests/test_cli/test_readline_setup.py | 9 +- tests/test_cli/test_repl_commands.py | 2 +- 8 files changed, 604 insertions(+), 489 deletions(-) create mode 100644 src/plyngent/cli/slash.py diff --git a/CLAUDE.md b/CLAUDE.md index 107eb09..3122f73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`. - **`plyngent chat`**: provider/model selection (flags or interactive), SQLite sessions via config `[database]` (file DB under user data if unset/`:memory:`), sessions bound to workspace dir; resumes **most recently updated** session for cwd/`--workspace` by default (`--new` / `--session`). -- Slash: `/history`, `/sessions` (newest first), `/resume [id]`, `/rename`, `/delete` (confirm), `/export md|json`, `/compact`, `/status`, `/rounds`, `/stream`, `/verbose`, `/retry`, … +- Slash commands: Click group in `cli/slash.py` (params/help/`UsageError`); dispatch via `awaitlet.async_def` + `awaitlet()` for async work. Completer reads `slash.list_commands`. - Explicit `/resume` or `--session` from another workspace prompts: **keep** session path, **update** binding to current, or **abort**. - Failed/cancelled turns: user message kept in DB; partial assistant/tool rolled back; Ctrl+C cancels in-flight turn; **TTY confirms** off-loop; auto-retry 10s/20s/30s then `/retry` (no duplicate user message). - **`plyngent providers`**: list config providers. @@ -107,7 +107,23 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i - **No local tokenizer stage** for now. - **Phase E**: tooling depth (grep/glob, VCS backends; prefer `edit_replace` / `edit_lineno` over model-generated patches). - **Phase F (providers + usage v2)**: API `usage` + char-based estimate fallback; session/turn totals; `/status` + end-of-turn. Optional later: cost, real tokenizer. -- **Phase G (CLI polish + hardening)**: shared `prompting` (`ask`/`choose`/`form`) + chat tools; then display/session/export/multiline/one-shot; agent/tools hardening; README. Multi-tenant stays Phase H. +- **Phase G (CLI polish + hardening)** — single-user only; multi-tenant stays Phase H. + + **Done** + - G0: `prompting` (`ask`/`choose`/`form`/`confirm`) + chat tools (`ask_user`/`choose_user`/`form_user`) + - G1: `ReasoningDeltaEvent`, `/stream`, `/verbose` + - G2: `/rename`, `/delete` (confirm), `/export md|json` + - G2.5: Click slash registry (`cli/slash.py`) + `awaitlet` for sync Click / async memory; auto `/help`; completer from `slash.list_commands` + + **Planned** + + - **G3 — Input**: multiline (`"""` … `"""`) and/or `/edit` via `$EDITOR` + - **G4 — One-shot**: `plyngent chat -p/--prompt` (and stdin non-TTY); exit codes; non-interactive safety (`--yes` / deny destructive) + - **G5 — Hardening**: tool timeouts/defaults review, config error clarity, cancel edges, optional `--log-level`, no secrets in status/export + - **G6 — Docs**: real README, example TOML, CLAUDE/help accuracy + + Milestone order: G3 → G4 → G5 → G6. + - **Phase H**: multi-tenant platform (`router/`, auth, sandboxed tools, web). ## Commit messages diff --git a/pdm.lock b/pdm.lock index 44b99e1..7489e8a 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:a27f943f7f5204047e0fe0e99b1080301877e231ec01cabee48c3a79473ca676" +content_hash = "sha256:cd5131c309485626148d1933a2a557a9cb6a41038b980bc26a453def4c38032c" [[metadata.targets]] requires_python = "~=3.14" @@ -21,6 +21,21 @@ files = [ {file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"}, ] +[[package]] +name = "awaitlet" +version = "0.0.1" +requires_python = ">=3.8" +summary = "Allow non-async defs that invoke awaitables inside of asyncio applications" +groups = ["default"] +dependencies = [ + "greenlet>=1", + "typing-extensions>=4.6.0", +] +files = [ + {file = "awaitlet-0.0.1-py3-none-any.whl", hash = "sha256:255b1c086f2fbfef2d8d88b66acae8636418d38bcfe93a897ccd8234a7b8bf6d"}, + {file = "awaitlet-0.0.1.tar.gz", hash = "sha256:d98c8b821f595284f9327d45067995ccbee0d99993f0d0b935b23ac8ecf58334"}, +] + [[package]] name = "basedpyright" version = "1.39.9" @@ -98,7 +113,7 @@ version = "0.4.6" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Cross-platform colored terminal text." groups = ["default", "dev"] -marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" +marker = "platform_system == \"Windows\" or sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -110,7 +125,6 @@ version = "3.5.3" requires_python = ">=3.10" summary = "Lightweight in-process concurrent programming" groups = ["default"] -marker = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\"" files = [ {file = "greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117"}, {file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8"}, diff --git a/pyproject.toml b/pyproject.toml index 7728039..d846e12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,8 @@ dependencies = [ "tomlkit>=0.15.0", "sqlalchemy>=2.0.51", "aiosqlite>=0.22.1", - "click>=8.4.2" + "click>=8.4.2", + "awaitlet>=0.0.1" ] requires-python = ">=3.14,<4" readme = "README.md" diff --git a/src/plyngent/cli/readline_setup.py b/src/plyngent/cli/readline_setup.py index 4a8aed6..bbe0bad 100644 --- a/src/plyngent/cli/readline_setup.py +++ b/src/plyngent/cli/readline_setup.py @@ -15,33 +15,17 @@ if TYPE_CHECKING: HISTORY_FILE_NAME = "repl_history" DEFAULT_HISTORY_LENGTH = 1000 -SLASH_COMMANDS: tuple[str, ...] = ( - "/help", - "/quit", - "/exit", - "/clear", - "/history", - "/sessions", - "/new", - "/resume", - "/rename", - "/delete", - "/export", - "/compact", - "/provider", - "/model", - "/tools", - "/stream", - "/verbose", - "/rounds", - "/retry", - "/status", -) - _ON_OFF_ARGS: tuple[str, ...] = ("on", "off") _EXPORT_ARGS: tuple[str, ...] = ("md", "json") +def slash_commands() -> list[str]: + """Slash command tokens for Tab completion (with leading /).""" + from plyngent.cli.slash import slash_command_names + + return slash_command_names() + + def history_path() -> Path: return user_data_path("plyngent", ensure_exists=True) / HISTORY_FILE_NAME @@ -63,7 +47,7 @@ def build_completer(state: ReplState) -> Callable[[str, int], str | None]: begidx = readline.get_begidx() # Completing the first token (command). if begidx == 0 or (begidx > 0 and buffer[:begidx].strip() == ""): - options = filter_prefix(text, list(SLASH_COMMANDS)) + options = filter_prefix(text, slash_commands()) else: head = buffer[:begidx].strip() command = head.split()[0] if head else "" diff --git a/src/plyngent/cli/repl.py b/src/plyngent/cli/repl.py index 3fabfc5..2d15b2c 100644 --- a/src/plyngent/cli/repl.py +++ b/src/plyngent/cli/repl.py @@ -1,467 +1,15 @@ from __future__ import annotations -import inspect -from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING import click -from msgspec import UNSET from plyngent.cli.readline_setup import setup_readline -from plyngent.cli.retry import retry_pending_with_retries, run_user_text_with_retries -from plyngent.cli.selection import select_model, select_provider -from plyngent.lmproto.openai_compatible.model import ( - AssistantChatMessage, - AssistantFunctionToolCall, - ToolChatMessage, - UserChatMessage, -) -from plyngent.runtime import ProviderNotSupportedError +from plyngent.cli.retry import run_user_text_with_retries +from plyngent.cli.slash import handle_slash if TYPE_CHECKING: from plyngent.cli.state import ReplState - from plyngent.lmproto.openai_compatible.model import AnyChatMessage - -HELP_TEXT = """\ -Commands: - /help Show this help - /quit, /exit Leave the REPL - /clear Clear in-memory conversation (keeps session id) - /history [n] Show last n messages in this session (default 20) - /sessions List sessions for this workspace (newest first) - /new [name] Start a new session (bound to workspace) - /resume [id] Resume session id, or latest for this workspace if omitted - /rename Rename the current session - /delete [id] Hard-delete a session (confirm; current → new empty) - /export [md|json] [path] Export session transcript from DB - /compact [name] Soft-compact + model-summarize into a new session - /provider [name] Show or switch provider - /model [id] Show or switch model - /tools [on|off] Show or toggle tools - /stream [on|off] Show or toggle streaming model output - /verbose [on|off] Show or toggle full tool-result dumps - /rounds [n] Show or set max tool-loop rounds - /retry Re-run incomplete last user turn (DB/orphan user; no retype) - /status Show session/provider/tools/rounds status - -User messages are saved immediately. On API errors or Ctrl+C, partial -assistant/tool output is discarded but the user message stays (so /retry -works after resume, not only via readline history). Auto-retry: 10s/20s/30s. - -Tab completes slash commands and some arguments (provider, model, tools, -stream, verbose). Use --session ID or /resume to continue a prior chat -after restart. -""" - -type SlashHandler = Callable[[], None | Awaitable[None]] - -_DEFAULT_HISTORY_LINES = 20 -_CONTENT_PREVIEW = 200 - - -def _cmd_status(state: ReplState) -> None: - from plyngent.agent.budget import estimate_messages_chars - - pending = state.agent.pending_retry_text - pending_disp = "yes" if pending else "no" - ctx_chars = estimate_messages_chars(state.agent.messages) - ctx_tokens = state.agent.context_tokens - ctx_src = state.agent.context_tokens_source - ctx_budget = state.agent.max_context_tokens - session_u = state.agent.session_usage - last_u = state.agent.last_turn_usage - last_req = state.agent.last_request_usage - last_rounds = state.agent.last_turn_rounds - # API prompt_tokens from the last model call is real context size for that request. - ctx_tag = "api" if ctx_src == "api" else "est" - ctx_tilde = "" if ctx_src == "api" else "~" - click.echo( - f"provider={state.provider_name} model={state.model}\n" - f"session={state.session_id} messages={len(state.agent.messages)} " - f"pending_retry={pending_disp}\n" - f"tools={'on' if state.tools_enabled else 'off'} " - f"rounds={state.max_rounds} " - f"stream={'on' if state.agent.stream else 'off'} " - f"verbose={'on' if state.verbose else 'off'}\n" - f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) " - f"context_chars={ctx_chars} " - f"tool_result_max={state.agent.max_tool_result_chars}\n" - f"last_request={last_req.format_line()}\n" - f"usage_last_turn={last_u.format_line(billed=True)} " - f"rounds={last_rounds}\n" - f"usage_session={session_u.format_line(billed=True)}\n" - f"workspace={state.workspace}" - ) - - -async def _cmd_sessions(state: ReplState) -> None: - sessions = await state.memory.list_sessions(workspace=state.workspace) - if not sessions: - click.echo(f"(no sessions for workspace {state.workspace})") - return - for session in sessions: - marker = "*" if session.sid == state.session_id else " " - ws = session.workspace or "(unbound)" - click.echo(f"{marker} {session.sid}\t{session.name}\tworkspace={ws}\tupdated={session.updated_at}") - - -async def _cmd_new(state: ReplState, arg: str) -> None: - name = arg.strip() or "chat" - await state.new_session(name=name) - click.echo(f"new session id={state.session_id} name={name}") - - -async def _cmd_rename(state: ReplState, arg: str) -> None: - name = arg.strip() - if not name: - click.echo("usage: /rename ") - return - try: - row = await state.rename_current_session(name) - except ValueError as exc: - click.echo(f"error: {exc}") - return - click.echo(f"renamed session {row.sid} -> {row.name}") - - -async def _cmd_delete(state: ReplState, arg: str) -> None: - from plyngent.prompting import NonInteractiveError, confirm_async - - token = arg.strip() - if token: - try: - sid = int(token) - except ValueError: - click.echo("usage: /delete [session id]") - return - else: - if state.session_id is None: - click.echo("error: no active session") - return - sid = state.session_id - try: - allowed = await confirm_async( - f"Permanently delete session {sid} and all messages?", - default=False, - ) - except NonInteractiveError: - click.echo("error: delete requires interactive confirm (or TTY)") - return - if not allowed: - click.echo("delete cancelled") - return - try: - was_current = await state.delete_session_and_maybe_replace(sid) - except ValueError as exc: - click.echo(f"error: {exc}") - return - if was_current: - click.echo(f"deleted session {sid}; new session {state.session_id}") - else: - click.echo(f"deleted session {sid}") - - -async def _cmd_export(state: ReplState, arg: str) -> None: - from plyngent.cli.export import ( - encode_session_export_json, - format_session_export_md, - resolve_export_path, - session_export_payload, - write_export_file, - ) - - if state.session_id is None: - click.echo("error: no active session") - return - parts = arg.split() - fmt = "md" - path_arg: str | None = None - if parts: - first = parts[0].lower() - if first in {"md", "markdown", "json"}: - fmt = "json" if first == "json" else "md" - path_arg = parts[1] if len(parts) > 1 else None - else: - path_arg = parts[0] - if len(parts) > 1: - click.echo("usage: /export [md|json] [path]") - return - row = await state.memory.get_session(state.session_id) - if row is None: - click.echo(f"error: session not found: {state.session_id}") - return - messages = await state.memory.list_messages(state.session_id) - out_path = resolve_export_path(state.session_id, fmt, path_arg) - if fmt == "json": - text = encode_session_export_json( - session_export_payload( - sid=row.sid, - name=row.name, - workspace=row.workspace, - created_at=row.created_at, - updated_at=row.updated_at, - messages=messages, - ) - ) - else: - text = format_session_export_md( - messages, - sid=row.sid, - name=row.name, - workspace=row.workspace, - ) - try: - written = write_export_file(out_path, text) - except OSError as exc: - click.echo(f"error: write failed: {exc}") - return - click.echo(f"exported session {row.sid} ({fmt}) -> {written}") - - -async def _cmd_resume(state: ReplState, arg: str) -> None: - if not arg.strip(): - mode = await state.resume_latest_or_new() - if mode == "new": - click.echo(f"no prior session; created new session {state.session_id}") - else: - click.echo( - f"resumed latest session {state.session_id} " - f"({len(state.agent.messages)} messages) workspace={state.workspace}" - ) - return - try: - session_id = int(arg.strip()) - except ValueError: - click.echo("session id must be an integer (or omit for latest)") - return - try: - await state.resume_session(session_id) - except ValueError as exc: - click.echo(f"error: {exc}") - return - click.echo(f"resumed session {session_id} ({len(state.agent.messages)} messages) workspace={state.workspace}") - - -async def _cmd_compact(state: ReplState, arg: str) -> None: - name = arg.strip() or None - click.secho("compacting (soft-compact + model summary)…", fg="yellow") - try: - old_id, new_id, summary = await state.compact_to_new_session(name=name) - except ValueError as exc: - click.echo(f"error: {exc}") - return - except Exception as exc: # noqa: BLE001 — surface model/API failures - click.secho(f"error: compact failed: {exc}", fg="red") - return - preview = summary if len(summary) <= 400 else summary[:400] + "…" # noqa: PLR2004 - click.echo(f"compacted session {old_id} -> new session {new_id}") - click.secho(preview, fg="bright_black") - - -def _cmd_provider(state: ReplState, arg: str) -> None: - if not arg.strip(): - click.echo(f"provider={state.provider_name}") - return - try: - name, provider = select_provider(state.config.providers, preferred=arg.strip()) - state.provider_name = name - state.provider = provider - state.rebuild_client() - click.echo(f"switched provider to {name}") - except (click.ClickException, ProviderNotSupportedError) as exc: - click.echo(f"error: {exc}") - - -def _cmd_model(state: ReplState, arg: str) -> None: - if not arg.strip(): - click.echo(f"model={state.model}") - return - try: - state.model = select_model(state.provider, preferred=arg.strip()) - state.rebuild_client() - click.echo(f"switched model to {state.model}") - except click.ClickException as exc: - click.echo(f"error: {exc}") - - -def _cmd_tools(state: ReplState, arg: str) -> None: - token = arg.strip().lower() - if not token: - click.echo(f"tools={'on' if state.tools_enabled else 'off'}") - return - if token in {"on", "1", "true", "yes"}: - state.tools_enabled = True - elif token in {"off", "0", "false", "no"}: - state.tools_enabled = False - else: - click.echo("usage: /tools [on|off]") - return - state.rebuild_client() - click.echo(f"tools={'on' if state.tools_enabled else 'off'}") - - -def _cmd_stream(state: ReplState, arg: str) -> None: - token = arg.strip().lower() - if not token: - click.echo(f"stream={'on' if state.agent.stream else 'off'}") - return - if token in {"on", "1", "true", "yes"}: - enabled = True - elif token in {"off", "0", "false", "no"}: - enabled = False - else: - click.echo("usage: /stream [on|off]") - return - state.stream_enabled = enabled - state.agent.stream = enabled - click.echo(f"stream={'on' if enabled else 'off'}") - - -def _cmd_verbose(state: ReplState, arg: str) -> None: - token = arg.strip().lower() - if not token: - click.echo(f"verbose={'on' if state.verbose else 'off'}") - return - if token in {"on", "1", "true", "yes"}: - enabled = True - elif token in {"off", "0", "false", "no"}: - enabled = False - else: - click.echo("usage: /verbose [on|off]") - return - state.verbose = enabled - state.sync_display_flags() - click.echo(f"verbose={'on' if enabled else 'off'}") - - -def _cmd_rounds(state: ReplState, arg: str) -> None: - token = arg.strip() - if not token: - click.echo(f"max_rounds={state.max_rounds}") - return - try: - value = int(token) - except ValueError: - click.echo("usage: /rounds ") - return - if value < 1: - click.echo("max_rounds must be >= 1") - return - state.max_rounds = value - state.agent.max_rounds = value - click.echo(f"max_rounds={state.max_rounds}") - - -def _cmd_clear(state: ReplState) -> None: - state.agent.messages.clear() - click.echo("conversation cleared (in-memory only; DB history kept)") - - -def _preview_content(text: str | None) -> str: - if not text: - return "" - if len(text) <= _CONTENT_PREVIEW: - return text - return text[:_CONTENT_PREVIEW] + "…" - - -def _format_history_message(index: int, message: AnyChatMessage) -> str: - if isinstance(message, UserChatMessage): - return f"{index}. user: {_preview_content(message.content)}" - if isinstance(message, AssistantChatMessage): - parts: list[str] = [] - if isinstance(message.content, str) and message.content: - parts.append(_preview_content(message.content)) - tool_calls = message.tool_calls - if tool_calls is not UNSET and tool_calls: - names: list[str] = [] - for call in tool_calls: - if isinstance(call, AssistantFunctionToolCall): - names.append(call.function.name) - else: - names.append("custom") - parts.append(f"tool_calls=[{', '.join(names)}]") - body = " ".join(parts) if parts else "(empty)" - return f"{index}. assistant: {body}" - if isinstance(message, ToolChatMessage): - return f"{index}. tool({message.tool_call_id}): {_preview_content(message.content)}" - role = getattr(message, "role", type(message).__name__) - content = getattr(message, "content", "") - return f"{index}. {role}: {_preview_content(str(content))}" - - -def _cmd_history(state: ReplState, arg: str) -> None: - token = arg.strip() - limit = _DEFAULT_HISTORY_LINES - if token: - try: - limit = int(token) - except ValueError: - click.echo("usage: /history [n]") - return - if limit < 1: - click.echo("n must be >= 1") - return - 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)) - if state.agent.pending_retry_text is not None: - click.secho( - f"(pending retry) user: {_preview_content(state.agent.pending_retry_text)}", - fg="yellow", - ) - - -async def _cmd_retry(state: ReplState) -> None: - _ = await retry_pending_with_retries(state.agent) - - -async def _dispatch_slash(state: ReplState, command: str, arg: str) -> bool: - if command in {"quit", "exit", "q"}: - return False - - handlers: dict[str, SlashHandler] = { - "help": lambda: click.echo(HELP_TEXT), - "clear": lambda: _cmd_clear(state), - "history": lambda: _cmd_history(state, arg), - "sessions": lambda: _cmd_sessions(state), - "new": lambda: _cmd_new(state, arg), - "rename": lambda: _cmd_rename(state, arg), - "delete": lambda: _cmd_delete(state, arg), - "export": lambda: _cmd_export(state, arg), - "resume": lambda: _cmd_resume(state, arg), - "compact": lambda: _cmd_compact(state, arg), - "provider": lambda: _cmd_provider(state, arg), - "model": lambda: _cmd_model(state, arg), - "tools": lambda: _cmd_tools(state, arg), - "stream": lambda: _cmd_stream(state, arg), - "verbose": lambda: _cmd_verbose(state, arg), - "rounds": lambda: _cmd_rounds(state, arg), - "retry": lambda: _cmd_retry(state), - "status": lambda: _cmd_status(state), - } - handler = handlers.get(command) - if handler is None: - click.echo(f"unknown command /{command}; try /help") - return True - result = handler() - if inspect.isawaitable(result): - await result - return True - - -async def handle_slash(state: ReplState, line: str) -> bool: - """Handle a slash command. Returns False if the REPL should exit.""" - body = line[1:].strip() - if not body: - return True - command, _, rest = body.partition(" ") - return await _dispatch_slash(state, command.lower(), rest.strip()) def _read_line() -> str: diff --git a/src/plyngent/cli/slash.py b/src/plyngent/cli/slash.py new file mode 100644 index 0000000..84ad986 --- /dev/null +++ b/src/plyngent/cli/slash.py @@ -0,0 +1,551 @@ +from __future__ import annotations + +import shlex +from typing import TYPE_CHECKING, Any, override + +import awaitlet # pyright: ignore[reportMissingTypeStubs] +import click +from msgspec import UNSET + +from plyngent.cli.retry import retry_pending_with_retries +from plyngent.cli.selection import select_model, select_provider +from plyngent.lmproto.openai_compatible.model import ( + AssistantChatMessage, + AssistantFunctionToolCall, + ToolChatMessage, + UserChatMessage, +) +from plyngent.runtime import ProviderNotSupportedError + +if TYPE_CHECKING: + from collections.abc import Awaitable, Sequence + + from plyngent.cli.state import ReplState + from plyngent.lmproto.openai_compatible.model import AnyChatMessage + +_DEFAULT_HISTORY_LINES = 20 +_CONTENT_PREVIEW = 200 +_COMPACT_PREVIEW = 400 + +HELP_FOOTER = """\ +User messages are saved immediately. On API errors or Ctrl+C, partial +assistant/tool output is discarded but the user message stays (so /retry +works after resume, not only via readline history). Auto-retry: 10s/20s/30s. + +Tab completes slash commands and some arguments (provider, model, tools, +stream, verbose, export). Use --session ID or /resume to continue a prior +chat after restart. +""" + + +class ReplExitError(Exception): + """Signal that the REPL should leave (not a process exit).""" + + +class OnOffParam(click.ParamType[bool]): + """Accept on/off (and common synonyms); convert to bool.""" + + name: str = "on_off" + + @override + def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> bool: + if isinstance(value, bool): + return value + token = str(value).strip().lower() + if token in {"on", "1", "true", "yes"}: + return True + if token in {"off", "0", "false", "no"}: + return False + self.fail("expected on or off", param, ctx) + return False + + +ON_OFF = OnOffParam() + + +class SlashGroup(click.Group): + """Click group for REPL slash commands (no process-level ownership).""" + + @override + def get_help(self, ctx: click.Context) -> str: + lines = ["Commands:"] + for name in sorted(self.list_commands(ctx)): + cmd = self.get_command(ctx, name) + if cmd is None or cmd.hidden: + continue + brief = cmd.get_short_help_str(limit=60) + lines.append(f" /{name:<16} {brief}") + lines.append("") + lines.append(HELP_FOOTER.rstrip()) + return "\n".join(lines) + + +slash = SlashGroup( + "slash", + help="REPL slash commands", + context_settings={"help_option_names": [], "max_content_width": 100}, +) + + +def slash_command_names() -> list[str]: + """Slash tokens for Tab completion (including leading /).""" + ctx = click.Context(slash) + return sorted(f"/{name}" for name in slash.list_commands(ctx)) + + +def _await[T](awaitable: Awaitable[T]) -> T: + # Greenlet parks until the awaitable completes on the running loop. + return awaitlet.awaitlet(awaitable) # pyright: ignore[reportUnknownVariableType] + + +# --- commands ----------------------------------------------------------------- + + +@slash.command("help") +@click.argument("command", required=False) +@click.pass_context +def help_cmd(ctx: click.Context, command: str | None) -> None: + """Show this help, or help for one command.""" + if command: + name = command.lstrip("/").lower() + cmd = slash.get_command(ctx, name) + if cmd is None: + click.echo(f"unknown command /{name}; try /help") + return + # Build a child context so get_help includes usage. + with click.Context(cmd, info_name=name, parent=ctx) as sub: + click.echo(cmd.get_help(sub)) + return + click.echo(slash.get_help(ctx)) + + +@slash.command("quit") +@click.pass_obj +def quit_cmd(_state: ReplState) -> None: + """Leave the REPL.""" + raise ReplExitError + + +slash.add_command(quit_cmd, name="exit") +slash.add_command(quit_cmd, name="q") + + +@slash.command("clear") +@click.pass_obj +def clear_cmd(state: ReplState) -> None: + """Clear in-memory conversation (keeps session id).""" + state.agent.messages.clear() + click.echo("conversation cleared (in-memory only; DB history kept)") + + +@slash.command("status") +@click.pass_obj +def status_cmd(state: ReplState) -> None: + """Show session/provider/tools/rounds status.""" + from plyngent.agent.budget import estimate_messages_chars + + pending = state.agent.pending_retry_text + pending_disp = "yes" if pending else "no" + ctx_chars = estimate_messages_chars(state.agent.messages) + ctx_tokens = state.agent.context_tokens + ctx_src = state.agent.context_tokens_source + ctx_budget = state.agent.max_context_tokens + session_u = state.agent.session_usage + last_u = state.agent.last_turn_usage + last_req = state.agent.last_request_usage + last_rounds = state.agent.last_turn_rounds + ctx_tag = "api" if ctx_src == "api" else "est" + ctx_tilde = "" if ctx_src == "api" else "~" + click.echo( + f"provider={state.provider_name} model={state.model}\n" + f"session={state.session_id} messages={len(state.agent.messages)} " + f"pending_retry={pending_disp}\n" + f"tools={'on' if state.tools_enabled else 'off'} " + f"rounds={state.max_rounds} " + f"stream={'on' if state.agent.stream else 'off'} " + f"verbose={'on' if state.verbose else 'off'}\n" + f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) " + f"context_chars={ctx_chars} " + f"tool_result_max={state.agent.max_tool_result_chars}\n" + f"last_request={last_req.format_line()}\n" + f"usage_last_turn={last_u.format_line(billed=True)} " + f"rounds={last_rounds}\n" + f"usage_session={session_u.format_line(billed=True)}\n" + f"workspace={state.workspace}" + ) + + +@slash.command("sessions") +@click.pass_obj +def sessions_cmd(state: ReplState) -> None: + """List sessions for this workspace (newest first).""" + sessions = _await(state.memory.list_sessions(workspace=state.workspace)) + if not sessions: + click.echo(f"(no sessions for workspace {state.workspace})") + return + for session in sessions: + marker = "*" if session.sid == state.session_id else " " + ws = session.workspace or "(unbound)" + click.echo(f"{marker} {session.sid}\t{session.name}\tworkspace={ws}\tupdated={session.updated_at}") + + +@slash.command("new") +@click.argument("name", required=False, default="chat") +@click.pass_obj +def new_cmd(state: ReplState, name: str) -> None: + """Start a new session (bound to workspace).""" + label = name.strip() or "chat" + _await(state.new_session(name=label)) + click.echo(f"new session id={state.session_id} name={label}") + + +@slash.command("rename") +@click.argument("name", nargs=-1, required=True) +@click.pass_obj +def rename_cmd(state: ReplState, name: tuple[str, ...]) -> None: + """Rename the current session.""" + full = " ".join(name).strip() + if not full: + msg = "NAME is required" + raise click.UsageError(msg) + try: + row = _await(state.rename_current_session(full)) + except ValueError as exc: + click.echo(f"error: {exc}") + return + click.echo(f"renamed session {row.sid} -> {row.name}") + + +@slash.command("delete") +@click.argument("session_id", type=int, required=False) +@click.pass_obj +def delete_cmd(state: ReplState, session_id: int | None) -> None: + """Hard-delete a session (confirm; current → new empty).""" + from plyngent.prompting import NonInteractiveError, confirm_async + + sid = session_id + if sid is None: + if state.session_id is None: + click.echo("error: no active session") + return + sid = state.session_id + try: + allowed = _await( + confirm_async( + f"Permanently delete session {sid} and all messages?", + default=False, + ) + ) + except NonInteractiveError: + click.echo("error: delete requires interactive confirm (or TTY)") + return + if not allowed: + click.echo("delete cancelled") + return + try: + was_current = _await(state.delete_session_and_maybe_replace(sid)) + except ValueError as exc: + click.echo(f"error: {exc}") + return + if was_current: + click.echo(f"deleted session {sid}; new session {state.session_id}") + else: + click.echo(f"deleted session {sid}") + + +@slash.command("export") +@click.argument("parts", nargs=-1) +@click.pass_obj +def export_cmd(state: ReplState, parts: tuple[str, ...]) -> None: + """Export session transcript from DB: /export [md|json] [path].""" + from plyngent.cli.export import ( + encode_session_export_json, + format_session_export_md, + resolve_export_path, + session_export_payload, + write_export_file, + ) + + fmt = "md" + path: str | None = None + if parts: + first = parts[0].lower() + if first in {"md", "markdown", "json"}: + fmt = "json" if first == "json" else "md" + path = parts[1] if len(parts) > 1 else None + if len(parts) > 2: # noqa: PLR2004 + msg = "usage: /export [md|json] [path]" + raise click.UsageError(msg) + else: + path = parts[0] + if len(parts) > 1: + msg = "usage: /export [md|json] [path]" + raise click.UsageError(msg) + + if state.session_id is None: + click.echo("error: no active session") + return + row = _await(state.memory.get_session(state.session_id)) + if row is None: + click.echo(f"error: session not found: {state.session_id}") + return + messages = _await(state.memory.list_messages(state.session_id)) + out_path = resolve_export_path(state.session_id, fmt, path) + if fmt == "json": + text = encode_session_export_json( + session_export_payload( + sid=row.sid, + name=row.name, + workspace=row.workspace, + created_at=row.created_at, + updated_at=row.updated_at, + messages=messages, + ) + ) + else: + text = format_session_export_md( + messages, + sid=row.sid, + name=row.name, + workspace=row.workspace, + ) + try: + written = write_export_file(out_path, text) + except OSError as exc: + click.echo(f"error: write failed: {exc}") + return + click.echo(f"exported session {row.sid} ({fmt}) -> {written}") + + +@slash.command("resume") +@click.argument("session_id", type=int, required=False) +@click.pass_obj +def resume_cmd(state: ReplState, session_id: int | None) -> None: + """Resume session id, or latest for this workspace if omitted.""" + if session_id is None: + mode = _await(state.resume_latest_or_new()) + if mode == "new": + click.echo(f"no prior session; created new session {state.session_id}") + else: + click.echo( + f"resumed latest session {state.session_id} " + f"({len(state.agent.messages)} messages) workspace={state.workspace}" + ) + return + try: + _await(state.resume_session(session_id)) + except ValueError as exc: + click.echo(f"error: {exc}") + return + click.echo(f"resumed session {session_id} ({len(state.agent.messages)} messages) workspace={state.workspace}") + + +@slash.command("compact") +@click.argument("name", required=False) +@click.pass_obj +def compact_cmd(state: ReplState, name: str | None) -> None: + """Soft-compact + model-summarize into a new session.""" + click.secho("compacting (soft-compact + model summary)…", fg="yellow") + try: + old_id, new_id, summary = _await(state.compact_to_new_session(name=name)) + except ValueError as exc: + click.echo(f"error: {exc}") + return + except Exception as exc: # noqa: BLE001 — surface model/API failures + click.secho(f"error: compact failed: {exc}", fg="red") + return + preview = summary if len(summary) <= _COMPACT_PREVIEW else summary[:_COMPACT_PREVIEW] + "…" + click.echo(f"compacted session {old_id} -> new session {new_id}") + click.secho(preview, fg="bright_black") + + +@slash.command("provider") +@click.argument("name", required=False) +@click.pass_obj +def provider_cmd(state: ReplState, name: str | None) -> None: + """Show or switch provider.""" + if not name: + click.echo(f"provider={state.provider_name}") + return + try: + pname, provider = select_provider(state.config.providers, preferred=name.strip()) + state.provider_name = pname + state.provider = provider + state.rebuild_client() + click.echo(f"switched provider to {pname}") + except (click.ClickException, ProviderNotSupportedError) as exc: + click.echo(f"error: {exc}") + + +@slash.command("model") +@click.argument("model_id", required=False) +@click.pass_obj +def model_cmd(state: ReplState, model_id: str | None) -> None: + """Show or switch model.""" + if not model_id: + click.echo(f"model={state.model}") + return + try: + state.model = select_model(state.provider, preferred=model_id.strip()) + state.rebuild_client() + click.echo(f"switched model to {state.model}") + except click.ClickException as exc: + click.echo(f"error: {exc}") + + +@slash.command("tools") +@click.argument("enabled", required=False, type=ON_OFF) +@click.pass_obj +def tools_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001 + """Show or toggle tools.""" + if enabled is None: + click.echo(f"tools={'on' if state.tools_enabled else 'off'}") + return + state.tools_enabled = enabled + state.rebuild_client() + click.echo(f"tools={'on' if enabled else 'off'}") + + +@slash.command("stream") +@click.argument("enabled", required=False, type=ON_OFF) +@click.pass_obj +def stream_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001 + """Show or toggle streaming model output.""" + if enabled is None: + click.echo(f"stream={'on' if state.agent.stream else 'off'}") + return + state.stream_enabled = enabled + state.agent.stream = enabled + click.echo(f"stream={'on' if enabled else 'off'}") + + +@slash.command("verbose") +@click.argument("enabled", required=False, type=ON_OFF) +@click.pass_obj +def verbose_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001 + """Show or toggle full tool-result dumps.""" + if enabled is None: + click.echo(f"verbose={'on' if state.verbose else 'off'}") + return + state.verbose = enabled + state.sync_display_flags() + click.echo(f"verbose={'on' if enabled else 'off'}") + + +@slash.command("rounds") +@click.argument("n", required=False, type=int) +@click.pass_obj +def rounds_cmd(state: ReplState, n: int | None) -> None: + """Show or set max tool-loop rounds.""" + if n is None: + click.echo(f"max_rounds={state.max_rounds}") + return + if n < 1: + msg = "max_rounds must be >= 1" + raise click.UsageError(msg) + state.max_rounds = n + state.agent.max_rounds = n + click.echo(f"max_rounds={state.max_rounds}") + + +@slash.command("history") +@click.argument("n", required=False, type=int) +@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" + raise click.UsageError(msg) + 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)) + if state.agent.pending_retry_text is not None: + click.secho( + f"(pending retry) user: {_preview_content(state.agent.pending_retry_text)}", + fg="yellow", + ) + + +@slash.command("retry") +@click.pass_obj +def retry_cmd(state: ReplState) -> None: + """Re-run incomplete last user turn (DB/orphan user; no retype).""" + _ = _await(retry_pending_with_retries(state.agent)) + + +def _preview_content(text: str | None) -> str: + if not text: + return "" + if len(text) <= _CONTENT_PREVIEW: + return text + return text[:_CONTENT_PREVIEW] + "…" + + +def _format_history_message(index: int, message: AnyChatMessage) -> str: + if isinstance(message, UserChatMessage): + return f"{index}. user: {_preview_content(message.content)}" + if isinstance(message, AssistantChatMessage): + parts: list[str] = [] + if isinstance(message.content, str) and message.content: + parts.append(_preview_content(message.content)) + tool_calls = message.tool_calls + if tool_calls is not UNSET and tool_calls: + names: list[str] = [] + for call in tool_calls: + if isinstance(call, AssistantFunctionToolCall): + names.append(call.function.name) + else: + names.append("custom") + parts.append(f"tool_calls=[{', '.join(names)}]") + body = " ".join(parts) if parts else "(empty)" + return f"{index}. assistant: {body}" + if isinstance(message, ToolChatMessage): + return f"{index}. tool({message.tool_call_id}): {_preview_content(message.content)}" + role = getattr(message, "role", type(message).__name__) + content = getattr(message, "content", "") + return f"{index}. {role}: {_preview_content(str(content))}" + + +def _run_slash_argv(args: Sequence[str], state: ReplState) -> None: + """Sync Click entrypoint; may call awaitlet() for async work.""" + # standalone_mode=False → UsageError/ClickException instead of SystemExit. + slash.main( + args=list(args), + prog_name="", + obj=state, + standalone_mode=False, + ) + + +async def handle_slash(state: ReplState, line: str) -> bool: + """Handle a slash command. Returns False if the REPL should exit.""" + body = line[1:].strip() + if not body: + return True + try: + args = shlex.split(body) + except ValueError as exc: + click.echo(f"error: {exc}") + return True + if not args: + return True + args[0] = args[0].lower() + try: + await awaitlet.async_def(_run_slash_argv, args, state) + except ReplExitError: + return False + except click.ClickException as exc: + exc.show() + except click.exceptions.Exit as exc: + # Click may still raise Exit(0) for some paths; ignore non-error codes. + if exc.exit_code not in {0, None}: + click.echo(f"error: exit {exc.exit_code}") + except click.Abort: + click.echo("aborted") + return True diff --git a/tests/test_cli/test_readline_setup.py b/tests/test_cli/test_readline_setup.py index b5bb2d0..4b493a9 100644 --- a/tests/test_cli/test_readline_setup.py +++ b/tests/test_cli/test_readline_setup.py @@ -3,10 +3,10 @@ from __future__ import annotations import tomlkit from plyngent.cli.readline_setup import ( - SLASH_COMMANDS, build_completer, filter_prefix, history_path, + slash_commands, ) from plyngent.cli.state import ReplState from plyngent.config.models import ModelConfig, OpenAICompatibleProvider, OpenAIProvider @@ -76,10 +76,11 @@ def test_completer_commands(tmp_path: object, monkeypatch: object) -> None: break found.append(item) index += 1 + names = slash_commands() assert "/help" in found - assert "/stream" in SLASH_COMMANDS - assert "/verbose" in SLASH_COMMANDS - assert set(found) <= set(SLASH_COMMANDS) + assert "/stream" in names + assert "/verbose" in names + assert set(found) <= set(names) def test_bind_tab_complete_runs() -> None: diff --git a/tests/test_cli/test_repl_commands.py b/tests/test_cli/test_repl_commands.py index 18e78e7..b55d342 100644 --- a/tests/test_cli/test_repl_commands.py +++ b/tests/test_cli/test_repl_commands.py @@ -6,7 +6,7 @@ import pytest import tomlkit from plyngent.agent import ChatAgent -from plyngent.cli.repl import handle_slash +from plyngent.cli.slash import handle_slash from plyngent.cli.state import ReplState from plyngent.config.models import DatabaseConfig, OpenAIProvider from plyngent.config.store import ConfigStore