feat: completion enchancements (#302)

* feat: completion enchancements

* fix: handle exception

* fix: duplicate view

* fix: remove deadcode

* fix tab
This commit is contained in:
Wiktor Cupiał
2026-07-05 07:14:42 +02:00
committed by GitHub
parent f086d77756
commit 1d117ff277
18 changed files with 504 additions and 219 deletions
+6 -6
View File
@@ -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.
+17 -24
View File
@@ -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,
+118 -74
View File
@@ -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")
+2 -1
View File
@@ -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",
]
+79 -56
View File
@@ -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
],
)
+32
View File
@@ -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."""
@@ -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:
@@ -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
@@ -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",
@@ -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]] = [
@@ -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",
@@ -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()
@@ -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 <tag>)"
)
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",
+16 -6
View File
@@ -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
]
+130
View File
@@ -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"]
+3 -3
View File
@@ -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."""
+2 -2
View File
@@ -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)
+42 -45
View File
@@ -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):