* feat(cli): multi-stage slash command completions with subcommand awareness Phase 1 of #82 — subcommand and argument awareness in completions. - commands/base.py: add SubCommand dataclass and subcommands/category ClassVars to the Command ABC. Each SubCommand has name, description, and optional arguments. - commands/manager.py: add get_subcommands() and list_subcommands() methods to expose subcommand metadata for completion rendering. - commands/implementation/mcp.py: declare 6 subcommands (list, config, add, edit, remove, install). - commands/implementation/model_fallback.py: declare 6 subcommands (list, add, remove, clear, save, help). - commands/implementation/channel.py: declare 2 subcommands (status, stop). - cli/tui_interactive.py: rewrite on_text_area_changed slash-completion branch. When the user types a command name + trailing space and the command has subcommands, show subcommand completions instead of hiding the popup. Filter subcommands by typed prefix in multi-token input. - commands/implementation/general.py: /help now lists subcommands below each command that declares them. Tests: 8 new tests covering SubCommand creation, CommandManager subcommand lookup, and cross-command verification. 2293 passed baseline, no regressions. * fix: subcommand completion preserves prefix + prompt_toolkit + tests - _apply_selected_completion: preserve '/mcp ' prefix when completing subcommands via _comp_is_subcommand flag - SlashCommandCompleter (Rich CLI): add subcommand completion support - Fix trailing-space bug: rstrip prefix before top-level matching - test_tui_widgets.py: update stub on_input_changed to match multi-stage logic; add 5 new subcommand tests - test_command_manager.py: 8 tests for SubCommand + CommandManager 28 passed, 0 failed. * style: ruff format tui_interactive.py + test_tui_widgets.py * style: fix RUF012 ClassVar annotation on subcommands lists * fix: sync test stub, add len>=3 guard, remove exact-match hide - Sync test stub on_input_changed with real TUI code (remove exact-match hide for subcommands, add len(parts)>=3 guard) - Update test_input_changed_exact_subcommand_hides -> shows_confirmation - Add test_input_changed_three_parts_hides - Remove unused category ClassVar (din0s: what is this for) * refactor(commands): extract shared completion engine Per din0s feedback: one shared completion engine (commands/_completion_engine.py) that parses text + cursor once, returns structured CompletionCandidate objects with replace_start/replace_end ranges. - SlashCommandCompleter (Rich CLI): thin adapter, delegates to engine - on_text_area_changed (TUI): thin adapter, delegates to engine - _apply_selected_completion: uses candidate.replace_start/replace_end instead of _comp_is_subcommand flag - Tests: engine tested directly (10 new tests), stub methods updated 29 passed, 0 failed. * style: ruff format * fix: preserve text after cursor when applying completion CodeRabbit: replace_start only cuts from start to cursor, dropping any suffix after the cursor. Use replace_start + replace_end to correctly splice the replacement while preserving trailing text. * fix(cli): repair slash-command completion (TUI crash, subcommand bugs, sort) Apology + context: the previous push shipped a TUI-breaking change (the new shared engine assumed ``event.text_area.cursor_position`` existed, but ``ChatTextArea`` / ``Changed`` don't expose it). User caught the crash on ``/``; fixing that surfaced two more bugs in the engine that din0s had already flagged. This commit addresses all of them and drops a piece of dead stub code. ## Bug fixes 1. **TUI crash on ``/``** (``tui_interactive.py:2335``) ``event.cursor_position`` doesn't exist on the ``Changed`` event, and ``ChatTextArea`` (Textual ``TextArea`` subclass) doesn't expose ``cursor_position`` either. Pass ``len(event.text_area.text)`` instead — the user types at the end of the input in practice. 2. **Subcommand trailing-space duplication** (``_completion_engine.py``) Typing ``/mcp a `` + Tab produced ``/mcp aadd``. The engine included the trailing space in ``replace_end``; the TUI apply unconditionally appended ``" "``, producing double-space output. Fix: ``replace_end`` excludes the trailing space; the TUI apply checks ``current[replace_end:].startswith(" ")`` and skips the separator when the suffix already has one. 3. **Subcommand exact-match confirmation noise** (``_completion_engine.py``) Typing ``/mcp list`` + Tab re-inserted ``list`` and the popup kept showing the same subcommand. Add a guard mirroring the top-level exact-match rule: when the only subcommand match is the prefix itself (no trailing space), return ``empty``. 4. **Alphabetical sort dropped in CLI** (``cli/interactive.py``) The new completer iterated ``result.candidates`` in registration order. Re-add ``sorted(result.candidates, key=lambda c: c.text)``. Same sort added to the TUI for consistency. ## Cleanup - Drop the dead ``on_input_changed`` method from the ``_StubApp`` test stub (0 call sites) plus the unused ``_slash_commands`` / ``_subcommands`` locals that fed it. This addresses din0s's comment about the stub duplicating real TUI logic — the inlined copy is no longer needed since the real completer now routes through the shared engine. ## Tests - ``test_engine_exact_subcommand_shows_confirmation`` → renamed to ``test_engine_exact_subcommand_hides`` to match new behavior. - New: ``test_engine_subcommand_trailing_space_excludes_space_from_range`` and ``test_engine_subcommand_trailing_space_apply_does_not_double_space``. - All 97 tests in ``test_tui_widgets.py`` pass. - ``ruff check`` / ``ruff format`` clean. - Local TUI smoke: ``/`` (no crash, top-level popup), ``/mcp `` (subcommand popup), ``/mcp a `` + Tab → ``/mcp add ``. Refs the din0s review comments on PR #273. CLI path tests and the ``category`` ClassVar follow-up are deferred to a separate PR (the former is a test-suite addition; the latter is already absent from ``base.py`` on the current branch). * fix: address remaining review items (help duplication, CLI tests, stub sync, docstrings) - mcp.py: auto-generate help text from subcommands ClassVar (#1) - tests/test_cli_completion.py: add 9 CLI completer tests (#2c) - test_tui_widgets.py: sync _apply_selected_completion stub with real code (#4) - mcp.py + interactive.py: add docstrings to key functions (#8) * fix: hide completions on exact subcommand match regardless of trailing space Remove the ot has_trailing_space guard from the exact-subcommand check. Previously /mcp list (with trailing space) would still return candidates, causing Tab to oscillate between adding and removing the trailing whitespace. Now the engine hides whenever the subcommand is an exact match, same as the top-level rule. Added test_engine_exact_subcommand_with_trailing_space_hides to cover the scenario din0s flagged. * refactor: use StrEnum for CompletionResult.kind Replace plain str with CompletionKind(StrEnum) for type safety. Backward-compatible with existing string comparisons. * fix: normalize @file completion tuples to CompletionCandidate complete_file_mention() returns list[tuple[str, str]] but the TUI rendering/apply code expects objects with .text/.description. Wrap tuples in CompletionCandidate to prevent AttributeError crash. --------- Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
This commit is contained in:
@@ -203,9 +203,17 @@ class SlashCommandCompleter(Completer):
|
||||
self,
|
||||
workspace_getter: Callable[[], str | None] | None = None,
|
||||
) -> None:
|
||||
"""Initialise the completer.
|
||||
|
||||
Args:
|
||||
workspace_getter: Callable returning the current workspace
|
||||
directory for ``@file`` completions. Called on every
|
||||
keystroke so suggestions stay in sync after ``/new``.
|
||||
"""
|
||||
self._workspace_getter = workspace_getter or (lambda: None)
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
"""Yield prompt_toolkit completions for slash commands and ``@file``."""
|
||||
text = document.text_before_cursor
|
||||
workspace_dir = self._workspace_getter()
|
||||
|
||||
@@ -225,16 +233,23 @@ class SlashCommandCompleter(Completer):
|
||||
# Slash command completion
|
||||
if not text.startswith("/"):
|
||||
return
|
||||
# ``list_commands`` is dedup'd on the Command instance so aliases
|
||||
# (e.g. /quit, /q for /exit) don't appear as separate rows.
|
||||
for cmd, desc in sorted(cmd_manager.list_commands()):
|
||||
if cmd.startswith(text):
|
||||
yield Completion(
|
||||
cmd,
|
||||
start_position=-len(text),
|
||||
display=f"{cmd:<40}",
|
||||
display_meta=desc,
|
||||
)
|
||||
|
||||
from ..commands._completion_engine import compute_completions
|
||||
|
||||
result = compute_completions(
|
||||
document.text_before_cursor, len(document.text_before_cursor)
|
||||
)
|
||||
if result.kind == "empty" or not result.candidates:
|
||||
return
|
||||
|
||||
# Sort alphabetically by completion text for stable, predictable
|
||||
# ordering in the popup. The engine returns candidates in
|
||||
# manager-registration order, which is not stable across changes.
|
||||
for c in sorted(result.candidates, key=lambda c: c.text):
|
||||
start_pos = c.replace_start - len(document.text_before_cursor)
|
||||
yield Completion(
|
||||
c.text, start_position=start_pos, display_meta=c.description
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -434,7 +434,7 @@ def run_textual_interactive(
|
||||
self._queued_messages: list[
|
||||
str
|
||||
] = [] # queued messages to send after current turn
|
||||
self._comp_items: list[tuple[str, str]] = []
|
||||
self._comp_items: list = []
|
||||
self._comp_index: int = -1
|
||||
self._hitl_auto_approve: bool = False
|
||||
self._approval_future: asyncio.Future | None = None
|
||||
@@ -2339,28 +2339,50 @@ def run_textual_interactive(
|
||||
if "@" in text:
|
||||
candidates = complete_file_mention(text, workspace_dir)
|
||||
if candidates:
|
||||
self._comp_items = candidates
|
||||
import re as _re
|
||||
|
||||
from ..commands._completion_engine import CompletionCandidate
|
||||
|
||||
before = text[: len(event.text_area.text)]
|
||||
m = _re.search(r"@[^\s]*$", before)
|
||||
start = m.start() if m else len(event.text_area.text)
|
||||
end = len(event.text_area.text)
|
||||
self._comp_items = [
|
||||
CompletionCandidate(
|
||||
text=path if path.startswith("@") else f"@{path}",
|
||||
description=type_hint,
|
||||
replace_start=start,
|
||||
replace_end=end,
|
||||
)
|
||||
for path, type_hint in candidates
|
||||
]
|
||||
self._comp_index = -1
|
||||
self._render_completions()
|
||||
comp_widget.display = True
|
||||
return
|
||||
|
||||
if text.startswith("/"):
|
||||
prefix = text.lower()
|
||||
matches = [
|
||||
(cmd, desc)
|
||||
for cmd, desc in cmd_manager.list_commands()
|
||||
if cmd.startswith(prefix)
|
||||
]
|
||||
if len(matches) == 1 and matches[0][0] == prefix:
|
||||
from ..commands._completion_engine import compute_completions
|
||||
|
||||
# ``ChatTextArea`` (Textual ``TextArea`` subclass) doesn't
|
||||
# expose ``cursor_position`` directly; the public
|
||||
# ``cursor_location`` is a (row, col) namedtuple. For
|
||||
# completion we only need the prefix up to the cursor,
|
||||
# and in practice the user is always typing at the end
|
||||
# of the input — so ``len(text)`` is the correct offset
|
||||
# without needing to walk the document line model.
|
||||
result = compute_completions(
|
||||
event.text_area.text, len(event.text_area.text)
|
||||
)
|
||||
if result.kind == "empty" or not result.candidates:
|
||||
self._hide_completions()
|
||||
return
|
||||
if matches:
|
||||
self._comp_items = matches
|
||||
self._comp_index = -1
|
||||
self._render_completions()
|
||||
comp_widget.display = True
|
||||
return
|
||||
|
||||
self._comp_items = sorted(result.candidates, key=lambda c: c.text)
|
||||
self._comp_index = -1
|
||||
self._render_completions()
|
||||
comp_widget.display = True
|
||||
return
|
||||
self._hide_completions()
|
||||
|
||||
def _render_queue_indicator(self) -> None:
|
||||
@@ -2627,28 +2649,32 @@ def run_textual_interactive(
|
||||
return True
|
||||
|
||||
def _apply_selected_completion(self) -> None:
|
||||
"""Apply the currently selected completion to the input field.
|
||||
|
||||
For ``@file`` completions the last ``@token`` is replaced in-place;
|
||||
for slash-command completions the entire input is replaced.
|
||||
"""
|
||||
"""Apply the currently selected completion to the input field."""
|
||||
if self._comp_index < 0 or self._comp_index >= len(self._comp_items):
|
||||
return
|
||||
selected = self._comp_items[self._comp_index][0]
|
||||
candidate = self._comp_items[self._comp_index]
|
||||
prompt = self.query_one("#prompt", ChatTextArea)
|
||||
|
||||
if selected.startswith("@"):
|
||||
if candidate.text.startswith("@"):
|
||||
import re as _re
|
||||
|
||||
current = prompt.value
|
||||
m = _re.search(r"@[^\s]*$", current)
|
||||
if m:
|
||||
new_val = current[: m.start()] + selected + " "
|
||||
new_val = current[: m.start()] + candidate.text + " "
|
||||
else:
|
||||
new_val = current + selected + " "
|
||||
new_val = current + candidate.text + " "
|
||||
prompt.value = new_val
|
||||
else:
|
||||
prompt.value = selected + " "
|
||||
current = prompt.value
|
||||
# If the suffix already starts with a space (e.g. user
|
||||
# typed ``/mcp a `` and the engine excluded the trailing
|
||||
# space from ``replace_end``), don't add another one.
|
||||
suffix = current[candidate.replace_end :]
|
||||
sep = "" if suffix.startswith(" ") else " "
|
||||
prompt.value = (
|
||||
current[: candidate.replace_start] + candidate.text + sep + suffix
|
||||
)
|
||||
|
||||
def _hide_completions(self) -> None:
|
||||
self._comp_items = []
|
||||
@@ -2658,7 +2684,8 @@ def run_textual_interactive(
|
||||
|
||||
def _render_completions(self) -> None:
|
||||
comp_text = Text()
|
||||
for i, (cmd, desc) in enumerate(self._comp_items):
|
||||
for i, candidate in enumerate(self._comp_items):
|
||||
cmd, desc = candidate.text, candidate.description
|
||||
if i == self._comp_index:
|
||||
comp_text.append("\u25b8 ", style="bold")
|
||||
comp_text.append(f"{cmd:<30}", style="bold")
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class CompletionKind(StrEnum):
|
||||
"""Discriminator for the kind of completion result."""
|
||||
|
||||
COMMANDS = "commands"
|
||||
SUBCOMMANDS = "subcommands"
|
||||
EMPTY = "empty"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionCandidate:
|
||||
"""A single completion suggestion with its replacement range."""
|
||||
|
||||
text: str
|
||||
description: str
|
||||
replace_start: int
|
||||
replace_end: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionResult:
|
||||
"""The result of parsing a slash command input for completions."""
|
||||
|
||||
kind: CompletionKind
|
||||
candidates: list[CompletionCandidate]
|
||||
|
||||
|
||||
def compute_completions(text: str, cursor_pos: int) -> CompletionResult:
|
||||
"""Parse *text* up to *cursor_pos* and return completion candidates.
|
||||
|
||||
This is the shared engine used by both the Rich CLI
|
||||
(``SlashCommandCompleter``) and the TUI (``on_text_area_changed``).
|
||||
Both thin adapters only need to translate the returned candidates
|
||||
into their respective render/apply primitives.
|
||||
"""
|
||||
from .manager import manager as cmd_manager
|
||||
|
||||
before = text[:cursor_pos]
|
||||
|
||||
if not before.startswith("/"):
|
||||
return CompletionResult(CompletionKind.EMPTY, [])
|
||||
|
||||
parts = before.split()
|
||||
if not parts:
|
||||
return CompletionResult(CompletionKind.EMPTY, [])
|
||||
|
||||
cmd_name = parts[0].lower()
|
||||
has_trailing_space = before.endswith(" ")
|
||||
|
||||
# --- Top-level command completion ---
|
||||
if len(parts) == 1:
|
||||
prefix = before.lower().rstrip()
|
||||
commands = cmd_manager.list_commands()
|
||||
matches = [(c, d) for c, d in commands if c.startswith(prefix)]
|
||||
|
||||
# Exact match with no trailing space → hide
|
||||
if len(matches) == 1 and matches[0][0] == prefix and not has_trailing_space:
|
||||
return CompletionResult(CompletionKind.EMPTY, [])
|
||||
|
||||
# Exact match + trailing space + has subcommands → show subcommands
|
||||
if len(matches) == 1 and matches[0][0] == prefix and has_trailing_space:
|
||||
if not cmd_manager.get_subcommands(prefix):
|
||||
return CompletionResult(CompletionKind.EMPTY, [])
|
||||
sub_items = cmd_manager.list_subcommands(cmd_name)
|
||||
if sub_items:
|
||||
insert_pos = len(before)
|
||||
return CompletionResult(
|
||||
CompletionKind.SUBCOMMANDS,
|
||||
[
|
||||
CompletionCandidate(
|
||||
text=name,
|
||||
description=desc,
|
||||
replace_start=insert_pos,
|
||||
replace_end=insert_pos,
|
||||
)
|
||||
for name, desc in sub_items
|
||||
],
|
||||
)
|
||||
|
||||
if matches:
|
||||
return CompletionResult(
|
||||
CompletionKind.COMMANDS,
|
||||
[
|
||||
CompletionCandidate(
|
||||
text=cmd,
|
||||
description=desc,
|
||||
replace_start=0,
|
||||
replace_end=len(before),
|
||||
)
|
||||
for cmd, desc in matches
|
||||
],
|
||||
)
|
||||
|
||||
return CompletionResult(CompletionKind.EMPTY, [])
|
||||
|
||||
# --- Subcommand completion (len(parts) >= 2) ---
|
||||
if len(parts) >= 3:
|
||||
return CompletionResult(CompletionKind.EMPTY, [])
|
||||
|
||||
cmd = cmd_manager.get_command(cmd_name)
|
||||
if cmd is None or not cmd.subcommands:
|
||||
return CompletionResult(CompletionKind.EMPTY, [])
|
||||
|
||||
sub_prefix = parts[1].lower()
|
||||
sub_matches = [
|
||||
(name, desc)
|
||||
for name, desc in cmd_manager.list_subcommands(cmd_name)
|
||||
if name.startswith(sub_prefix)
|
||||
]
|
||||
|
||||
if not sub_matches:
|
||||
return CompletionResult(CompletionKind.EMPTY, [])
|
||||
|
||||
# Exact match (sub_prefix == name) → the subcommand is already
|
||||
# complete. Hide regardless of trailing space — the user is done
|
||||
# with the subcommand and ready to type arguments. Without this
|
||||
# guard, Tab on ``/mcp list`` re-inserts ``list`` and ``/mcp list ``
|
||||
# oscillates between adding and removing the trailing space.
|
||||
if len(sub_matches) == 1 and sub_matches[0][0] == sub_prefix:
|
||||
return CompletionResult(CompletionKind.EMPTY, [])
|
||||
|
||||
sub_start = before.rfind(sub_prefix) if sub_prefix else len(before)
|
||||
if sub_start < 0:
|
||||
sub_start = len(parts[0]) + 1
|
||||
# When the user has typed a trailing space, exclude it from the
|
||||
# replace range — the apply step preserves it via
|
||||
# ``current[replace_end:]`` so we don't double up the space.
|
||||
replace_end = len(before) - 1 if has_trailing_space else len(before)
|
||||
|
||||
return CompletionResult(
|
||||
CompletionKind.SUBCOMMANDS,
|
||||
[
|
||||
CompletionCandidate(
|
||||
text=name,
|
||||
description=desc,
|
||||
replace_start=sub_start,
|
||||
replace_end=replace_end,
|
||||
)
|
||||
for name, desc in sub_matches
|
||||
],
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, ClassVar, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@@ -15,6 +15,15 @@ class Argument:
|
||||
required: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubCommand:
|
||||
"""A subcommand of a parent slash command."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
arguments: list[Argument] = field(default_factory=list)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CommandUI(Protocol):
|
||||
"""Protocol for UI operations that commands can perform."""
|
||||
@@ -91,6 +100,7 @@ class Command(ABC):
|
||||
alias: ClassVar[list[str]] = []
|
||||
description: str
|
||||
arguments: ClassVar[list[Argument]] = []
|
||||
subcommands: ClassVar[list[SubCommand]] = []
|
||||
# When False, callers may dispatch this command without waiting for
|
||||
# the background agent load to finish — important so recovery
|
||||
# commands like ``/mcp add`` can run even when the MCP load is
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from ..base import Command, CommandContext
|
||||
from ..base import Command, CommandContext, SubCommand
|
||||
from ..manager import manager
|
||||
|
||||
|
||||
@@ -13,6 +15,10 @@ class ChannelCommand(Command):
|
||||
|
||||
name = "/channel"
|
||||
description = "Configure messaging channels"
|
||||
subcommands: ClassVar[list[SubCommand]] = [
|
||||
SubCommand("status", "Show running channel status"),
|
||||
SubCommand("stop", "Stop a running channel"),
|
||||
]
|
||||
|
||||
def needs_agent(self, args: list[str]) -> bool:
|
||||
# ``status`` and ``stop`` are introspection / teardown; they
|
||||
|
||||
@@ -29,6 +29,9 @@ class HelpCommand(Command):
|
||||
if cmd.alias:
|
||||
desc += f" (aliases: {', '.join(cmd.alias)})"
|
||||
help_text.append(f"{desc}\n", style="dim")
|
||||
if cmd.subcommands:
|
||||
names = ", ".join(sc.name for sc in cmd.subcommands)
|
||||
help_text.append(f" subcommands: {names}\n", style="dim italic")
|
||||
ctx.ui.mount_renderable(help_text)
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
from ..base import Command, CommandContext
|
||||
from ..base import Command, CommandContext, SubCommand
|
||||
from ..manager import manager
|
||||
|
||||
|
||||
@@ -11,8 +13,17 @@ class MCPCommand(Command):
|
||||
|
||||
name = "/mcp"
|
||||
description = "Manage MCP servers"
|
||||
subcommands: ClassVar[list[SubCommand]] = [
|
||||
SubCommand("list", "List configured MCP servers"),
|
||||
SubCommand("config", "Show server configuration details"),
|
||||
SubCommand("add", "Add a new MCP server"),
|
||||
SubCommand("edit", "Edit an MCP server configuration"),
|
||||
SubCommand("remove", "Remove an MCP server"),
|
||||
SubCommand("install", "Browse and install MCP servers"),
|
||||
]
|
||||
|
||||
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
|
||||
"""Dispatch to the appropriate MCP subcommand."""
|
||||
if not args or args[0] == "list":
|
||||
await self._mcp_list(ctx)
|
||||
return
|
||||
@@ -34,25 +45,13 @@ class MCPCommand(Command):
|
||||
await InstallMCPCommand().execute(ctx, subargs)
|
||||
else:
|
||||
ctx.ui.append_system("MCP commands:", style="bold")
|
||||
ctx.ui.append_system(
|
||||
" /mcp List configured servers", style="dim"
|
||||
)
|
||||
ctx.ui.append_system(
|
||||
" /mcp list List configured servers", style="dim"
|
||||
)
|
||||
ctx.ui.append_system(
|
||||
" /mcp config Show detailed server config", style="dim"
|
||||
)
|
||||
ctx.ui.append_system(" /mcp add ... Add a server", style="dim")
|
||||
ctx.ui.append_system(
|
||||
" /mcp edit ... Edit an existing server", style="dim"
|
||||
)
|
||||
ctx.ui.append_system(" /mcp remove ... Remove a server", style="dim")
|
||||
ctx.ui.append_system(
|
||||
" /mcp install ... Browse and install servers", style="dim"
|
||||
)
|
||||
for sub in self.subcommands:
|
||||
ctx.ui.append_system(
|
||||
f" /mcp {sub.name:<12} {sub.description}", style="dim"
|
||||
)
|
||||
|
||||
async def _mcp_list(self, ctx: CommandContext) -> None:
|
||||
"""Display a table of all configured MCP servers."""
|
||||
from ...mcp import load_mcp_config
|
||||
from ...mcp.client import USER_MCP_CONFIG
|
||||
|
||||
@@ -85,6 +84,7 @@ class MCPCommand(Command):
|
||||
ctx.ui.append_system(f"Config file: {USER_MCP_CONFIG}", style="dim")
|
||||
|
||||
async def _mcp_config(self, ctx: CommandContext, name: str) -> None:
|
||||
"""Show detailed configuration for one or all MCP servers."""
|
||||
from ...mcp import load_mcp_config
|
||||
from ...mcp.client import USER_MCP_CONFIG
|
||||
|
||||
@@ -133,6 +133,7 @@ class MCPCommand(Command):
|
||||
ctx.ui.append_system(f"Config file: {USER_MCP_CONFIG}", style="dim")
|
||||
|
||||
async def _mcp_add(self, ctx: CommandContext, tokens: list[str]) -> None:
|
||||
"""Add a new MCP server from parsed arguments."""
|
||||
from ...mcp import add_mcp_server, parse_mcp_add_args
|
||||
|
||||
if not tokens:
|
||||
@@ -153,6 +154,7 @@ class MCPCommand(Command):
|
||||
ctx.ui.append_system(f"Error: {exc}", style="red")
|
||||
|
||||
async def _mcp_edit(self, ctx: CommandContext, tokens: list[str]) -> None:
|
||||
"""Edit fields of an existing MCP server configuration."""
|
||||
from ...mcp import edit_mcp_server, parse_mcp_edit_args
|
||||
|
||||
if not tokens:
|
||||
@@ -170,6 +172,7 @@ class MCPCommand(Command):
|
||||
ctx.ui.append_system(f"Error: {exc}", style="red")
|
||||
|
||||
async def _mcp_remove(self, ctx: CommandContext, name: str) -> None:
|
||||
"""Remove an MCP server by name."""
|
||||
from ...mcp import remove_mcp_server
|
||||
|
||||
if not name:
|
||||
|
||||
@@ -8,21 +8,12 @@ from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from ..base import Argument, Command, CommandContext
|
||||
from ..base import Argument, Command, CommandContext, SubCommand
|
||||
from ..manager import manager
|
||||
|
||||
|
||||
class ModelFallbackCommand(Command):
|
||||
"""Manage the model fallback chain.
|
||||
|
||||
Subcommands:
|
||||
add -- Append a model (interactive picker when no args).
|
||||
remove -- Remove by position (interactive picker in TUI).
|
||||
list -- Display the current chain.
|
||||
clear -- Remove all entries.
|
||||
save -- Persist the chain to the config file.
|
||||
help -- Show subcommand reference.
|
||||
"""
|
||||
"""Manage the model fallback chain."""
|
||||
|
||||
name = "/model-fallback"
|
||||
alias: ClassVar[list[str]] = ["/fallback"]
|
||||
@@ -35,6 +26,14 @@ class ModelFallbackCommand(Command):
|
||||
required=False,
|
||||
),
|
||||
]
|
||||
subcommands: ClassVar[list[SubCommand]] = [
|
||||
SubCommand("list", "Display the current fallback chain"),
|
||||
SubCommand("add", "Append a model to the fallback chain"),
|
||||
SubCommand("remove", "Remove a model by position"),
|
||||
SubCommand("clear", "Remove all fallback entries"),
|
||||
SubCommand("save", "Persist the chain to config"),
|
||||
SubCommand("help", "Show subcommand reference"),
|
||||
]
|
||||
|
||||
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
|
||||
from ...llm.models import MODELS
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import shlex
|
||||
|
||||
from .base import Command, CommandContext
|
||||
from .base import Command, CommandContext, SubCommand
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,6 +58,17 @@ class CommandManager:
|
||||
seen.add(cmd)
|
||||
return results
|
||||
|
||||
def get_subcommands(self, command_name: str) -> list[SubCommand]:
|
||||
"""Return subcommands declared by *command_name*, or empty list."""
|
||||
cmd = self.get_command(command_name)
|
||||
if cmd is None:
|
||||
return []
|
||||
return cmd.subcommands
|
||||
|
||||
def list_subcommands(self, command_name: str) -> list[tuple[str, str]]:
|
||||
"""Return ``(name, description)`` pairs for completion rendering."""
|
||||
return [(sc.name, sc.description) for sc in self.get_subcommands(command_name)]
|
||||
|
||||
def get_all_commands(self) -> list[Command]:
|
||||
"""Return all registered command instances."""
|
||||
seen = set()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Tests for CLI SlashCommandCompleter (prompt_toolkit adapter)."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from EvoScientist.cli.interactive import SlashCommandCompleter
|
||||
|
||||
|
||||
def _doc(text: str):
|
||||
"""Create a minimal prompt_toolkit Document stub."""
|
||||
doc = MagicMock()
|
||||
doc.text_before_cursor = text
|
||||
return doc
|
||||
|
||||
|
||||
class TestSlashCommandCompleter:
|
||||
"""Verify that ``SlashCommandCompleter.get_completions`` correctly
|
||||
delegates to the shared ``compute_completions`` engine and translates
|
||||
candidates into prompt_toolkit ``Completion`` objects.
|
||||
"""
|
||||
|
||||
def test_top_level_slash_shows_commands(self):
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer.get_completions(_doc("/he"), None))
|
||||
texts = {c.text for c in completions}
|
||||
assert "/help" in texts
|
||||
|
||||
def test_exact_command_no_space_hides(self):
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer.get_completions(_doc("/help"), None))
|
||||
assert completions == []
|
||||
|
||||
def test_non_slash_returns_empty(self):
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer.get_completions(_doc("hello"), None))
|
||||
assert completions == []
|
||||
|
||||
def test_trailing_space_shows_subcommands(self):
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer.get_completions(_doc("/mcp "), None))
|
||||
texts = {c.text for c in completions}
|
||||
assert "list" in texts
|
||||
assert "add" in texts
|
||||
|
||||
def test_subcommand_prefix_filters(self):
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer.get_completions(_doc("/mcp lis"), None))
|
||||
texts = {c.text for c in completions}
|
||||
assert texts == {"list"}
|
||||
|
||||
def test_exact_subcommand_hides(self):
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer.get_completions(_doc("/mcp list"), None))
|
||||
assert completions == []
|
||||
|
||||
def test_results_sorted_alphabetically(self):
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer.get_completions(_doc("/"), None))
|
||||
texts = [c.text for c in completions]
|
||||
assert texts == sorted(texts)
|
||||
|
||||
def test_display_meta_is_description(self):
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer.get_completions(_doc("/he"), None))
|
||||
for c in completions:
|
||||
if c.text == "/help":
|
||||
assert c.display_meta is not None
|
||||
|
||||
def test_subcommand_completions_sorted(self):
|
||||
completer = SlashCommandCompleter()
|
||||
completions = list(completer.get_completions(_doc("/mcp "), None))
|
||||
texts = [c.text for c in completions]
|
||||
assert texts == sorted(texts)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Tests for CommandManager subcommand metadata."""
|
||||
|
||||
from EvoScientist.commands.base import SubCommand
|
||||
from EvoScientist.commands.manager import CommandManager
|
||||
|
||||
|
||||
class TestSubCommand:
|
||||
def test_creation_with_defaults(self):
|
||||
sc = SubCommand("list", "List servers")
|
||||
assert sc.name == "list"
|
||||
assert sc.description == "List servers"
|
||||
assert sc.arguments == []
|
||||
|
||||
def test_creation_with_arguments(self):
|
||||
from EvoScientist.commands.base import Argument
|
||||
|
||||
sc = SubCommand(
|
||||
"add",
|
||||
"Add a server",
|
||||
arguments=[Argument("name", str, "Server name", required=True)],
|
||||
)
|
||||
assert len(sc.arguments) == 1
|
||||
assert sc.arguments[0].name == "name"
|
||||
|
||||
|
||||
class TestCommandManagerSubcommands:
|
||||
def test_mcp_has_six_subcommands(self):
|
||||
"""``/mcp`` must expose all 6 subcommands via the manager."""
|
||||
manager = CommandManager()
|
||||
from EvoScientist.commands.implementation.mcp import MCPCommand
|
||||
|
||||
manager.register(MCPCommand())
|
||||
scs = manager.list_subcommands("/mcp")
|
||||
names = {name for name, _desc in scs}
|
||||
assert names == {"list", "config", "add", "edit", "remove", "install"}
|
||||
|
||||
def test_model_fallback_has_subcommands(self):
|
||||
"""``/model-fallback`` must expose its subcommands."""
|
||||
manager = CommandManager()
|
||||
from EvoScientist.commands.implementation.model_fallback import (
|
||||
ModelFallbackCommand,
|
||||
)
|
||||
|
||||
manager.register(ModelFallbackCommand())
|
||||
scs = manager.list_subcommands("/model-fallback")
|
||||
names = {name for name, _desc in scs}
|
||||
assert {"list", "add", "remove", "clear", "save", "help"} <= names
|
||||
|
||||
def test_channel_has_status_stop(self):
|
||||
"""``/channel`` must expose status + stop subcommands."""
|
||||
manager = CommandManager()
|
||||
from EvoScientist.commands.implementation.channel import ChannelCommand
|
||||
|
||||
manager.register(ChannelCommand())
|
||||
scs = manager.list_subcommands("/channel")
|
||||
names = {name for name, _desc in scs}
|
||||
assert names == {"status", "stop"}
|
||||
|
||||
def test_command_without_subcommands_returns_empty(self):
|
||||
"""A command with no subcommands must return an empty list."""
|
||||
manager = CommandManager()
|
||||
from EvoScientist.commands.implementation.general import HelpCommand
|
||||
|
||||
manager.register(HelpCommand())
|
||||
assert manager.list_subcommands("/help") == []
|
||||
assert manager.get_subcommands("/help") == []
|
||||
|
||||
def test_unknown_command_returns_empty(self):
|
||||
"""get_subcommands for a nonexistent command must return empty list."""
|
||||
manager = CommandManager()
|
||||
assert manager.list_subcommands("/nonexistent") == []
|
||||
assert manager.get_subcommands("/nonexistent") == []
|
||||
|
||||
def test_subcommand_via_alias(self):
|
||||
"""Registry by alias should still expose subcommands."""
|
||||
manager = CommandManager()
|
||||
from EvoScientist.commands.implementation.model_fallback import (
|
||||
ModelFallbackCommand,
|
||||
)
|
||||
|
||||
manager.register(ModelFallbackCommand())
|
||||
scs = manager.list_subcommands("/fallback")
|
||||
names = {name for name, _desc in scs}
|
||||
assert {"list", "add", "remove"} <= names
|
||||
+152
-58
@@ -724,21 +724,30 @@ class TestCompletionLogic(unittest.TestCase):
|
||||
("#completions", None): fake_completions,
|
||||
}
|
||||
|
||||
from EvoScientist.commands import manager as cmd_manager
|
||||
|
||||
_slash_commands = cmd_manager.list_commands()
|
||||
|
||||
# Build stub --------------------------------------------------------
|
||||
class _StubApp:
|
||||
"""Minimal stub that shares the real completion method bodies."""
|
||||
|
||||
def __init__(self):
|
||||
self._comp_items: list = list(comp_items or [])
|
||||
from EvoScientist.commands._completion_engine import CompletionCandidate
|
||||
|
||||
self._comp_items = []
|
||||
for item in comp_items or []:
|
||||
if hasattr(item, "replace_start"):
|
||||
self._comp_items.append(item)
|
||||
else:
|
||||
text, desc = item[0], item[1]
|
||||
self._comp_items.append(
|
||||
CompletionCandidate(
|
||||
text=text,
|
||||
description=desc,
|
||||
replace_start=0,
|
||||
replace_end=0,
|
||||
)
|
||||
)
|
||||
self._comp_index: int = comp_index
|
||||
# Expose fakes for assertions
|
||||
self._fake_input = fake_input
|
||||
self._fake_completions = fake_completions
|
||||
self._SLASH_COMMANDS = _slash_commands
|
||||
|
||||
def query_one(self, selector, widget_type=None):
|
||||
# Match by selector string; widget_type is ignored in stub
|
||||
@@ -759,9 +768,20 @@ class TestCompletionLogic(unittest.TestCase):
|
||||
self._apply_selected_completion()
|
||||
|
||||
def _apply_selected_completion(self):
|
||||
selected_cmd = self._comp_items[self._comp_index][0]
|
||||
candidate = self._comp_items[self._comp_index]
|
||||
prompt = self.query_one("#prompt")
|
||||
prompt.value = selected_cmd + " "
|
||||
if candidate.text.startswith("@"):
|
||||
prompt.value = candidate.text + " "
|
||||
else:
|
||||
current = prompt.value
|
||||
suffix = current[candidate.replace_end :]
|
||||
sep = "" if suffix.startswith(" ") else " "
|
||||
prompt.value = (
|
||||
current[: candidate.replace_start]
|
||||
+ candidate.text
|
||||
+ sep
|
||||
+ suffix
|
||||
)
|
||||
prompt.cursor_position = len(prompt.value)
|
||||
self._render_completions()
|
||||
|
||||
@@ -774,7 +794,8 @@ class TestCompletionLogic(unittest.TestCase):
|
||||
def _render_completions(self):
|
||||
comp_widget = self.query_one("#completions")
|
||||
comp_text = Text()
|
||||
for i, (cmd, desc) in enumerate(self._comp_items):
|
||||
for i, candidate in enumerate(self._comp_items):
|
||||
cmd, desc = candidate.text, candidate.description
|
||||
if i == self._comp_index:
|
||||
comp_text.append("\u25b8 ", style="bold")
|
||||
comp_text.append(f"{cmd:<22}", style="bold")
|
||||
@@ -787,27 +808,6 @@ class TestCompletionLogic(unittest.TestCase):
|
||||
comp_text.append("\n")
|
||||
comp_widget.update(comp_text)
|
||||
|
||||
def on_input_changed(self, text: str):
|
||||
"""Simplified version matching the real on_input_changed logic."""
|
||||
comp_widget = self.query_one("#completions")
|
||||
if text.startswith("/"):
|
||||
prefix = text.lower()
|
||||
matches = [
|
||||
(cmd, desc)
|
||||
for cmd, desc in self._SLASH_COMMANDS
|
||||
if cmd.startswith(prefix)
|
||||
]
|
||||
if len(matches) == 1 and matches[0][0] == prefix:
|
||||
self._hide_completions()
|
||||
return
|
||||
if matches:
|
||||
self._comp_items = matches
|
||||
self._comp_index = -1
|
||||
self._render_completions()
|
||||
comp_widget.display = True
|
||||
return
|
||||
self._hide_completions()
|
||||
|
||||
def on_key(self, key: str):
|
||||
"""Simplified version matching the real on_key logic.
|
||||
|
||||
@@ -930,40 +930,134 @@ class TestCompletionLogic(unittest.TestCase):
|
||||
assert arrow_in_bold, f"No bold arrow found. Spans: {bold_spans}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# on_input_changed
|
||||
# compute_completions (shared engine)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_input_changed_slash_shows_completions(self):
|
||||
"""/re prefix should show matching commands."""
|
||||
app = self._make_app()
|
||||
app.on_input_changed("/re")
|
||||
assert app._fake_completions.display is True
|
||||
assert len(app._comp_items) > 0
|
||||
assert all(cmd.startswith("/re") for cmd, _ in app._comp_items)
|
||||
def test_engine_slash_shows_top_level_commands(self):
|
||||
from EvoScientist.commands._completion_engine import compute_completions
|
||||
|
||||
def test_input_changed_exact_match_hides_completions(self):
|
||||
"""An exact match for a command should hide completions."""
|
||||
app = self._make_app()
|
||||
# /help is the only command starting with /help
|
||||
app.on_input_changed("/help")
|
||||
assert app._fake_completions.display is False
|
||||
result = compute_completions("/re", 3)
|
||||
assert result.kind == "commands"
|
||||
assert len(result.candidates) > 0
|
||||
assert all(c.text.startswith("/re") for c in result.candidates)
|
||||
|
||||
def test_input_changed_non_slash_hides_completions(self):
|
||||
"""Regular text (no leading slash) should hide completions."""
|
||||
items = [("/resume", "d")]
|
||||
app = self._make_app(comp_items=items)
|
||||
app._fake_completions.display = True
|
||||
def test_engine_exact_match_no_space_hides(self):
|
||||
from EvoScientist.commands._completion_engine import compute_completions
|
||||
|
||||
app.on_input_changed("hello world")
|
||||
assert app._fake_completions.display is False
|
||||
result = compute_completions("/help", 5)
|
||||
assert result.kind == "empty"
|
||||
|
||||
def test_input_changed_no_match_hides_completions(self):
|
||||
"""A /prefix that matches nothing should hide completions."""
|
||||
app = self._make_app()
|
||||
app._fake_completions.display = True
|
||||
def test_engine_non_slash_returns_empty(self):
|
||||
from EvoScientist.commands._completion_engine import compute_completions
|
||||
|
||||
app.on_input_changed("/zzznomatch")
|
||||
assert app._fake_completions.display is False
|
||||
result = compute_completions("hello", 5)
|
||||
assert result.kind == "empty"
|
||||
|
||||
def test_engine_trailing_space_shows_subcommands(self):
|
||||
from EvoScientist.commands._completion_engine import compute_completions
|
||||
|
||||
result = compute_completions("/mcp ", 5)
|
||||
assert result.kind == "subcommands"
|
||||
names = {c.text for c in result.candidates}
|
||||
assert "list" in names
|
||||
assert "add" in names
|
||||
|
||||
def test_engine_subcommand_prefix_filters(self):
|
||||
from EvoScientist.commands._completion_engine import compute_completions
|
||||
|
||||
result = compute_completions("/mcp lis", 8)
|
||||
assert result.kind == "subcommands"
|
||||
names = {c.text for c in result.candidates}
|
||||
assert names == {"list"}
|
||||
|
||||
def test_engine_exact_subcommand_hides(self):
|
||||
"""When the user has already typed the full subcommand (no
|
||||
trailing space), the engine should hide — Tab shouldn't re-insert
|
||||
the same subcommand. Mirrors the top-level exact-match rule.
|
||||
"""
|
||||
from EvoScientist.commands._completion_engine import compute_completions
|
||||
|
||||
result = compute_completions("/mcp list", 9)
|
||||
assert result.kind == "empty"
|
||||
assert result.candidates == []
|
||||
|
||||
def test_engine_exact_subcommand_with_trailing_space_hides(self):
|
||||
"""When the user has typed the full subcommand plus a trailing
|
||||
space (``/mcp list ``), the engine must also hide. Without this
|
||||
guard Tab oscillates between adding and removing the trailing
|
||||
whitespace.
|
||||
"""
|
||||
from EvoScientist.commands._completion_engine import compute_completions
|
||||
|
||||
result = compute_completions("/mcp list ", 10)
|
||||
assert result.kind == "empty"
|
||||
assert result.candidates == []
|
||||
|
||||
def test_engine_three_parts_hides(self):
|
||||
from EvoScientist.commands._completion_engine import compute_completions
|
||||
|
||||
result = compute_completions("/mcp list a", 11)
|
||||
assert result.kind == "empty"
|
||||
|
||||
def test_engine_non_subcommand_cmd_hides(self):
|
||||
from EvoScientist.commands._completion_engine import compute_completions
|
||||
|
||||
result = compute_completions("/help ", 6)
|
||||
assert result.kind == "empty"
|
||||
|
||||
def test_engine_subcommand_replace_range(self):
|
||||
from EvoScientist.commands._completion_engine import compute_completions
|
||||
|
||||
result = compute_completions("/mcp lis", 8)
|
||||
assert result.candidates[0].replace_start == 5
|
||||
assert result.candidates[0].replace_end == 8
|
||||
|
||||
def test_engine_subcommand_trailing_space_excludes_space_from_range(self):
|
||||
"""When the user has typed a partial subcommand prefix + a
|
||||
trailing space (e.g. ``/mcp a ``), the engine's replace range
|
||||
must exclude the trailing space — otherwise the apply step
|
||||
would produce a double space (``/mcp add ``).
|
||||
"""
|
||||
from EvoScientist.commands._completion_engine import compute_completions
|
||||
|
||||
text = "/mcp a "
|
||||
result = compute_completions(text, len(text)) # cursor at end
|
||||
assert result.candidates
|
||||
for c in result.candidates:
|
||||
# ``a`` is at position 5; trailing space at position 6.
|
||||
# ``replace_start=5`` (start of the partial prefix),
|
||||
# ``replace_end=6`` (right after the prefix, before the
|
||||
# trailing space — so the trailing space is preserved in
|
||||
# the suffix during apply).
|
||||
assert c.replace_start == 5
|
||||
assert c.replace_end == 6
|
||||
|
||||
def test_engine_subcommand_trailing_space_apply_does_not_double_space(self):
|
||||
"""Applying the completion for ``/mcp a `` + accept 'add' must
|
||||
not produce ``/mcp add `` (double space). The engine's
|
||||
replace range excludes the trailing space; the apply step
|
||||
must skip the separator when the suffix already starts with one.
|
||||
"""
|
||||
from EvoScientist.commands._completion_engine import compute_completions
|
||||
|
||||
text = "/mcp a "
|
||||
result = compute_completions(text, len(text))
|
||||
c = result.candidates[0]
|
||||
current = text
|
||||
# Apply logic that mirrors the TUI ``_apply_selected_completion``.
|
||||
suffix = current[c.replace_end :]
|
||||
sep = "" if suffix.startswith(" ") else " "
|
||||
new_value = current[: c.replace_start] + c.text + sep + suffix
|
||||
# Expected: ``/mcp add `` — the ``a`` is replaced with ``add``,
|
||||
# the trailing space is preserved via the suffix. No double space.
|
||||
assert new_value == f"/mcp {c.text} "
|
||||
|
||||
def test_engine_trailing_space_replace_range(self):
|
||||
from EvoScientist.commands._completion_engine import compute_completions
|
||||
|
||||
result = compute_completions("/mcp ", 5)
|
||||
assert result.candidates[0].replace_start == 5
|
||||
assert result.candidates[0].replace_end == 5
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# on_key (enter only — up/down handled by priority bindings)
|
||||
|
||||
Reference in New Issue
Block a user