diff --git a/EvoScientist/cli/file_mentions.py b/EvoScientist/cli/file_mentions.py index 7deca36..787e1e6 100644 --- a/EvoScientist/cli/file_mentions.py +++ b/EvoScientist/cli/file_mentions.py @@ -462,7 +462,7 @@ def complete_file_mention( rel = entry.relative_to(base) suffix = "/" if entry.is_dir() else "" candidates_raw.append(rel.as_posix() + suffix) - except OSError: + except (OSError, ValueError): return [] return [ (_format_mention(r), "dir" if r.endswith("/") else _type_hint(r)) @@ -481,17 +481,17 @@ def complete_file_mention( except OSError: pass - combined = all_files + dir_candidates - - # Determine query: if partial has a slash, search within that subtree if "/" in partial: - # Filter candidates to those starting with the directory prefix + # Search within the subtree for the given directory prefix + combined = all_files + dir_candidates dir_prefix = partial.rsplit("/", 1)[0] + "/" file_query = partial.rsplit("/", 1)[1] subtree = [c for c in combined if c.startswith(dir_prefix)] results = _fuzzy_search(file_query, subtree) else: - results = _fuzzy_search(partial, combined) + # Depth 1 only: top-level files and directories + top_files = [f for f in all_files if "/" not in f] + results = _fuzzy_search(partial, top_files + dir_candidates) if quoted: # User opened a quoted mention — close it for them. diff --git a/EvoScientist/cli/interactive.py b/EvoScientist/cli/interactive.py index a8ee67f..a79c050 100644 --- a/EvoScientist/cli/interactive.py +++ b/EvoScientist/cli/interactive.py @@ -234,40 +234,33 @@ class SlashCommandCompleter(Completer): text = document.text_before_cursor workspace_dir = self._workspace_getter() - # @file mention completion + # Slash command / subcommand completions take priority + if text.startswith("/"): + from ..commands._completion_engine import compute_completions + + result = compute_completions(text, len(text)) + if result.kind != "empty" and result.candidates: + for c in result.candidates: + start_pos = c.replace_start - len(text) + yield Completion( + c.text, + start_position=start_pos, + display_meta=c.description, + ) + return + + # @file mention completion (only for non-command input) if "@" in text: candidates = complete_file_mention(text, workspace_dir) if candidates: - # Replace from the last '@' token import re as _re - m = _re.search(r"@[^\s]*$", text) + m = _re.search(r'@"[^"\n]*$|@[^\s"\']*$', text) start = -len(m.group(0)) if m else 0 for path, type_hint in candidates: yield Completion(path, start_position=start, display_meta=type_hint) return - # Slash command completion - if not text.startswith("/"): - return - - 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 - ) - async def _resolve_startup_session( requested_thread_id: str | None, diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index f39bf30..cdfac47 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -449,6 +449,7 @@ def run_textual_interactive( ] = [] # queued messages to send after current turn self._comp_items: list = [] self._comp_index: int = -1 + self._comp_base: str = "" self._hitl_auto_approve: bool = False self._approval_future: asyncio.Future | None = None self._ask_user_future: asyncio.Future | None = None @@ -2398,58 +2399,59 @@ def run_textual_interactive( self._history_suggester.append_entry(text) self._run_task = asyncio.ensure_future(self._run_turn(text)) + def _build_file_candidates(self, text: str) -> list: + """Build CompletionCandidate list for @file mentions.""" + import re as _re + + from ..commands._completion_engine import CompletionCandidate + + candidates = complete_file_mention(text, self._workspace_dir) + if not candidates: + return [] + m = _re.search(r'@"[^"\n]*$|@[^\s"\']*$', text) + start = m.start() if m else len(text) + return [ + CompletionCandidate( + text=path if path.startswith("@") else f"@{path}", + description=type_hint, + replace_start=start, + replace_end=len(text), + ) + for path, type_hint in candidates + ] + def on_text_area_changed(self, event: ChatTextArea.Changed) -> None: + text = event.text_area.text comp_widget = self.query_one("#completions", Static) - # @file mention completion + # Slash-command completions take priority over @file + if text.startswith("/"): + from ..commands._completion_engine import compute_completions + + result = compute_completions(text, len(text)) + if result.kind == "empty" or not result.candidates: + self._hide_completions() + return + + self._comp_items = result.candidates + self._comp_index = -1 + c0 = result.candidates[0] + self._comp_base = text[: c0.replace_start] + self._render_completions() + comp_widget.display = True + return + + # @file mention completion (only for non-command input) if "@" in text: - candidates = complete_file_mention(text, workspace_dir) - if 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 - ] + file_items = self._build_file_candidates(text) + if file_items: + self._comp_items = file_items self._comp_index = -1 self._render_completions() comp_widget.display = True return - if text.startswith("/"): - 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 - - 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: @@ -2472,6 +2474,10 @@ def run_textual_interactive( def action_cancel_queued(self) -> None: """Cancel the last queued message on Esc.""" + comp_widget = self.query_one("#completions", Static) + if comp_widget.display: + self._hide_completions() + return # Cancel ask_user if active (widget handles Escape internally, # but this is a safety fallback) if self._ask_user_future and not self._ask_user_future.done(): @@ -2541,7 +2547,11 @@ def run_textual_interactive( # Handle completion list selection (up key) comp_widget = self.query_one("#completions", Static) if comp_widget.display and self._comp_items: - self._comp_index = (self._comp_index - 1) % len(self._comp_items) + n = len(self._comp_items) + if self._comp_index <= 0: + self._comp_index = n - 1 + else: + self._comp_index -= 1 self._render_completions() return @@ -2678,19 +2688,43 @@ def run_textual_interactive( prompt.focus() def action_tab_complete(self) -> None: - """Handle TAB: cycle completions when visible, otherwise no-op. - - Registered as a priority binding so it intercepts before Textual's - default focus-next behaviour, which would steal focus from the input - and lose the cursor. - """ + """Handle TAB: apply highlighted completion, or trigger if not visible.""" comp_widget = self.query_one("#completions", Static) if not (comp_widget.display and self._comp_items): - # No completions active — keep focus on the prompt. - self.query_one("#prompt", ChatTextArea).focus() + # Try to trigger completions from current input + prompt = self.query_one("#prompt", ChatTextArea) + prompt.focus() + text = prompt.value + if text.startswith("/"): + from ..commands._completion_engine import compute_completions + + result = compute_completions(text, len(text)) + if result.kind != "empty" and result.candidates: + self._comp_items = result.candidates + self._comp_index = -1 + self._comp_base = text[: result.candidates[0].replace_start] + self._render_completions() + comp_widget.display = True + elif "@" in text: + file_items = self._build_file_candidates(text) + if file_items: + self._comp_items = file_items + self._comp_index = -1 + self._render_completions() + comp_widget.display = True return - self._comp_index = (self._comp_index + 1) % len(self._comp_items) + if self._comp_index < 0: + self._comp_index = 0 + self._render_completions() self._apply_selected_completion() + selected = self._comp_items[self._comp_index] + is_file_dir = selected.text.startswith("@") and selected.text.rstrip( + '"' + ).endswith("/") + if is_file_dir: + return + if selected.text.startswith("@"): + self._hide_completions() def _handle_completion_enter(self) -> bool: """Called by ChatTextArea before submitting on Enter. @@ -2707,16 +2741,26 @@ def run_textual_interactive( if not (comp_widget.display and self._comp_items): return False - # If no item highlighted yet, select the first one if self._comp_index < 0: self._comp_index = 0 - self._apply_selected_completion() - self._hide_completions() + selected = self._comp_items[self._comp_index] + is_file_dir = selected.text.startswith("@") and selected.text.rstrip( + '"' + ).endswith("/") + with self.prevent(ChatTextArea.Changed): + self._apply_selected_completion() + if not is_file_dir: + self._hide_completions() return True def _apply_selected_completion(self) -> None: - """Apply the currently selected completion to the input field.""" + """Apply the currently selected completion to the input field. + + Callers are responsible for suppressing or allowing Changed + events (e.g. ``self.prevent(ChatTextArea.Changed)`` around + the call when the popup should stay hidden). + """ if self._comp_index < 0 or self._comp_index >= len(self._comp_items): return candidate = self._comp_items[self._comp_index] @@ -2725,41 +2769,41 @@ def run_textual_interactive( if candidate.text.startswith("@"): import re as _re + is_dir = candidate.text.rstrip('"').endswith("/") + suffix = "" if is_dir else " " current = prompt.value - m = _re.search(r"@[^\s]*$", current) + m = _re.search(r'@"[^"\n]*$|@[^\s"\']*$', current) if m: - new_val = current[: m.start()] + candidate.text + " " + new_val = current[: m.start()] + candidate.text + suffix else: - new_val = current + candidate.text + " " + new_val = current + candidate.text + suffix prompt.value = new_val else: - 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 - ) + prompt.value = self._comp_base + candidate.text + " " def _hide_completions(self) -> None: self._comp_items = [] self._comp_index = -1 - comp_widget = self.query_one("#completions", Static) - comp_widget.display = False + self.query_one("#completions", Static).display = False def _render_completions(self) -> None: comp_text = Text() + last_cat = "" for i, candidate in enumerate(self._comp_items): cmd, desc = candidate.text, candidate.description + cat = getattr(candidate, "category", "") + if cat and cat != last_cat: + if last_cat: + comp_text.append("\n") + comp_text.append(f" {cat}\n", style="bold #6b7280") + last_cat = cat if i == self._comp_index: - comp_text.append("\u25b8 ", style="bold") - comp_text.append(f"{cmd:<30}", style="bold") + comp_text.append(" \u25b8 ", style="bold") + comp_text.append(f"{cmd:<28}", style="bold") comp_text.append(desc, style="bold") else: - comp_text.append(" ", style="#888888") - comp_text.append(f"{cmd:<30}", style="#888888") + comp_text.append(" ", style="#888888") + comp_text.append(f"{cmd:<28}", style="#888888") comp_text.append(desc, style="#888888") if i < len(self._comp_items) - 1: comp_text.append("\n") diff --git a/EvoScientist/commands/__init__.py b/EvoScientist/commands/__init__.py index 3b9eb24..d7219be 100644 --- a/EvoScientist/commands/__init__.py +++ b/EvoScientist/commands/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations from . import implementation -from .base import Argument, Command, CommandContext, CommandUI +from .base import Argument, Command, CommandContext, CommandUI, SubCommand from .channel_ui import ChannelCommandUI from .manager import CommandManager, manager @@ -12,6 +12,7 @@ __all__ = [ "CommandContext", "CommandManager", "CommandUI", + "SubCommand", "implementation", "manager", ] diff --git a/EvoScientist/commands/_completion_engine.py b/EvoScientist/commands/_completion_engine.py index ac094e2..f693276 100644 --- a/EvoScientist/commands/_completion_engine.py +++ b/EvoScientist/commands/_completion_engine.py @@ -12,6 +12,9 @@ class CompletionKind(StrEnum): EMPTY = "empty" +_CATEGORY_ORDER = ["Session", "Skills", "MCP", "Channels", "Model", "General"] + + @dataclass(frozen=True) class CompletionCandidate: """A single completion suggestion with its replacement range.""" @@ -20,6 +23,7 @@ class CompletionCandidate: description: str replace_start: int replace_end: int + category: str = "" @dataclass(frozen=True) @@ -55,27 +59,25 @@ def compute_completions(text: str, cursor_pos: int) -> CompletionResult: # --- 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)] - # Whether the typed prefix is itself a complete command name. Checked - # via membership rather than ``len(matches) == 1`` so a command whose - # name is a strict prefix of another (e.g. ``/model`` vs - # ``/model-fallback``) still counts as an exact match — otherwise the - # popup never hides on the shorter command and Enter completes instead - # of submitting it. - exact = any(c == prefix for c, _ in matches) + # Match commands by canonical name AND aliases + by_cat: dict[str, list[tuple[str, str]]] = {} + for cmd in cmd_manager.get_all_commands(): + all_names = [cmd.name.lower()] + [ + a.lower() if a.startswith("/") else f"/{a.lower()}" for a in cmd.alias + ] + if any(n.startswith(prefix) for n in all_names): + by_cat.setdefault(cmd.category, []).append((cmd.name, cmd.description)) - # Exact match with no trailing space → hide - if exact and not has_trailing_space: + # Whether the typed prefix resolves to an exact command/alias + exact_cmd = cmd_manager.get_command(prefix) + + if exact_cmd and not has_trailing_space: return CompletionResult(CompletionKind.EMPTY, []) - # Exact match + trailing space + has subcommands → show subcommands - if exact 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: + if exact_cmd and has_trailing_space: + completions = exact_cmd.get_completions([""]) + if completions: insert_pos = len(before) return CompletionResult( CompletionKind.SUBCOMMANDS, @@ -86,59 +88,80 @@ def compute_completions(text: str, cursor_pos: int) -> CompletionResult: replace_start=insert_pos, replace_end=insert_pos, ) - for name, desc in sub_items + for name, desc in completions ], ) + return CompletionResult(CompletionKind.EMPTY, []) - if matches: - return CompletionResult( - CompletionKind.COMMANDS, - [ + all_matches = [v for vs in by_cat.values() for v in vs] + if not all_matches: + return CompletionResult(CompletionKind.EMPTY, []) + + # Build candidates ordered by category + candidates: list[CompletionCandidate] = [] + for cat in _CATEGORY_ORDER: + for cmd_text, desc in by_cat.get(cat, []): + candidates.append( CompletionCandidate( - text=cmd, + text=cmd_text, description=desc, replace_start=0, replace_end=len(before), + category=cat, + ) + ) + for cat, items in by_cat.items(): + if cat not in _CATEGORY_ORDER: + for cmd_text, desc in items: + candidates.append( + CompletionCandidate( + text=cmd_text, + description=desc, + replace_start=0, + replace_end=len(before), + category=cat, + ) ) - for cmd, desc in matches - ], - ) - return CompletionResult(CompletionKind.EMPTY, []) - - # --- Subcommand completion (len(parts) >= 2) --- - if len(parts) >= 3: - return CompletionResult(CompletionKind.EMPTY, []) + return CompletionResult(CompletionKind.COMMANDS, candidates) + # --- Subcommand / argument completion (len(parts) >= 2) --- cmd = cmd_manager.get_command(cmd_name) - if cmd is None or not cmd.subcommands: + if cmd is None: 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: + # Delegate to Command.get_completions for all depths + tokens = parts[1:] + if has_trailing_space: + tokens.append("") + completions = cmd.get_completions(tokens) + if not completions: 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) + # Compute replacement range. + if tokens[-1]: + # User is typing a partial — replace it + sub_start = before.rfind(tokens[-1]) + if sub_start < 0: + sub_start = len(before) + replace_end = len(before) + elif len(tokens) >= 2 and tokens[-2]: + # Trailing space after a token. Check if the previous token is + # a known subcommand name — if so, the completion is for the + # NEXT argument (insert at cursor). If not, the completions + # refine the partial (replace it). + prev = tokens[-2] + is_known_sub = any(sc.name == prev for sc in cmd.subcommands) + if not is_known_sub: + sub_start = before.rfind(prev) + if sub_start < 0: + sub_start = len(before) + else: + sub_start = len(before) + replace_end = len(before) + else: + sub_start = len(before) + replace_end = len(before) return CompletionResult( CompletionKind.SUBCOMMANDS, @@ -149,6 +172,6 @@ def compute_completions(text: str, cursor_pos: int) -> CompletionResult: replace_start=sub_start, replace_end=replace_end, ) - for name, desc in sub_matches + for name, desc in completions ], ) diff --git a/EvoScientist/commands/base.py b/EvoScientist/commands/base.py index f52863a..c125996 100644 --- a/EvoScientist/commands/base.py +++ b/EvoScientist/commands/base.py @@ -104,6 +104,7 @@ class Command(ABC): alias: ClassVar[list[str]] = [] description: str arguments: ClassVar[list[Argument]] = [] + category: ClassVar[str] = "General" subcommands: ClassVar[list[SubCommand]] = [] # When False, callers may dispatch this command without waiting for # the background agent load to finish — important so recovery @@ -120,6 +121,37 @@ class Command(ABC): """ return self.requires_agent + def get_completions(self, tokens: list[str]) -> list[tuple[str, str]]: + """Return completions for args typed after the command name. + + Default walks :attr:`subcommands` for the first positional token + only. Override for deeper levels (e.g. server names, thread IDs). + """ + if not self.subcommands: + return [] + if len(tokens) <= 1: + prefix = tokens[0].lower() if tokens else "" + matches = [ + (sc.name, sc.description) + for sc in self.subcommands + if sc.name.startswith(prefix) + ] + # Exact match — subcommand already complete, hide popup + if len(matches) == 1 and matches[0][0] == prefix: + return [] + return matches + # partial + trailing space: /mcp a → still show "add" + if len(tokens) == 2 and tokens[1] == "": + prefix = tokens[0].lower() + if any(sc.name == prefix for sc in self.subcommands): + return [] + return [ + (sc.name, sc.description) + for sc in self.subcommands + if sc.name.startswith(prefix) + ] + return [] + @abstractmethod async def execute(self, ctx: CommandContext, args: list[str]) -> None: """Execute the command with given context and arguments.""" diff --git a/EvoScientist/commands/implementation/channel.py b/EvoScientist/commands/implementation/channel.py index 4cf0f31..3a19f83 100644 --- a/EvoScientist/commands/implementation/channel.py +++ b/EvoScientist/commands/implementation/channel.py @@ -15,9 +15,18 @@ class ChannelCommand(Command): name = "/channel" description = "Configure messaging channels" + category = "Channels" subcommands: ClassVar[list[SubCommand]] = [ - SubCommand("status", "Show running channel status"), - SubCommand("stop", "Stop a running channel"), + SubCommand("status", "Show channel status"), + SubCommand("stop", "Stop running channels"), + SubCommand("telegram", "Start Telegram channel"), + SubCommand("discord", "Start Discord channel"), + SubCommand("slack", "Start Slack channel"), + SubCommand("feishu", "Start Feishu channel"), + SubCommand("dingtalk", "Start DingTalk channel"), + SubCommand("wechat", "Start WeChat channel"), + SubCommand("email", "Start Email channel"), + SubCommand("imessage", "Start iMessage channel"), ] def needs_agent(self, args: list[str]) -> bool: diff --git a/EvoScientist/commands/implementation/mcp.py b/EvoScientist/commands/implementation/mcp.py index 6840f52..d94d4d4 100644 --- a/EvoScientist/commands/implementation/mcp.py +++ b/EvoScientist/commands/implementation/mcp.py @@ -13,6 +13,7 @@ class MCPCommand(Command): name = "/mcp" description = "Manage MCP servers" + category = "MCP" subcommands: ClassVar[list[SubCommand]] = [ SubCommand("list", "List configured MCP servers"), SubCommand("config", "Show server configuration details"), @@ -22,6 +23,34 @@ class MCPCommand(Command): SubCommand("install", "Browse and install MCP servers"), ] + _server_names_cache: list[str] | None = None + + def _get_server_names(self) -> list[str]: + if self._server_names_cache is None: + try: + from ...mcp import load_mcp_config + + self._server_names_cache = list(load_mcp_config().keys()) + except Exception: + return [] + return self._server_names_cache + + def _invalidate_server_cache(self) -> None: + self._server_names_cache = None + + def get_completions(self, tokens: list[str]) -> list[tuple[str, str]]: + if len(tokens) <= 1: + return super().get_completions(tokens) + subcmd = tokens[0].lower() + if subcmd in ("config", "remove", "edit") and len(tokens) == 2: + prefix = tokens[1].lower() + return [ + (name, "") + for name in self._get_server_names() + if name.lower().startswith(prefix) + ] + return super().get_completions(tokens) + async def execute(self, ctx: CommandContext, args: list[str]) -> None: """Dispatch to the appropriate MCP subcommand.""" if not args or args[0] == "list": @@ -35,10 +64,13 @@ class MCPCommand(Command): await self._mcp_config(ctx, subargs[0] if subargs else "") elif subcmd == "add": await self._mcp_add(ctx, subargs) + self._invalidate_server_cache() elif subcmd == "edit": await self._mcp_edit(ctx, subargs) + self._invalidate_server_cache() elif subcmd == "remove": await self._mcp_remove(ctx, subargs[0] if subargs else "") + self._invalidate_server_cache() elif subcmd == "install": from .mcp_install import InstallMCPCommand diff --git a/EvoScientist/commands/implementation/mcp_install.py b/EvoScientist/commands/implementation/mcp_install.py index 19d18a0..1217cba 100644 --- a/EvoScientist/commands/implementation/mcp_install.py +++ b/EvoScientist/commands/implementation/mcp_install.py @@ -10,6 +10,7 @@ class InstallMCPCommand(Command): name = "/install-mcp" description = "Browse and install MCP servers" + category = "MCP" arguments: ClassVar[list[Argument]] = [ Argument( name="source", diff --git a/EvoScientist/commands/implementation/model.py b/EvoScientist/commands/implementation/model.py index 91df5b2..0172650 100644 --- a/EvoScientist/commands/implementation/model.py +++ b/EvoScientist/commands/implementation/model.py @@ -47,6 +47,7 @@ class ModelCommand(Command): name = "/model" description = "Switch model (--save to persist)" + category = "Model" # ``--save`` is parsed manually in ``execute`` via ``"--save" in args``; # ``type=bool`` below is declarative metadata, not enforced by the manager. arguments: ClassVar[list[Argument]] = [ diff --git a/EvoScientist/commands/implementation/model_fallback.py b/EvoScientist/commands/implementation/model_fallback.py index 6fb8f72..7e21cda 100644 --- a/EvoScientist/commands/implementation/model_fallback.py +++ b/EvoScientist/commands/implementation/model_fallback.py @@ -18,6 +18,7 @@ class ModelFallbackCommand(Command): name = "/model-fallback" alias: ClassVar[list[str]] = ["/fallback"] description = "Manage fallback models (add/remove/list/clear)" + category = "Model" arguments: ClassVar[list[Argument]] = [ Argument( name="action", diff --git a/EvoScientist/commands/implementation/session.py b/EvoScientist/commands/implementation/session.py index fcb27d0..7f01699 100644 --- a/EvoScientist/commands/implementation/session.py +++ b/EvoScientist/commands/implementation/session.py @@ -22,6 +22,7 @@ class CompactCommand(Command): name = "/compact" description = "Compact conversation to free context" requires_agent = True + category = "Session" async def execute(self, ctx: CommandContext, args: list[str]) -> None: from ...cli.commands import ( @@ -82,6 +83,7 @@ class ThreadsCommand(Command): name = "/threads" description = "List recent sessions" + category = "Session" async def execute(self, ctx: CommandContext, args: list[str]) -> None: from ...sessions import _format_relative_time, short_thread_id @@ -137,6 +139,7 @@ class ResumeCommand(Command): name = "/resume" description = "Resume a previous session" + category = "Session" arguments: ClassVar[list[Argument]] = [ Argument( name="thread_id", @@ -208,6 +211,7 @@ class NewCommand(Command): name = "/new" description = "Start a new session" + category = "Session" async def execute(self, ctx: CommandContext, args: list[str]) -> None: await ctx.ui.start_new_session() @@ -218,6 +222,7 @@ class ClearCommand(Command): name = "/clear" description = "Clear chat history" + category = "Session" async def execute(self, ctx: CommandContext, args: list[str]) -> None: ctx.ui.clear_chat() @@ -228,6 +233,7 @@ class DeleteCommand(Command): name = "/delete" description = "Delete a saved session" + category = "Session" arguments: ClassVar[list[Argument]] = [ Argument( name="thread_id", @@ -296,6 +302,7 @@ class ExitCommand(Command): name = "/exit" alias: ClassVar[list[str]] = ["/quit", "/q"] description = "Quit EvoScientist" + category = "Session" async def execute(self, ctx: CommandContext, args: list[str]) -> None: ctx.ui.force_quit() diff --git a/EvoScientist/commands/implementation/skills.py b/EvoScientist/commands/implementation/skills.py index 6da4183..7820c5b 100644 --- a/EvoScientist/commands/implementation/skills.py +++ b/EvoScientist/commands/implementation/skills.py @@ -13,6 +13,7 @@ class SkillsCommand(Command): name = "/skills" description = "List installed skills" + category = "Skills" async def execute(self, ctx: CommandContext, args: list[str]) -> None: from ...cli.agent import _shorten_path @@ -65,6 +66,7 @@ class InstallSkill(Command): name: ClassVar[str] = "/install-skill" description: ClassVar[str] = "Add a skill from path or GitHub" + category: ClassVar[str] = "Skills" arguments: ClassVar[list[Argument]] = [ Argument( name="source", @@ -142,6 +144,7 @@ class InstallSkills(Command): description: ClassVar[str] = ( "Browse and install EvoSkills (optional: /evoskills )" ) + category: ClassVar[str] = "Skills" arguments: ClassVar[list[Argument]] = [ Argument( name="tag", type=str, description="Tag to filter skills by", required=False @@ -255,6 +258,7 @@ class UninstallSkill(Command): name: ClassVar[str] = "/uninstall-skill" description: ClassVar[str] = "Remove an installed skill" + category: ClassVar[str] = "Skills" arguments: ClassVar[list[Argument]] = [ Argument( name="name", diff --git a/tests/test_cli_completion.py b/tests/test_cli_completion.py index 606ae22..cc6e67f 100644 --- a/tests/test_cli_completion.py +++ b/tests/test_cli_completion.py @@ -66,11 +66,16 @@ class TestSlashCommandCompleter: completions = list(completer.get_completions(_doc("/mcp list"), None)) assert completions == [] - def test_results_sorted_alphabetically(self): + def test_results_match_engine_order(self): + """Completer preserves the category-based order from compute_completions.""" + from EvoScientist.commands._completion_engine import compute_completions + completer = SlashCommandCompleter() completions = list(completer.get_completions(_doc("/"), None)) - texts = [c.text for c in completions] - assert texts == sorted(texts) + engine_result = compute_completions("/", 1) + assert [c.text for c in completions] == [ + c.text for c in engine_result.candidates + ] def test_display_meta_is_description(self): completer = SlashCommandCompleter() @@ -79,8 +84,13 @@ class TestSlashCommandCompleter: if c.text == "/help": assert c.display_meta is not None - def test_subcommand_completions_sorted(self): + def test_subcommand_completions_match_engine_order(self): + """Subcommand completions preserve the order from compute_completions.""" + from EvoScientist.commands._completion_engine import compute_completions + completer = SlashCommandCompleter() completions = list(completer.get_completions(_doc("/mcp "), None)) - texts = [c.text for c in completions] - assert texts == sorted(texts) + engine_result = compute_completions("/mcp ", 5) + assert [c.text for c in completions] == [ + c.text for c in engine_result.candidates + ] diff --git a/tests/test_command_completions.py b/tests/test_command_completions.py new file mode 100644 index 0000000..58193de --- /dev/null +++ b/tests/test_command_completions.py @@ -0,0 +1,130 @@ +"""Tests for multi-stage command completions, categories, and dynamic completions.""" + +from unittest.mock import patch + +from EvoScientist.commands._completion_engine import compute_completions + + +class TestTopLevelCompletions: + def test_slash_returns_all_commands(self): + r = compute_completions("/", 1) + names = [c.text for c in r.candidates] + assert "/mcp" in names + assert "/help" in names + assert "/new" in names + + def test_prefix_filters(self): + r = compute_completions("/mc", 3) + names = [c.text for c in r.candidates] + assert "/mcp" in names + assert "/help" not in names + + def test_exact_leaf_hides(self): + r = compute_completions("/new", 4) + assert r.kind == "empty" + + def test_exact_with_subcommands_hides_without_space(self): + r = compute_completions("/mcp", 4) + assert r.kind == "empty" + + def test_results_have_categories(self): + r = compute_completions("/", 1) + cats = {c.category for c in r.candidates} + assert "Session" in cats + assert "MCP" in cats + assert "General" in cats + + def test_category_ordering(self): + r = compute_completions("/", 1) + cats = [] + for c in r.candidates: + if not cats or cats[-1] != c.category: + cats.append(c.category) + assert cats.index("Session") < cats.index("General") + + +class TestAliasVisibility: + def test_alias_prefix_matches(self): + r = compute_completions("/fa", 3) + names = [c.text for c in r.candidates] + assert "/model-fallback" in names + + def test_alias_exact_hides_leaf(self): + r = compute_completions("/quit", 5) + assert r.kind == "empty" + + def test_alias_exact_hides_without_space(self): + r = compute_completions("/fallback", 9) + assert r.kind == "empty" + + def test_alias_space_shows_subcommands(self): + r = compute_completions("/fallback ", 10) + names = [c.text for c in r.candidates] + assert "add" in names + assert "list" in names + + +class TestSubcommandCompletions: + def test_space_shows_subcommands(self): + r = compute_completions("/mcp ", 5) + names = [c.text for c in r.candidates] + assert "list" in names + assert "config" in names + assert "install" in names + + def test_prefix_filters_subcommands(self): + r = compute_completions("/mcp c", 6) + names = [c.text for c in r.candidates] + assert "config" in names + assert "list" not in names + + def test_leaf_subcommand_stops(self): + r = compute_completions("/model-fallback help ", 21) + assert r.kind == "empty" + + def test_channel_all_types(self): + r = compute_completions("/channel ", 9) + names = [c.text for c in r.candidates] + assert "status" in names + assert "telegram" in names + assert "discord" in names + + def test_model_fallback_subcommands(self): + r = compute_completions("/model-fallback ", 16) + names = [c.text for c in r.candidates] + assert "add" in names + assert "clear" in names + + +class TestDynamicCompletions: + def _invalidate_mcp_cache(self): + from EvoScientist.commands.implementation.mcp import MCPCommand + from EvoScientist.commands.manager import manager + + cmd = manager.get_command("/mcp") + if isinstance(cmd, MCPCommand): + cmd._invalidate_server_cache() + + def test_mcp_config_server_names(self): + self._invalidate_mcp_cache() + fake_config = {"myserver": {}, "other": {}} + with patch("EvoScientist.mcp.load_mcp_config", return_value=fake_config): + r = compute_completions("/mcp config ", 12) + names = [c.text for c in r.candidates] + assert "myserver" in names + assert "other" in names + + def test_mcp_config_prefix_filters(self): + self._invalidate_mcp_cache() + fake_config = {"myserver": {}, "other": {}} + with patch("EvoScientist.mcp.load_mcp_config", return_value=fake_config): + r = compute_completions("/mcp config my", 14) + names = [c.text for c in r.candidates] + assert names == ["myserver"] + + def test_mcp_remove_shows_servers(self): + self._invalidate_mcp_cache() + fake_config = {"srv1": {}} + with patch("EvoScientist.mcp.load_mcp_config", return_value=fake_config): + r = compute_completions("/mcp remove ", 12) + assert [c.text for c in r.candidates] == ["srv1"] diff --git a/tests/test_command_manager.py b/tests/test_command_manager.py index b8c16ed..80f9ccc 100644 --- a/tests/test_command_manager.py +++ b/tests/test_command_manager.py @@ -46,15 +46,15 @@ class TestCommandManagerSubcommands: 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.""" + def test_channel_has_subcommands(self): + """``/channel`` must expose status, stop, and channel type 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"} + assert {"status", "stop", "telegram", "discord"}.issubset(names) def test_command_without_subcommands_returns_empty(self): """A command with no subcommands must return an empty list.""" diff --git a/tests/test_file_mentions.py b/tests/test_file_mentions.py index 93acbe7..0063ff3 100644 --- a/tests/test_file_mentions.py +++ b/tests/test_file_mentions.py @@ -346,11 +346,11 @@ class TestCompleteFileMention: assert any("results.json" in p for p in paths) def test_fuzzy_deep_file(self, tmp_path: Path) -> None: - """Files nested 2+ levels deep are discovered by fuzzy search.""" + """Files nested 2+ levels deep are discovered via directory prefix.""" nested = tmp_path / "src" / "models" nested.mkdir(parents=True) (nested / "base.py").write_text("") - result = complete_file_mention("@base", str(tmp_path)) + result = complete_file_mention("@src/models/base", str(tmp_path)) paths = [p for p, _ in result] assert any("base.py" in p for p in paths) diff --git a/tests/test_tui_widgets.py b/tests/test_tui_widgets.py index 5037bd6..cdae349 100644 --- a/tests/test_tui_widgets.py +++ b/tests/test_tui_widgets.py @@ -746,11 +746,11 @@ class TestCompletionLogic(unittest.TestCase): ) ) self._comp_index: int = comp_index + self._comp_base: str = "" self._fake_input = fake_input self._fake_completions = fake_completions def query_one(self, selector, widget_type=None): - # Match by selector string; widget_type is ignored in stub if "prompt" in selector: return fake_input if "completions" in selector: @@ -764,59 +764,65 @@ class TestCompletionLogic(unittest.TestCase): if not (comp_widget.display and self._comp_items): self.query_one("#prompt").focus() return - self._comp_index = (self._comp_index + 1) % len(self._comp_items) + if self._comp_index < 0: + self._comp_index = 0 + self._render_completions() self._apply_selected_completion() + selected = self._comp_items[self._comp_index] + is_file_dir = selected.text.startswith("@") and selected.text.rstrip( + '"' + ).endswith("/") + if is_file_dir: + return + if selected.text.startswith("@"): + self._hide_completions() def _apply_selected_completion(self): candidate = self._comp_items[self._comp_index] prompt = self.query_one("#prompt") if candidate.text.startswith("@"): - prompt.value = candidate.text + " " + is_dir = candidate.text.rstrip('"').endswith("/") + suffix = "" if is_dir else " " + prompt.value = candidate.text + suffix 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.value = self._comp_base + candidate.text + " " prompt.cursor_position = len(prompt.value) self._render_completions() def _hide_completions(self): self._comp_items = [] self._comp_index = -1 - comp_widget = self.query_one("#completions") - comp_widget.display = False + self.query_one("#completions").display = False def _render_completions(self): comp_widget = self.query_one("#completions") comp_text = Text() for i, candidate in enumerate(self._comp_items): cmd, desc = candidate.text, candidate.description + cat = getattr(candidate, "category", "") + if cat and ( + i == 0 + or getattr(self._comp_items[i - 1], "category", "") != cat + ): + if i > 0: + comp_text.append("\n") + comp_text.append(f" {cat}\n", style="bold #6b7280") if i == self._comp_index: - comp_text.append("\u25b8 ", style="bold") - comp_text.append(f"{cmd:<22}", style="bold") + comp_text.append(" \u25b8 ", style="bold") + comp_text.append(f"{cmd:<28}", style="bold") comp_text.append(desc, style="bold") else: - comp_text.append(" ", style="#888888") - comp_text.append(f"{cmd:<22}", style="#888888") + comp_text.append(" ", style="#888888") + comp_text.append(f"{cmd:<28}", style="#888888") comp_text.append(desc, style="#888888") if i < len(self._comp_items) - 1: comp_text.append("\n") comp_widget.update(comp_text) def on_key(self, key: str): - """Simplified version matching the real on_key logic. - - Up/down are handled by priority bindings, not on_key. - Only enter needs on_key handling. - """ comp_widget = self.query_one("#completions") if not (comp_widget.display and self._comp_items): - return False # did nothing + return False if key == "enter" and self._comp_index >= 0: self._hide_completions() return True @@ -845,15 +851,15 @@ class TestCompletionLogic(unittest.TestCase): assert app._comp_index == 0 assert app._fake_input.value == "/resume " - def test_tab_complete_wraps_around(self): - """TAB past the last item should wrap back to index 0.""" + def test_tab_applies_current_highlight(self): + """TAB on an already-highlighted item should apply it, not advance.""" items = [("/resume", "d1"), ("/run", "d2")] - app = self._make_app(comp_items=items, comp_index=1) # last item + app = self._make_app(comp_items=items, comp_index=1) # second item app._fake_completions.display = True app.action_tab_complete() - assert app._comp_index == 0 - assert app._fake_input.value == "/resume " + assert app._comp_index == 1 + assert app._fake_input.value == "/run " def test_tab_complete_updates_cursor_position(self): """TAB should position the cursor at the end of the completed text.""" @@ -1024,32 +1030,23 @@ class TestCompletionLogic(unittest.TestCase): 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). + # ``replace_start`` must point to the start of the partial + # ``a`` (position 5) so that ``_comp_base`` = ``/mcp ``. 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. + not produce ``/mcp add `` (double space). The TUI uses + ``_comp_base + candidate.text + " "`` which avoids double spaces. """ 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. + # TUI apply logic: base (text[:replace_start]) + candidate + " " + comp_base = text[: c.replace_start] + new_value = comp_base + c.text + " " assert new_value == f"/mcp {c.text} " def test_engine_trailing_space_replace_range(self):