Files
plyngent/src/plyngent/cli/slash.py
T

1485 lines
51 KiB
Python

from __future__ import annotations
import os
import shlex
from typing import TYPE_CHECKING, Any, cast, override
import awaitlet # pyright: ignore[reportMissingTypeStubs]
import click
from click.shell_completion import CompletionItem
from msgspec import UNSET
from plyngent.cli.models_source import DEFAULT_MODELS_CACHE_TTL, model_choices_for_provider
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,
DeveloperChatMessage,
SystemChatMessage,
UserChatMessage,
)
from plyngent.runtime import ProviderNotSupportedError
if TYPE_CHECKING:
from collections.abc import Awaitable, Sequence
from plyngent.cli.state import ReplState, YoloMode
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
_DEFAULT_HISTORY_LINES = 20
_CONTENT_PREVIEW = 200
_COMPACT_PREVIEW = 400
_ON_OFF_CHOICES = ("on", "off")
_YOLO_MODE_CHOICES = ("on", "off", "once")
_EXPORT_FORMAT_CHOICES = ("md", "json")
_TODO_ACTION_CHOICES = (
"list",
"show",
"ls",
"push",
"pop",
"done",
"cancel",
"pending",
"in_progress",
"clear",
)
_ROUNDS_CHOICES = ("8", "16", "32", "64", "128")
class HistoryLimitType(click.ParamType[int]):
"""``N`` (int >= 1) or the shortcut ``last`` (equivalent to ``1``)."""
name: str = "history_limit"
@override
def convert(self, value: object, param: click.Parameter | None, ctx: click.Context | None) -> int:
if isinstance(value, int):
if value < 1:
self.fail("must be >= 1", param, ctx)
return value
text = str(value).strip().lower()
if text == "last":
return 1
try:
n = int(text, 10)
except ValueError:
self.fail("expected an integer N or 'last'", param, ctx)
if n < 1:
self.fail("must be >= 1", param, ctx)
return n
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del ctx, param
tokens = ("last", "1", "5", "10", "20")
return [CompletionItem(token) for token in tokens if incomplete == "" or token.startswith(incomplete.lower())]
HELP_FOOTER = (
"User messages are saved immediately. On API errors or Ctrl+C, partial\n"
"assistant/tool output is discarded but the user message stays (so /retry\n"
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
"\n"
"Side questions: /btw … answers without changing the main session\n"
"(optional --tools / --fresh). Tab completes slash commands and arguments.\n"
"Use --session ID or /resume to continue a prior chat after restart.\n"
"\n"
'Multiline: start a message with """ then end a later line with """.\n'
"Long prompts: /edit opens $VISUAL/$EDITOR (blocking).\n"
)
class ReplExitError(Exception):
"""Signal that the REPL should leave (not a process exit)."""
def _filter_choices(incomplete: str, choices: Sequence[str]) -> list[CompletionItem]:
return [CompletionItem(c) for c in choices if c.startswith(incomplete)]
def _repl_state(ctx: click.Context | None) -> ReplState | None:
if ctx is None or ctx.obj is None:
return None
return cast("ReplState", ctx.obj)
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
msg = "expected on or off"
raise click.BadParameter(msg, ctx=ctx, param=param)
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del ctx, param
return _filter_choices(incomplete, _ON_OFF_CHOICES)
ON_OFF = OnOffParam()
class YoloModeParam(click.ParamType[str]):
"""Accept on|off|once for soft destructive-tool confirms."""
name: str = "yolo_mode"
@override
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> str:
if isinstance(value, str) and value in _YOLO_MODE_CHOICES:
return value
token = str(value).strip().lower()
if token in _YOLO_MODE_CHOICES:
return token
msg = "expected on, off, or once"
raise click.BadParameter(msg, ctx=ctx, param=param)
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del ctx, param
return _filter_choices(incomplete, _YOLO_MODE_CHOICES)
YOLO_MODE = YoloModeParam()
class ExportFormatParam(click.ParamType[str]):
"""First token of /export: md|json (or a path if not a format)."""
name: str = "export_format"
@override
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> str:
del param, ctx
return str(value)
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del ctx, param
return _filter_choices(incomplete, _EXPORT_FORMAT_CHOICES)
EXPORT_FORMAT = ExportFormatParam()
class TodosActionParam(click.ParamType[str]):
"""First token of ``/todos``: list|push|pop|done|…"""
name: str = "todos_action"
@override
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> str:
del param, ctx
return str(value)
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del ctx, param
return _filter_choices(incomplete, _TODO_ACTION_CHOICES)
TODOS_ACTION = TodosActionParam()
class SessionIdParam(click.ParamType[int]):
"""Session id for ``/resume`` / ``/delete`` (from ReplState cache + current)."""
name: str = "session_id"
@override
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> int:
if isinstance(value, int):
return value
text = str(value).strip()
try:
return int(text, 10)
except ValueError:
self.fail("expected a session id (integer)", param, ctx)
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del param
state = _repl_state(ctx)
if state is None:
return []
# Sync cache only — no async from readline Tab (no awaitlet greenlet).
return _filter_choices(incomplete, state.session_ids_for_complete())
SESSION_ID = SessionIdParam()
class RoundsParam(click.ParamType[int]):
"""Max tool-loop rounds (int >= 1); Tab suggests common values."""
name: str = "rounds"
@override
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> int:
if isinstance(value, int):
n = value
else:
try:
n = int(str(value).strip(), 10)
except ValueError:
self.fail("expected an integer >= 1", param, ctx)
if n < 1:
self.fail("max_rounds 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
return _filter_choices(incomplete, _ROUNDS_CHOICES)
ROUNDS = RoundsParam()
class ProviderNameParam(click.ParamType[str]):
name: str = "provider"
@override
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> str:
del param, ctx
return str(value)
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del param
state = _repl_state(ctx)
if state is None:
return []
return _filter_choices(incomplete, sorted(state.config.selectable_providers().keys()))
PROVIDER_NAME = ProviderNameParam()
class ModelIdParam(click.ParamType[str]):
name: str = "model"
@override
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> str:
del param, ctx
return str(value)
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del param
state = _repl_state(ctx)
if state is None:
return []
return _filter_choices(incomplete, state.model_choice_ids())
MODEL_ID = ModelIdParam()
class SlashCommandNameParam(click.ParamType[str]):
"""Command name for ``/help <cmd>``."""
name: str = "slash_command"
@override
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> str:
del param, ctx
return str(value)
@override
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
del param
names = sorted(slash.list_commands(ctx))
token = incomplete.lstrip("/")
return _filter_choices(token, names)
SLASH_COMMAND_NAME = SlashCommandNameParam()
class SlashGroup(click.Group):
"""Click group for REPL slash commands (no process-level ownership).
Commands never expose Click's ``--help``; use ``/help`` / ``/help <cmd>``.
"""
@override
def command(self, *args: Any, **kwargs: Any) -> Any:
# No auto --help; drop [OPTIONS] metavar when the command has no options.
kwargs.setdefault("add_help_option", False)
kwargs.setdefault("options_metavar", "")
return super().command(*args, **kwargs)
@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",
add_help_option=False,
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 complete_slash_args(
state: ReplState,
command: str,
incomplete: str,
*,
prior_args: Sequence[str] | None = None,
) -> list[str]:
"""Tab-complete arguments/options for ``command`` from ParamTypes.
*prior_args* are tokens already typed after the command name (so
``/todos done t`` can complete todo ids). Options are completed when
*incomplete* starts with ``-``.
"""
name = command.lstrip("/").lower()
ctx = click.Context(slash, obj=state)
cmd = slash.get_command(ctx, name)
if cmd is None:
return []
prior = list(prior_args or ())
with click.Context(cmd, info_name=name, parent=ctx, obj=state) as sub:
# Flag / option completion (e.g. --persist, --full).
if incomplete.startswith("-"):
flags = [
opt
for param in cmd.params
if isinstance(param, click.Option)
for opt in param.opts
if opt.startswith(incomplete)
]
if flags:
return sorted(set(flags))
# Contextual /todos second token: todo ids after done|cancel|pending|in_progress.
if name == "todos" and prior:
action = prior[0].lower()
if action in {"done", "cancel", "pending", "in_progress"} and len(prior) == 1:
ids = [item.id for item in state.todo_stack.all_items()]
return [i for i in ids if i.startswith(incomplete)]
# Positional arguments: complete the next unset argument.
arg_params = [p for p in cmd.params if isinstance(p, click.Argument)]
arg_index = len(prior)
if arg_index < len(arg_params):
param = arg_params[arg_index]
items = param.type.shell_complete(sub, param, incomplete)
if items:
return [item.value for item in items]
for param in arg_params:
items = param.type.shell_complete(sub, param, incomplete)
if items:
return [item.value for item in items]
return []
def _await[T](awaitable: Awaitable[T]) -> T:
# Greenlet parks until the awaitable completes on the running loop.
return awaitlet.awaitlet(awaitable)
# --- commands -----------------------------------------------------------------
@slash.command("help")
@click.argument("command", required=False, type=SLASH_COMMAND_NAME)
@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
# Standalone context (no parent=help) so usage is "/compact …" not "help … compact".
with click.Context(cmd, info_name=f"/{name}") 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("edit")
@click.pass_obj
def edit_cmd(state: ReplState) -> None:
"""Compose a user message in ``$VISUAL``/``$EDITOR``, then send it.
Opens a temporary buffer; save and quit the editor to submit.
Empty buffer cancels. Requires a blocking editor (no system-open fallback).
"""
from plyngent.cli.editor import edit_text_in_editor
try:
text = edit_text_in_editor("")
except click.ClickException as exc:
click.echo(f"error: {exc}")
return
if text is None:
click.echo("edit cancelled (empty)")
return
state.pending_user_text = text
click.echo(f"(edit) {len(text)} characters ready to send")
@slash.command("config")
@click.pass_obj
def config_cmd(state: ReplState) -> None:
"""Open plyngent.toml in ``$VISUAL``/``$EDITOR`` (or system default), then reload.
Same file as ``plyngent config edit``. After a **blocking** editor exits,
config is re-read. System-open fallback does not wait — reload is skipped
with a hint to re-run ``/config`` after saving.
"""
from plyngent import config as config_mod
from plyngent.cli.editor import open_in_editor
path = state.config.path
try:
outcome = open_in_editor(path, allow_system_open=True)
except click.ClickException as exc:
click.echo(f"error: {exc}")
return
if outcome == "system":
click.secho(
f"opened {path} with system default (not waiting). Save the file, then run /config again to reload.",
fg="yellow",
)
return
try:
state.reload_config_from_disk()
except (config_mod.ConfigFormatError, ValueError, OSError) as exc:
click.secho(f"error: config reload failed: {exc}", fg="red")
click.echo(f"config file: {path}")
return
if state.config.recoverable_providers:
names = ", ".join(sorted(state.config.recoverable_providers.keys()))
click.secho(
f"recoverable providers (empty models): {names}",
fg="yellow",
)
if state.config.bad_providers:
names = ", ".join(sorted(state.config.bad_providers.keys()))
click.secho(f"warning: ignored bad providers: {names}", fg="yellow")
click.echo(f"config reloaded from {path}\nprovider={state.provider_name} model={state.model}")
@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'} "
f"yolo={state.effective_yolo()}\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))
state.remember_session_ids([s.sid for s in sessions])
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=SESSION_ID, 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, type=EXPORT_FORMAT)
@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,
provider_name=row.provider_name,
model=row.model,
)
)
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=SESSION_ID, 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(
f"active session is summary-only ({len(state.agent.messages)} messages); "
f"full history remains on /resume {old_id}",
fg="bright_black",
)
click.secho(preview, fg="bright_black")
@slash.command("provider")
@click.argument("name", required=False, type=PROVIDER_NAME)
@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:
from plyngent.cli.provider_recovery import ensure_provider_ready
pname, provider = select_provider(
state.config.selectable_providers(),
preferred=name.strip(),
)
provider = _await(
ensure_provider_ready(
state.config,
pname,
provider,
preferred_model=state.model,
interactive=True,
)
)
prev_model = state.model
state.provider_name = pname
state.provider = provider
state.rebuild_client()
# Always request remote catalog for the new provider (bypass stale cache).
choices = _await(state.merged_model_choices(refresh=True))
if prev_model and (prev_model in choices or prev_model in provider.models):
state.model = prev_model
else:
# Current model not on the new provider — pick one (prompt when interactive).
try:
state.model = select_model(
provider,
preferred=None,
interactive=True,
choices=choices,
)
except click.ClickException as exc:
click.echo(f"error: switched provider but model selection failed: {exc}")
return
if prev_model:
click.secho(
f"model {prev_model!r} is not available on {pname}; using {state.model!r}",
fg="yellow",
)
state.rebuild_client()
_await(state.persist_llm_selection())
click.echo(f"switched provider to {pname} model={state.model}")
except (click.ClickException, ProviderNotSupportedError) as exc:
click.echo(f"error: {exc}")
@slash.command("models")
@click.option("--refresh", is_flag=True, help="Bypass cache and re-fetch GET /models.")
@click.option(
"--persist",
is_flag=True,
help="Merge remote (and recovered) model ids into plyngent.toml for this provider.",
)
@click.pass_obj
def models_cmd(state: ReplState, *, refresh: bool, persist: bool) -> None: # noqa: C901
"""List models (remote-first, plus config-only ids). Always tries GET /models."""
del refresh # always re-fetch; flag kept for CLI compatibility / docs
remote: list[str] | None = None
remote_err: str | None = None
try:
remote = _await(state.ensure_remote_models(refresh=True))
except (RuntimeError, TypeError, OSError, ValueError) as exc:
remote_err = str(exc)
remote = state.cached_remote_models()
# Promote empty-models recoverable provider after a successful remote list.
if remote and state.provider_name in state.config.recoverable_providers:
try:
state.provider = state.config.promote_provider(state.provider_name, remote)
state.rebuild_client()
click.secho(
f"recovered provider {state.provider_name!r} from remote catalog",
fg="yellow",
err=True,
)
except (KeyError, ValueError) as exc:
click.secho(f"could not recover provider: {exc}", fg="yellow", err=True)
if persist:
try:
path = state.persist_models_to_config(mode="catalog", catalog_ids=remote or ())
click.echo(f"persisted {len(state.provider.models)} model(s) for {state.provider_name!r} to {path}")
except (KeyError, ValueError, OSError) as exc:
click.secho(f"error: could not persist models: {exc}", fg="red")
return
config_ids = set(state.config_model_ids())
choices = model_choices_for_provider(state.provider, remote_ids=remote)
if not choices:
click.echo("(no models in config or remote catalog)")
else:
remote_set = set(remote or ())
for mid in choices:
tags: list[str] = []
if mid in remote_set:
tags.append("remote")
if mid in config_ids:
tags.append("config")
suffix = f" ({', '.join(tags)})" if tags else ""
mark = " *" if mid == state.model else ""
click.echo(f"{mid}{mark}{suffix}")
if remote_err is not None:
click.secho(f"remote list unavailable: {remote_err}", fg="yellow", err=True)
elif remote is not None:
click.echo(
f"(remote-first: {len(remote)} remote, {len(config_ids)} config; "
f"cache TTL {int(DEFAULT_MODELS_CACHE_TTL)}s)",
err=True,
)
@slash.command("model")
@click.argument("model_id", required=False, type=MODEL_ID)
@click.option(
"--persist",
is_flag=True,
help="Write the current (or newly selected) model id into plyngent.toml.",
)
@click.pass_obj
def model_cmd(state: ReplState, model_id: str | None, *, persist: bool) -> None:
"""Show or switch model (Tab: remote-first plus config; live fetch on pick).
``/model --persist`` saves the active model id into the provider catalog in
TOML (faster Tab/list on next launch). ``/model <id> --persist`` switches
then saves.
"""
if model_id:
try:
choices = _await(state.merged_model_choices(refresh=True))
state.model = select_model(
state.provider,
preferred=model_id.strip(),
choices=choices,
)
state.rebuild_client()
_await(state.persist_llm_selection())
click.echo(f"switched model to {state.model}")
except click.ClickException as exc:
click.echo(f"error: {exc}")
return
elif not persist:
click.echo(f"model={state.model}")
return
if persist:
try:
path = state.persist_models_to_config(mode="current")
click.echo(f"persisted model {state.model!r} for {state.provider_name!r} to {path}")
except (KeyError, ValueError, OSError) as exc:
click.secho(f"error: could not persist model: {exc}", fg="red")
def _format_tool_tags(tags: object) -> str:
"""Compact Flag names for operator display (e.g. LOCAL|INSTANCE_STATE)."""
from plyngent.agent.tools import ToolTag
if not isinstance(tags, ToolTag):
return str(tags)
if tags.value == 0:
return "-"
names = [member.name for member in ToolTag if member.name and (tags & member) == member]
return "|".join(names) if names else str(tags)
@slash.command("btw")
@click.argument("question", nargs=-1, required=False)
@click.option(
"--tools/--no-tools",
"with_tools",
default=False,
show_default=True,
help="Allow tools on the side turn (shared workspace; forked session grants/todo).",
)
@click.option(
"--fresh",
is_flag=True,
default=False,
help="Ignore main history; system prompt only.",
)
@click.pass_obj
def btw_cmd(
state: ReplState,
question: tuple[str, ...],
*,
with_tools: bool,
fresh: bool,
) -> None:
"""Ask a side question without changing the main session transcript or DB.
Uses the current model and (unless ``--fresh``) a copy of main history.
Tools are **off** by default; ``--tools`` clones the main tool registry with
a fresh session bag and the shared instance workspace.
"""
text = " ".join(question).strip()
if not text:
click.echo("usage: /btw [--tools] [--fresh] <question>")
return
if state.agent.pending_retry_text is not None:
click.echo("error: finish or /retry the main turn before /btw")
return
if with_tools and (not state.tools_enabled or state.agent.tools is None):
click.echo("error: --tools requires tools on (/tools on)")
return
from plyngent.cli.display import render_events
from plyngent.cli.retry import run_cancellable
from plyngent.tools.context import SessionState
click.secho("btw: ", fg="magenta", nl=False)
click.echo(text)
tools_arg: bool = with_tools
aside_session = SessionState() if with_tools else None
aside_instance = state.instance_state if with_tools else None
async def _run() -> None:
await run_cancellable(
render_events(
state.agent.run_aside(
text,
include_history=not fresh,
tools=tools_arg,
instance_state=aside_instance,
session_state=aside_session,
)
)
)
try:
_await(_run())
except Exception as exc: # noqa: BLE001 — surface side-turn failures
click.secho(f"error: btw failed: {exc}", fg="red")
return
click.secho("(btw done — main session unchanged)", fg="bright_black")
@slash.command("tools")
@click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]")
@click.option(
"--list",
"list_tools",
is_flag=True,
default=False,
help="List registry tools with tags and catalog source.",
)
@click.pass_obj
def tools_cmd(
state: ReplState,
enabled: bool | None, # noqa: FBT001
*,
list_tools: bool,
) -> None:
"""Show or set whether agent tools are enabled.
Omit the argument to print the current value; pass ``on`` or ``off`` to change it.
Use ``--list`` to print name, tags, and catalog source for each registry tool.
"""
if list_tools:
if not state.tools_enabled or state.agent.tools is None:
click.echo("tools=off (no registry)")
return
from plyngent.tools.catalog import get_catalog
catalog = get_catalog()
registry = state.agent.tools
# ToolRegistry has no name iterator; use schema items then re-resolve defs.
items = sorted(registry.tool_items(), key=lambda item: item.function.name)
click.echo(f"tools=on count={len(items)}")
for item in items:
name = item.function.name
definition = registry.get(name)
tags_s = _format_tool_tags(definition.tags) if definition is not None else "-"
entry = catalog.get(name)
source_s = str(entry.source) if entry is not None else "registry"
click.echo(f" {name}\t{tags_s}\t{source_s}")
if enabled is not None:
state.tools_enabled = enabled
state.rebuild_client()
click.echo(f"tools={'on' if enabled else 'off'}")
return
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'}")
def _slash_rebuild_tools_if_on(state: ReplState) -> None:
if state.tools_enabled:
state.rebuild_client()
def _slash_apply_plugin_action(state: ReplState, act: str, token: str) -> None:
if act == "enable":
_ = state.config.enable_plugin(token)
elif act == "disable":
_ = state.config.disable_plugin(token)
elif act == "undeny":
_ = state.config.undeny_plugin(token)
else:
msg = f"unknown plugins action {act!r}"
raise click.UsageError(msg)
state.config.write()
_slash_rebuild_tools_if_on(state)
cfg = state.config.plugins_config
click.echo(f"plugins {act} {token!r} enable={list(cfg.enable)!r} disable={list(cfg.disable)!r}")
@slash.command("plugins")
@click.argument(
"action",
required=False,
type=click.Choice(["list", "enable", "disable", "undeny", "reload", "clear"]),
)
@click.argument("name", required=False)
@click.pass_obj
def plugins_slash_cmd(state: ReplState, action: str | None, name: str | None) -> None:
"""List or change plugin allowlist; reload tools into this session.
``/plugins`` or ``/plugins list`` — installed entry points + config status.
``/plugins enable NAME`` / ``disable NAME`` / ``undeny NAME`` — update
config on disk, then rebuild the tool registry for this session.
``/plugins reload`` — re-read config and rebuild tools without list changes.
``/plugins clear`` — empty enable/disable lists and rebuild.
"""
act = (action or "list").lower()
if act == "list":
from plyngent.cli.app import print_plugins_table
print_plugins_table(state.config)
return
if act == "reload":
state.config.reload()
_slash_rebuild_tools_if_on(state)
cfg = state.config.plugins_config
click.echo(
f"reloaded plugins enable={list(cfg.enable)!r} disable={list(cfg.disable)!r} "
f"tools={'on' if state.tools_enabled else 'off'}"
)
return
if act == "clear":
_ = state.config.clear_plugins()
state.config.write()
_slash_rebuild_tools_if_on(state)
if state.tools_enabled:
click.echo("cleared plugins enable/disable; tools registry rebuilt")
else:
click.echo("cleared plugins enable/disable")
return
if name is None or not name.strip():
msg = f"/plugins {act} requires a plugin entry-point name"
raise click.UsageError(msg)
try:
_slash_apply_plugin_action(state, act, name.strip())
except ValueError as exc:
raise click.UsageError(str(exc)) from exc
@slash.command("yolo")
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
@click.pass_obj
def yolo_cmd(state: ReplState, mode: str | None) -> None:
"""Show or set YOLO mode for soft destructive-tool confirms.
``off`` (default when config ``confirm_destructive`` is true): prompt on
delete/move/overwrite (deny in non-TTY). ``on``: skip confirms for the
process. ``once``: skip for the next user turn only, then return to ``off``.
Path/command denylists still apply. Omit the argument to print the value.
"""
if mode is None:
click.echo(f"yolo={state.effective_yolo()}")
return
state.set_yolo(cast("YoloMode", mode))
click.echo(f"yolo={state.effective_yolo()}")
@slash.command("stream")
@click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]")
@click.pass_obj
def stream_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
"""Show or set streaming model output.
``on`` (default): print assistant text and reasoning as tokens arrive.
``off``: wait for each full model response before printing.
Omit the argument to print the current value.
"""
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, metavar="[on|off]")
@click.pass_obj
def verbose_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
"""Show or set full tool-result dumps in the terminal.
``off`` (default): short one-line tool result preview.
``on``: print the full tool result text.
Omit the argument to print the current value.
"""
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("markdown")
@click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]")
@click.pass_obj
def markdown_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
"""Show or set end-of-turn Rich markdown for assistant text.
``on`` (default on TTY): stream plain tokens, then re-render as markdown.
``off``: leave streamed plain text as-is.
Non-TTY / ``PLYNGENT_PLAIN=1`` never pretty-prints.
"""
if enabled is None:
click.echo(f"markdown={'on' if state.markdown_enabled else 'off'}")
return
state.markdown_enabled = enabled
state.sync_display_flags()
click.echo(f"markdown={'on' if enabled else 'off'}")
@slash.command("rounds")
@click.argument("n", required=False, type=ROUNDS)
@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
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=HistoryLimitType())
@click.option(
"--full",
"mode_full",
is_flag=True,
default=False,
help="Print full message bodies (markdown for assistant when TTY).",
)
@click.option(
"--preview",
"mode_preview",
is_flag=True,
default=False,
help="Force short previews even for a single message.",
)
@click.pass_obj
def history_cmd(
state: ReplState,
n: int | None,
*,
mode_full: bool,
mode_preview: bool,
) -> None:
"""Show recent messages (default: last 20 as short previews).
``/history last`` or ``/history 1`` shows the last message in full with Rich
markdown when available. Use ``--full`` for multiple messages in full;
``--preview`` forces the short form even for a single message.
"""
if mode_full and mode_preview:
msg = "use only one of --full / --preview"
raise click.UsageError(msg)
limit = _DEFAULT_HISTORY_LINES if n is None else n
messages = state.agent.messages
if not messages:
click.echo("(no messages in this session)")
return
start = max(0, len(messages) - limit)
slice_msgs = messages[start:]
# Full: --full, or single-message window (last / 1) unless --preview.
full = mode_full or (len(slice_msgs) == 1 and not mode_preview)
mode_tag = "full" if full else "preview"
click.echo(f"session={state.session_id} messages={len(messages)} showing={len(slice_msgs)} mode={mode_tag}")
for offset, message in enumerate(slice_msgs):
idx = start + offset
if full:
_print_history_message_full(idx, message)
else:
click.echo(_format_history_message(idx, message))
if state.agent.pending_retry_text is not None:
pending = state.agent.pending_retry_text
if full:
click.secho("(pending retry) user:", fg="yellow")
click.echo(pending)
else:
click.secho(
f"(pending retry) user: {_preview_content(pending)}",
fg="yellow",
)
@slash.command("todos")
@click.argument("action", required=False, type=TODOS_ACTION)
@click.argument("rest", required=False, nargs=-1, type=str)
@click.pass_obj
def todos_cmd( # noqa: C901, PLR0911, PLR0912, PLR0915
state: ReplState, action: str | None, rest: tuple[str, ...]
) -> None:
"""Show or edit the LIFO stack of **task groups**.
``/todos`` — list (top group first)
``/todos push T1; T2`` — one new group of siblings
``/todos pop`` — pop entire top group
``/todos done <id>`` / ``/todos cancel <id>`` — set status
``/todos clear`` — wipe stack
"""
from plyngent.agent.todo_stack import parse_push_titles
stack = state.todo_stack
act = (action or "list").strip().lower()
if act in {"list", "show", "ls"}:
click.echo(stack.render())
return
if act == "push":
raw = " ".join(rest).strip()
if not raw:
click.echo('error: usage: /todos push <title> | /todos push ["T1","T2"] | T1; T2')
return
titles = parse_push_titles(raw)
if not titles:
click.echo("error: no titles to push (use a JSON array or ; / newlines)")
return
try:
group = stack.push_group(titles)
except ValueError as exc:
click.echo(f"error: {exc}")
return
_await(state.persist_todo_stack())
ids = ", ".join(i.id for i in group.items)
click.echo(f"pushed group depth={stack.depth} items=[{ids}]")
click.echo(stack.render())
return
if act == "pop":
group = stack.pop()
if group is None:
click.echo("todo stack empty")
return
_await(state.persist_todo_stack())
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)"
click.echo(f"popped TOP group ({titles})")
click.echo(stack.render())
return
if act in {"done", "cancel", "pending", "in_progress"}:
if not rest:
click.echo(f"error: usage: /todos {act} <id>")
return
if act == "done":
new_status = "done"
elif act == "cancel":
new_status = "cancelled"
elif act == "pending":
new_status = "pending"
else:
new_status = "in_progress"
try:
item = stack.update(rest[0], status=new_status)
except (KeyError, ValueError) as exc:
click.echo(f"error: {exc}")
return
_await(state.persist_todo_stack())
click.echo(f"updated {item.id}{item.status}")
click.echo(stack.render())
return
if act == "clear":
n = stack.clear()
_await(state.persist_todo_stack())
click.echo(f"cleared {n} item(s)")
return
click.echo("error: usage: /todos [list|push|pop|done|cancel|clear] …")
@slash.command("retry")
@click.pass_obj
def retry_cmd(state: ReplState) -> None:
"""Continue an incomplete turn (user-only or after committed tools).
Does not retype the user message. After tools already ran, continues the
model loop without re-executing those tool calls.
"""
_ = _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 _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
# ToolChatMessage (remaining AnyChatMessage arm)
click.secho(f"{index}. tool({message.tool_call_id}):", fg="magenta")
click.echo(message.content or "")
click.echo()
def _format_history_message(index: int, message: AnyChatMessage) -> str:
if isinstance(message, UserChatMessage):
return f"{index}. user: {_preview_content(message.content)}"
if isinstance(message, DeveloperChatMessage):
return f"{index}. developer: {_preview_content(message.content)}"
if isinstance(message, SystemChatMessage):
return f"{index}. system: {_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}"
# ToolChatMessage
return f"{index}. tool({message.tool_call_id}): {_preview_content(message.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:
# Windows paths use backslashes; POSIX shlex would treat them as escapes.
args = shlex.split(body, posix=os.name != "nt")
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