* 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:
@@ -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
|
||||
Reference in New Issue
Block a user