feat: completion enchancements (#302)
* feat: completion enchancements * fix: handle exception * fix: duplicate view * fix: remove deadcode * fix tab
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user