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 "]