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.
This commit is contained in:
2026-07-15 13:03:55 +08:00
parent 9d05988245
commit 5b0c9be90b
8 changed files with 604 additions and 489 deletions
+18 -2
View File
@@ -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
Generated
+17 -3
View File
@@ -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"},
+2 -1
View File
@@ -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"
+8 -24
View File
@@ -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 ""
+2 -454
View File
@@ -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 <name> 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 <name>")
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 <positive integer>")
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:
+551
View File
@@ -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
+5 -4
View File
@@ -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:
+1 -1
View File
@@ -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