diff --git a/cli/constants.py b/cli/constants.py
index f3471a6..d1c2188 100644
--- a/cli/constants.py
+++ b/cli/constants.py
@@ -155,32 +155,36 @@ def get_completions(self, document, complete_event):
for cmd, desc in SLASH_COMMANDS.items():
if cmd.startswith(partial):
- padded_cmd = cmd[1:].ljust(16)
- safe_desc = _xml_escape(desc)
- yield Completion(
- cmd,
- start_position=-len(text),
- display=HTML(
- f"{padded_cmd}{safe_desc}"
- ),
- display_meta=desc,
- )
+ yield self._command_completion(cmd, cmd[1:], desc, desc, text)
if self._skill_manager:
for skill in self._skill_manager.list_skills():
skill_cmd = f"/{skill['name']}"
if skill_cmd.startswith(partial) and skill_cmd not in SLASH_COMMANDS:
- padded_cmd = skill["name"].ljust(16)
- safe_desc = _xml_escape(skill["description"])
- yield Completion(
+ yield self._command_completion(
skill_cmd,
- start_position=-len(text),
- display=HTML(
- f"{padded_cmd}{safe_desc}"
- ),
- display_meta=f"skill: {skill['description']}",
+ skill["name"],
+ skill["description"],
+ f"skill: {skill['description']}",
+ text,
)
+ @staticmethod
+ def _command_completion(cmd: str, label: str, desc: str, meta: str, typed: str) -> Completion:
+ # prompt_toolkit hides a completion that is identical to the text already typed, so the
+ # dropdown (and the command's description) vanished the moment you finished typing `/cost`.
+ # On an exact match offer " " instead: it differs, so the entry stays visible, and a
+ # trailing space is where arguments start anyway.
+ insert = cmd + " " if cmd == typed.lower() else cmd
+ return Completion(
+ insert,
+ start_position=-len(typed),
+ display=HTML(
+ f"{label.ljust(16)}{_xml_escape(desc)}"
+ ),
+ display_meta=meta,
+ )
+
def _path_completions_for(partial: str, limit: int = 30):
"""Yield (completion_text, is_dir) for paths matching a partial @mention."""
diff --git a/tests/test_boxed_input.py b/tests/test_boxed_input.py
index 965d506..879a941 100644
--- a/tests/test_boxed_input.py
+++ b/tests/test_boxed_input.py
@@ -191,3 +191,19 @@ def test_empty_prompt_is_one_line_and_toolbar_shows_the_mode(cols):
assert "[ASK]" in lines[-1], "mode clipped off the toolbar" # the reported bug
if cols >= 60:
assert "tok" in lines[-1]
+
+
+def test_dropdown_stays_open_when_the_command_is_fully_typed():
+ # regression: typing `/cos` showed the dropdown but `/cost` hid it
+ for text in ("/cos", "/cost", "/models"):
+ lines = render_live(
+ text,
+ rows=12,
+ wait=0.5,
+ completer=SlashCommandCompleter(),
+ complete_while_typing=True,
+ **common(Holder()),
+ )
+ menu = [line for line in lines[2:] if line.strip()]
+ assert menu, f"dropdown vanished at {text!r}"
+ assert menu[0].strip().startswith(text[1:]), menu
diff --git a/tests/test_prompt_ui.py b/tests/test_prompt_ui.py
index b2b3598..c8ca4f7 100644
--- a/tests/test_prompt_ui.py
+++ b/tests/test_prompt_ui.py
@@ -229,3 +229,59 @@ def test_clear_screen_only_on_a_real_terminal():
def test_clear_screen_can_be_disabled_with_env():
assert not should_clear_screen(True, env={"DEVORCH_NO_CLEAR": "1"})
assert should_clear_screen(True, env={"DEVORCH_NO_CLEAR": ""})
+
+
+# ── slash dropdown stays visible on an exact match ──────────────────────────
+
+from prompt_toolkit.document import Document # noqa: E402
+
+from cli.constants import SLASH_COMMANDS, SlashCommandCompleter # noqa: E402
+
+
+def _complete(text):
+ return [c.text for c in SlashCommandCompleter().get_completions(Document(text), None)]
+
+
+@pytest.mark.parametrize("cmd", list(SLASH_COMMANDS))
+def test_every_command_keeps_a_dropdown_entry_as_you_finish_typing_it(cmd):
+ # prompt_toolkit hides a completion identical to the typed text; the entry must differ from it
+ for i in range(2, len(cmd) + 1):
+ typed = cmd[:i]
+ entries = _complete(typed)
+ assert entries, f"dropdown empty at {typed!r}"
+ assert all(e != typed for e in entries), (
+ f"identical completion at {typed!r} would be hidden"
+ )
+ assert cmd + " " in _complete(cmd)
+
+
+def test_exact_match_inserts_a_trailing_space_other_matches_do_not():
+ assert _complete("/cost") == ["/cost "]
+ assert "/mode " in _complete("/mode") and "/models" in _complete("/mode")
+
+
+def test_no_dropdown_once_arguments_start():
+ assert _complete("/cost ") == []
+ assert _complete("/model gpt") == []
+
+
+def test_exact_match_is_case_insensitive():
+ assert _complete("/COST") == ["/cost "]
+
+
+def test_enter_on_an_exact_match_submits_the_typed_text_unchanged():
+ assert (
+ run_prompt("/cost\r", completer=SlashCommandCompleter(), complete_while_typing=True)
+ == "/cost"
+ )
+
+
+def test_skills_also_keep_their_dropdown_entry():
+ class Skills:
+ def list_skills(self):
+ return [{"name": "deploy", "description": "Deploy it"}]
+
+ comps = [
+ c.text for c in SlashCommandCompleter(Skills()).get_completions(Document("/deploy"), None)
+ ]
+ assert comps == ["/deploy "]