feat(cli): multi-stage slash command completions with subcommand awareness (Phase 1 of #82) (#273)

* 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:
houren Antony
2026-06-14 01:06:23 +08:00
committed by GitHub
parent 8f9dfa159e
commit 76972449c7
12 changed files with 596 additions and 126 deletions
+25 -10
View File
@@ -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
)
# =============================================================================
+53 -26
View File
@@ -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")
+146
View File
@@ -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
],
)
+11 -1
View File
@@ -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)
+21 -18
View File
@@ -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
+12 -1
View File
@@ -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()
+72
View File
@@ -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)
+84
View File
@@ -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
View File
@@ -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)