mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/cli: drive Tab completion from Click ParamTypes
Add shell_complete on OnOff/export/provider/model/help param types and complete_slash_args so readline no longer keeps a parallel arg table.
This commit is contained in:
@@ -15,9 +15,6 @@ if TYPE_CHECKING:
|
|||||||
HISTORY_FILE_NAME = "repl_history"
|
HISTORY_FILE_NAME = "repl_history"
|
||||||
DEFAULT_HISTORY_LENGTH = 1000
|
DEFAULT_HISTORY_LENGTH = 1000
|
||||||
|
|
||||||
_ON_OFF_ARGS: tuple[str, ...] = ("on", "off")
|
|
||||||
_EXPORT_ARGS: tuple[str, ...] = ("md", "json")
|
|
||||||
|
|
||||||
|
|
||||||
def slash_commands() -> list[str]:
|
def slash_commands() -> list[str]:
|
||||||
"""Slash command tokens for Tab completion (with leading /)."""
|
"""Slash command tokens for Tab completion (with leading /)."""
|
||||||
@@ -38,11 +35,18 @@ def filter_prefix(prefix: str, candidates: list[str]) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def build_completer(state: ReplState) -> Callable[[str, int], str | None]:
|
def build_completer(state: ReplState) -> Callable[[str, int], str | None]:
|
||||||
"""Return a readline completer bound to the current REPL state."""
|
"""Return a readline completer bound to the current REPL state.
|
||||||
|
|
||||||
|
Command names come from the Click slash registry; argument values come from
|
||||||
|
each parameter's :meth:`~click.ParamType.shell_complete` via
|
||||||
|
:func:`plyngent.cli.slash.complete_slash_args`.
|
||||||
|
"""
|
||||||
|
|
||||||
def completer(text: str, state_index: int) -> str | None:
|
def completer(text: str, state_index: int) -> str | None:
|
||||||
import readline
|
import readline
|
||||||
|
|
||||||
|
from plyngent.cli.slash import complete_slash_args
|
||||||
|
|
||||||
buffer = readline.get_line_buffer()
|
buffer = readline.get_line_buffer()
|
||||||
begidx = readline.get_begidx()
|
begidx = readline.get_begidx()
|
||||||
# Completing the first token (command).
|
# Completing the first token (command).
|
||||||
@@ -51,7 +55,7 @@ def build_completer(state: ReplState) -> Callable[[str, int], str | None]:
|
|||||||
else:
|
else:
|
||||||
head = buffer[:begidx].strip()
|
head = buffer[:begidx].strip()
|
||||||
command = head.split()[0] if head else ""
|
command = head.split()[0] if head else ""
|
||||||
options = _argument_options(state, command, text)
|
options = complete_slash_args(state, command, text)
|
||||||
if state_index < len(options):
|
if state_index < len(options):
|
||||||
return options[state_index]
|
return options[state_index]
|
||||||
return None
|
return None
|
||||||
@@ -59,23 +63,6 @@ def build_completer(state: ReplState) -> Callable[[str, int], str | None]:
|
|||||||
return completer
|
return completer
|
||||||
|
|
||||||
|
|
||||||
def _argument_options(state: ReplState, command: str, text: str) -> list[str]:
|
|
||||||
candidates: list[str] = []
|
|
||||||
if command == "/help":
|
|
||||||
# Second token: command name without leading slash (also accept /name).
|
|
||||||
candidates = [n.removeprefix("/") for n in slash_commands()]
|
|
||||||
text = text.lstrip("/")
|
|
||||||
elif command == "/provider":
|
|
||||||
candidates = sorted(state.config.providers.keys())
|
|
||||||
elif command == "/model":
|
|
||||||
candidates = sorted(state.provider.models.keys())
|
|
||||||
elif command in {"/tools", "/stream", "/verbose"}:
|
|
||||||
candidates = list(_ON_OFF_ARGS)
|
|
||||||
elif command == "/export":
|
|
||||||
candidates = list(_EXPORT_ARGS)
|
|
||||||
return filter_prefix(text, candidates)
|
|
||||||
|
|
||||||
|
|
||||||
def bind_tab_complete(readline_mod: object) -> None:
|
def bind_tab_complete(readline_mod: object) -> None:
|
||||||
"""Bind Tab to completion for GNU readline and libedit/editline."""
|
"""Bind Tab to completion for GNU readline and libedit/editline."""
|
||||||
parse = getattr(readline_mod, "parse_and_bind", None)
|
parse = getattr(readline_mod, "parse_and_bind", None)
|
||||||
|
|||||||
+124
-5
@@ -1,10 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shlex
|
import shlex
|
||||||
from typing import TYPE_CHECKING, Any, override
|
from typing import TYPE_CHECKING, Any, cast, override
|
||||||
|
|
||||||
import awaitlet # pyright: ignore[reportMissingTypeStubs]
|
import awaitlet # pyright: ignore[reportMissingTypeStubs]
|
||||||
import click
|
import click
|
||||||
|
from click.shell_completion import CompletionItem
|
||||||
from msgspec import UNSET
|
from msgspec import UNSET
|
||||||
|
|
||||||
from plyngent.cli.retry import retry_pending_with_retries
|
from plyngent.cli.retry import retry_pending_with_retries
|
||||||
@@ -26,6 +27,8 @@ if TYPE_CHECKING:
|
|||||||
_DEFAULT_HISTORY_LINES = 20
|
_DEFAULT_HISTORY_LINES = 20
|
||||||
_CONTENT_PREVIEW = 200
|
_CONTENT_PREVIEW = 200
|
||||||
_COMPACT_PREVIEW = 400
|
_COMPACT_PREVIEW = 400
|
||||||
|
_ON_OFF_CHOICES = ("on", "off")
|
||||||
|
_EXPORT_FORMAT_CHOICES = ("md", "json")
|
||||||
|
|
||||||
HELP_FOOTER = """\
|
HELP_FOOTER = """\
|
||||||
User messages are saved immediately. On API errors or Ctrl+C, partial
|
User messages are saved immediately. On API errors or Ctrl+C, partial
|
||||||
@@ -42,6 +45,16 @@ class ReplExitError(Exception):
|
|||||||
"""Signal that the REPL should leave (not a process exit)."""
|
"""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]):
|
class OnOffParam(click.ParamType[bool]):
|
||||||
"""Accept on/off (and common synonyms); convert to bool."""
|
"""Accept on/off (and common synonyms); convert to bool."""
|
||||||
|
|
||||||
@@ -59,10 +72,95 @@ class OnOffParam(click.ParamType[bool]):
|
|||||||
msg = "expected on or off"
|
msg = "expected on or off"
|
||||||
raise click.BadParameter(msg, ctx=ctx, param=param)
|
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()
|
ON_OFF = OnOffParam()
|
||||||
|
|
||||||
|
|
||||||
|
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 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.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, sorted(state.provider.models.keys()))
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class SlashGroup(click.Group):
|
||||||
"""Click group for REPL slash commands (no process-level ownership).
|
"""Click group for REPL slash commands (no process-level ownership).
|
||||||
|
|
||||||
@@ -104,6 +202,27 @@ def slash_command_names() -> list[str]:
|
|||||||
return sorted(f"/{name}" for name in slash.list_commands(ctx))
|
return sorted(f"/{name}" for name in slash.list_commands(ctx))
|
||||||
|
|
||||||
|
|
||||||
|
def complete_slash_args(state: ReplState, command: str, incomplete: str) -> list[str]:
|
||||||
|
"""Tab-complete arguments for ``command`` (e.g. ``/stream``) from ParamTypes.
|
||||||
|
|
||||||
|
Uses the first :class:`click.Argument` on the registered command whose type
|
||||||
|
implements :meth:`~click.ParamType.shell_complete` with candidates.
|
||||||
|
"""
|
||||||
|
name = command.lstrip("/").lower()
|
||||||
|
ctx = click.Context(slash, obj=state)
|
||||||
|
cmd = slash.get_command(ctx, name)
|
||||||
|
if cmd is None:
|
||||||
|
return []
|
||||||
|
with click.Context(cmd, info_name=name, parent=ctx, obj=state) as sub:
|
||||||
|
for param in cmd.params:
|
||||||
|
if not isinstance(param, click.Argument):
|
||||||
|
continue
|
||||||
|
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:
|
def _await[T](awaitable: Awaitable[T]) -> T:
|
||||||
# Greenlet parks until the awaitable completes on the running loop.
|
# Greenlet parks until the awaitable completes on the running loop.
|
||||||
return awaitlet.awaitlet(awaitable)
|
return awaitlet.awaitlet(awaitable)
|
||||||
@@ -113,7 +232,7 @@ def _await[T](awaitable: Awaitable[T]) -> T:
|
|||||||
|
|
||||||
|
|
||||||
@slash.command("help")
|
@slash.command("help")
|
||||||
@click.argument("command", required=False)
|
@click.argument("command", required=False, type=SLASH_COMMAND_NAME)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def help_cmd(ctx: click.Context, command: str | None) -> None:
|
def help_cmd(ctx: click.Context, command: str | None) -> None:
|
||||||
"""Show this help, or help for one command."""
|
"""Show this help, or help for one command."""
|
||||||
@@ -265,7 +384,7 @@ def delete_cmd(state: ReplState, session_id: int | None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@slash.command("export")
|
@slash.command("export")
|
||||||
@click.argument("parts", nargs=-1)
|
@click.argument("parts", nargs=-1, type=EXPORT_FORMAT)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def export_cmd(state: ReplState, parts: tuple[str, ...]) -> None:
|
def export_cmd(state: ReplState, parts: tuple[str, ...]) -> None:
|
||||||
"""Export session transcript from DB: /export [md|json] [path]."""
|
"""Export session transcript from DB: /export [md|json] [path]."""
|
||||||
@@ -371,7 +490,7 @@ def compact_cmd(state: ReplState, name: str | None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@slash.command("provider")
|
@slash.command("provider")
|
||||||
@click.argument("name", required=False)
|
@click.argument("name", required=False, type=PROVIDER_NAME)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def provider_cmd(state: ReplState, name: str | None) -> None:
|
def provider_cmd(state: ReplState, name: str | None) -> None:
|
||||||
"""Show or switch provider."""
|
"""Show or switch provider."""
|
||||||
@@ -389,7 +508,7 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@slash.command("model")
|
@slash.command("model")
|
||||||
@click.argument("model_id", required=False)
|
@click.argument("model_id", required=False, type=MODEL_ID)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def model_cmd(state: ReplState, model_id: str | None) -> None:
|
def model_cmd(state: ReplState, model_id: str | None) -> None:
|
||||||
"""Show or switch model."""
|
"""Show or switch model."""
|
||||||
|
|||||||
@@ -142,3 +142,34 @@ def test_completer_help_commands(tmp_path: object, monkeypatch: object) -> None:
|
|||||||
index += 1
|
index += 1
|
||||||
assert "compact" in found
|
assert "compact" in found
|
||||||
assert "clear" in found
|
assert "clear" in found
|
||||||
|
|
||||||
|
|
||||||
|
def test_completer_stream_on_off(tmp_path: object, monkeypatch: object) -> None:
|
||||||
|
import readline
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
assert isinstance(tmp_path, Path)
|
||||||
|
assert isinstance(monkeypatch, pytest.MonkeyPatch)
|
||||||
|
|
||||||
|
state = _minimal_state(tmp_path)
|
||||||
|
completer = build_completer(state)
|
||||||
|
monkeypatch.setattr(readline, "get_line_buffer", lambda: "/stream ")
|
||||||
|
monkeypatch.setattr(readline, "get_begidx", lambda: len("/stream "))
|
||||||
|
|
||||||
|
assert completer("o", 0) == "on"
|
||||||
|
assert completer("o", 1) == "off"
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_slash_args_from_registry(tmp_path: object) -> None:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from plyngent.cli.slash import complete_slash_args
|
||||||
|
|
||||||
|
assert isinstance(tmp_path, Path)
|
||||||
|
state = _minimal_state(tmp_path)
|
||||||
|
assert complete_slash_args(state, "/provider", "r") == ["remote"]
|
||||||
|
assert complete_slash_args(state, "/model", "a") == ["alpha"]
|
||||||
|
assert complete_slash_args(state, "/export", "j") == ["json"]
|
||||||
|
assert complete_slash_args(state, "/help", "st") == ["status", "stream"]
|
||||||
|
|||||||
Reference in New Issue
Block a user