mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/cli: readline REPL with slash commands
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
with contextlib.suppress(ImportError):
|
||||
import readline as _readline
|
||||
|
||||
_ = _readline
|
||||
|
||||
from plyngent.cli.display import render_events
|
||||
from plyngent.cli.selection import select_model, select_provider
|
||||
from plyngent.runtime import ProviderNotSupportedError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from plyngent.cli.state import ReplState
|
||||
|
||||
HELP_TEXT = """\
|
||||
Commands:
|
||||
/help Show this help
|
||||
/quit, /exit Leave the REPL
|
||||
/clear Clear in-memory conversation (keeps session id)
|
||||
/sessions List sessions
|
||||
/new [name] Start a new session
|
||||
/resume <id> Resume a session by id
|
||||
/provider [name] Show or switch provider
|
||||
/model [id] Show or switch model
|
||||
/tools [on|off] Show or toggle tools
|
||||
"""
|
||||
|
||||
type SlashHandler = Callable[[], None | Awaitable[None]]
|
||||
|
||||
|
||||
async def _cmd_sessions(state: ReplState) -> None:
|
||||
sessions = await state.memory.list_sessions()
|
||||
if not sessions:
|
||||
click.echo("(no sessions)")
|
||||
return
|
||||
for session in sessions:
|
||||
marker = "*" if session.sid == state.session_id else " "
|
||||
click.echo(f"{marker} {session.sid}\t{session.name}\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_resume(state: ReplState, arg: str) -> None:
|
||||
if not arg.strip():
|
||||
click.echo("usage: /resume <session_id>")
|
||||
return
|
||||
try:
|
||||
session_id = int(arg.strip())
|
||||
except ValueError:
|
||||
click.echo("session id must be an integer")
|
||||
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)")
|
||||
|
||||
|
||||
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_clear(state: ReplState) -> None:
|
||||
state.agent.messages.clear()
|
||||
click.echo("conversation cleared")
|
||||
|
||||
|
||||
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),
|
||||
"sessions": lambda: _cmd_sessions(state),
|
||||
"new": lambda: _cmd_new(state, arg),
|
||||
"resume": lambda: _cmd_resume(state, arg),
|
||||
"provider": lambda: _cmd_provider(state, arg),
|
||||
"model": lambda: _cmd_model(state, arg),
|
||||
"tools": lambda: _cmd_tools(state, arg),
|
||||
}
|
||||
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:
|
||||
"""Blocking readline input (intentional for TTY REPL)."""
|
||||
return input("> ").strip()
|
||||
|
||||
|
||||
async def run_repl(state: ReplState) -> None:
|
||||
"""Interactive chat loop with readline editing."""
|
||||
click.echo(
|
||||
f"plyngent chat provider={state.provider_name} model={state.model} "
|
||||
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'}"
|
||||
)
|
||||
click.echo("Type /help for commands. Empty line is ignored.")
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = _read_line()
|
||||
except EOFError:
|
||||
click.echo()
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
click.echo()
|
||||
continue
|
||||
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("/"):
|
||||
cont = await handle_slash(state, line)
|
||||
if not cont:
|
||||
break
|
||||
continue
|
||||
|
||||
click.secho("user: ", fg="green", nl=False)
|
||||
click.echo(line)
|
||||
try:
|
||||
await render_events(state.agent.run(line))
|
||||
except Exception as exc: # noqa: BLE001 — show API/runtime errors in REPL
|
||||
click.secho(f"error: {exc}", fg="red")
|
||||
Reference in New Issue
Block a user