diff --git a/EvoScientist/cli/interactive.py b/EvoScientist/cli/interactive.py index b40b79e..13561c2 100644 --- a/EvoScientist/cli/interactive.py +++ b/EvoScientist/cli/interactive.py @@ -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 + ) # ============================================================================= diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index 8ccddb3..64b9e5c 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -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") diff --git a/EvoScientist/commands/_completion_engine.py b/EvoScientist/commands/_completion_engine.py new file mode 100644 index 0000000..2b52b22 --- /dev/null +++ b/EvoScientist/commands/_completion_engine.py @@ -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 + ], + ) diff --git a/EvoScientist/commands/base.py b/EvoScientist/commands/base.py index 06582f5..27ff2cb 100644 --- a/EvoScientist/commands/base.py +++ b/EvoScientist/commands/base.py @@ -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 diff --git a/EvoScientist/commands/implementation/channel.py b/EvoScientist/commands/implementation/channel.py index 5ee40fd..4cf0f31 100644 --- a/EvoScientist/commands/implementation/channel.py +++ b/EvoScientist/commands/implementation/channel.py @@ -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 diff --git a/EvoScientist/commands/implementation/general.py b/EvoScientist/commands/implementation/general.py index 8680fb5..a19a6dd 100644 --- a/EvoScientist/commands/implementation/general.py +++ b/EvoScientist/commands/implementation/general.py @@ -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) diff --git a/EvoScientist/commands/implementation/mcp.py b/EvoScientist/commands/implementation/mcp.py index b89e22b..6840f52 100644 --- a/EvoScientist/commands/implementation/mcp.py +++ b/EvoScientist/commands/implementation/mcp.py @@ -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: diff --git a/EvoScientist/commands/implementation/model_fallback.py b/EvoScientist/commands/implementation/model_fallback.py index 718dff5..34d03c3 100644 --- a/EvoScientist/commands/implementation/model_fallback.py +++ b/EvoScientist/commands/implementation/model_fallback.py @@ -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 diff --git a/EvoScientist/commands/manager.py b/EvoScientist/commands/manager.py index ce11660..add5faa 100644 --- a/EvoScientist/commands/manager.py +++ b/EvoScientist/commands/manager.py @@ -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() diff --git a/tests/test_cli_completion.py b/tests/test_cli_completion.py new file mode 100644 index 0000000..a9224d1 --- /dev/null +++ b/tests/test_cli_completion.py @@ -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) diff --git a/tests/test_command_manager.py b/tests/test_command_manager.py new file mode 100644 index 0000000..b8c16ed --- /dev/null +++ b/tests/test_command_manager.py @@ -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 diff --git a/tests/test_tui_widgets.py b/tests/test_tui_widgets.py index 32138c8..5037bd6 100644 --- a/tests/test_tui_widgets.py +++ b/tests/test_tui_widgets.py @@ -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)