diff --git a/src/lecode/tui/app.py b/src/lecode/tui/app.py index 362bd7c..58f2b4e 100644 --- a/src/lecode/tui/app.py +++ b/src/lecode/tui/app.py @@ -21,6 +21,7 @@ from prompt_toolkit import Application from prompt_toolkit.buffer import Buffer from prompt_toolkit.completion import merge_completers +from prompt_toolkit.data_structures import Point from prompt_toolkit.document import Document from prompt_toolkit.filters import Condition from prompt_toolkit.formatted_text import ANSI @@ -34,6 +35,8 @@ from prompt_toolkit.layout.dimension import Dimension from prompt_toolkit.output import Output from prompt_toolkit.patch_stdout import patch_stdout +from prompt_toolkit.styles import Style +from prompt_toolkit.utils import get_cwidth from prompt_toolkit.widgets import Frame, TextArea from rich.console import Console @@ -96,14 +99,20 @@ ) from lecode.tui.notify import Notifier from lecode.tui.permission import ApprovalPrompt, approval_prompt_text -from lecode.tui.pickers import TriggerCompleter, persona_names +from lecode.tui.pickers import ( + TriggerCompleter, + command_candidates, + persona_names, + prefix_matches, + trigger_token, +) from lecode.tui.statusline import ( CachedGitInfo, StatusLineState, StatusState, render_statusline, ) -from lecode.tui.themes import THEME +from lecode.tui.themes import PICKER_MENU_BG, PICKER_MENU_SELECTED_BG, THEME if TYPE_CHECKING: from lecode.agent.builder import Runtime @@ -116,6 +125,9 @@ #: Spinner/statusline refresh period while the app runs. SPINNER_INTERVAL_S = 0.3 +#: Visible rows in a completion dropdown (slash panel and @/./path menu). +DROPDOWN_MAX_ROWS = 8 + #: Timeout for ``!cmd`` shell-outs. SHELL_TIMEOUT_S = 120.0 @@ -267,6 +279,9 @@ def __init__( ) self._input_area: TextArea | None = None self._chatbox: Frame | None = None + #: Prefix whose "No matching commands" row Escape dismissed (Tab or + #: any edit clears it — see _close_completion_menu / _tab). + self._no_match_dismissed: str | None = None # -- public seams for slash-command handlers ------------------------------- @@ -536,14 +551,33 @@ def _deny(event: Any) -> None: def _deny_escape(event: Any) -> None: self._approval.resolve(Deny()) + @kb.add("escape", filter=~approval_pending) + def _close_completion_menu(event: Any) -> None: + # Approval owns Escape while pending; otherwise dismiss the + # dropdown, restoring typed text a navigation overwrote. The + # no-match row has no completion state, so its dismissal is + # remembered per prefix (any edit or Tab brings it back). + # Longer M-* sequences still win over this bare-key handler. + buffer = event.current_buffer + if buffer.complete_state is not None: + buffer.cancel_completion() + return + prefix = self._slash_prefix() + if prefix: + self._no_match_dismissed = prefix + @kb.add("enter") def _enter(event: Any) -> None: if self._approval.is_pending: return # y/a/n/ESC only while an approval is pending - text = event.current_buffer.text + buffer = event.current_buffer + if buffer.complete_state is not None: + self._accept_completion(buffer) + return + text = buffer.text if text.strip(): - event.current_buffer.append_to_history() - event.current_buffer.reset() + buffer.append_to_history() + buffer.reset() self._spawn(self._submit(text)) @kb.add("escape", "enter") @@ -588,8 +622,12 @@ def _ctrl_d(event: Any) -> None: @kb.add("tab") def _tab(event: Any) -> None: - if event.current_buffer.text: - event.current_buffer.start_completion() + buffer = event.current_buffer + if buffer.complete_state is not None: + self._accept_completion(buffer) + elif buffer.text: + self._no_match_dismissed = None # Tab reopens a dismissed row + buffer.start_completion() else: self.cycle_agent() @@ -641,6 +679,44 @@ async def _edit() -> None: return kb + @staticmethod + def _accept_completion(buffer: Buffer) -> None: + """Fill in the highlighted completion (first when none) and close the menu. + + Commands insert ``/name `` — a following Enter submits it. Selecting + never submits, so browsing the dropdown can't run a command. + """ + state = buffer.complete_state + if state is None: + return + if state.complete_index is None: + buffer.go_to_completion(0) + buffer.complete_state = None + + def _slash_prefix(self) -> str | None: + """The typed ``/`` prefix while the slash picker owns the input, else ``None``. + + Navigation rewrites the buffer with the candidate text, so an open + menu reads the prefix from the document the completion started from. + """ + if self._input_area is None: + return None + state = self._input_area.buffer.complete_state + document = ( + state.original_document if state is not None else self._input_area.buffer.document + ) + trigger = trigger_token(document) + if trigger is None or trigger[0] != "/": + return None + return trigger[1] + + def _slash_menu_empty(self) -> bool: + """Show the inert no-match row: a slash prefix is set, nothing matches it.""" + prefix = self._slash_prefix() + if not prefix or prefix == self._no_match_dismissed: + return False + return not prefix_matches(prefix, command_candidates(self._runtime.skills)) + def _build_app(self, input: Input | None = None, output: Output | None = None) -> Application: _register_shift_enter() draft = self._input_history.load_draft() @@ -673,8 +749,98 @@ def _build_app(self, input: Input | None = None, output: Output | None = None) - # The chatbox: a framed input area directly above the statusline. # Enter submits the text into the transcript above (see _enter). self._chatbox = Frame(self._input_area, title="message") + buffer = self._input_area.buffer + + @Condition + def picker_menu_visible() -> bool: + # Every completion in this app comes from the trigger pickers + # (@/./commands) or the path completer, so the themed panel owns + # them all. Rows stream in asynchronously (the @/path pickers + # await the fd listing first), so wait for the first row; the + # no-match row adds the empty slash case. + state = buffer.complete_state + return (state is not None and bool(state.completions)) or self._slash_menu_empty() + + def menu_heading() -> str: + state = buffer.complete_state + count = len(state.completions) if state else 0 + if state is None: + label = "commands" # the inert no-match row (slash picker) + else: + trigger = trigger_token(state.original_document) + label = {"@": "context", "/": "commands", ".": "personas"}.get( + trigger[0] if trigger else None, "files" + ) + return f" {label} {count} {'match' if count == 1 else 'matches'}" + + def menu_rows() -> list[tuple[str, str]]: + state = buffer.complete_state + if state is None: + return [("", " No matching commands")] + if not state.completions: + return [] # rows still streaming in; nothing to render yet + width = max(get_cwidth(c.display_text) for c in state.completions) + rows = [] + for index, completion in enumerate(state.completions): + selected = index == state.complete_index + style = "class:picker-menu.selected" if selected else "class:picker-menu.command" + if index: + rows.append(("", "\n")) + rows.extend( + [ + (style, "> " if selected else " "), + (style, completion.display_text), + ("", " " * (width - get_cwidth(completion.display_text) + 2)), + ("", completion.display_meta_text), + ] + ) + return rows + + picker_panel = ConditionalContainer( + Frame( + HSplit( + [ + Window(FormattedTextControl(menu_heading), height=1), + Window( + FormattedTextControl( + menu_rows, + get_cursor_position=lambda: Point( + 0, + (buffer.complete_state.complete_index or 0) + if buffer.complete_state + else 0, + ), + ), + height=Dimension(min=1, max=DROPDOWN_MAX_ROWS), + dont_extend_height=True, + cursorline=Condition( + lambda: ( + buffer.complete_state is not None + and buffer.complete_state.complete_index is not None + ) + ), + ), + Window( + FormattedTextControl(" ↑↓ navigate Enter/Tab select Esc close"), + height=1, + ), + ] + ), + style="class:picker-menu", + ), + picker_menu_visible, + ) return Application( - layout=Layout(HSplit([live_area, self._chatbox, toolbar])), + layout=Layout(HSplit([live_area, self._chatbox, picker_panel, toolbar])), + style=Style.from_dict( + { + "picker-menu": f"bg:{PICKER_MENU_BG} {self._theme.muted}", + "picker-menu frame.border": self._theme.muted, + "picker-menu.command": self._theme.text, + "picker-menu.selected": f"{self._theme.accent} bold", + "picker-menu cursor-line": f"bg:{PICKER_MENU_SELECTED_BG}", + } + ), key_bindings=self._build_keybindings(), full_screen=False, mouse_support=False, diff --git a/src/lecode/tui/input.py b/src/lecode/tui/input.py index 2d023ba..67a9436 100644 --- a/src/lecode/tui/input.py +++ b/src/lecode/tui/input.py @@ -23,6 +23,7 @@ import tempfile import time from collections.abc import AsyncGenerator, Callable, Iterable +from itertools import islice from pathlib import Path from prompt_toolkit.application.run_in_terminal import run_in_terminal @@ -42,6 +43,9 @@ #: Timeout for the fd subprocess itself. FD_TIMEOUT_S = 5.0 +#: Max path completions offered (same cap as the @ picker's file branch). +PATH_COMPLETION_LIMIT = 20 + #: Word characters for Ctrl-W (kill word back). _WORD_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") @@ -212,6 +216,10 @@ def _path_token_before_cursor(document: Document) -> str | None: token = before.split(" ")[-1] if before else "" if not token: return None + # Buffer-start trigger chars: "/" (commands) and "." (personas) own the + # input — except "./" and "../", which are explicit path prefixes. + if before == token and token[0] in "/." and not token.startswith(("./", "../")): + return None if "/" in token or token.startswith(("~", ".")): return token return None @@ -298,9 +306,9 @@ async def get_completions_async( return # fd lists paths relative to cwd; normalize the user's "./" away. prefix = token[2:] if token.startswith("./") else token - for path in await self._lister.files(): - if path.startswith(prefix): - yield Completion(path, start_position=-len(token)) + matches = (path for path in await self._lister.files() if path.startswith(prefix)) + for path in islice(matches, PATH_COMPLETION_LIMIT): + yield Completion(path, start_position=-len(token)) def get_completions(self, document: Document, complete_event: CompleteEvent): # Unused: prompt_toolkit drives the async variant. diff --git a/src/lecode/tui/pickers.py b/src/lecode/tui/pickers.py index a031c85..539d5bc 100644 --- a/src/lecode/tui/pickers.py +++ b/src/lecode/tui/pickers.py @@ -72,7 +72,25 @@ def _ranked[T](token: str, candidates: list[tuple[str, T]]) -> list[tuple[str, T return [(name, payload) for _, name, payload in scored] -def _trigger_token(document: Document) -> tuple[str, str] | None: +def prefix_matches[T](token: str, candidates: list[tuple[str, T]]) -> list[tuple[str, T]]: + """Case-insensitive prefix matches, alphabetical (the ``/`` picker).""" + prefix = token.lower() + return sorted( + (name, payload) for name, payload in candidates if name.lower().startswith(prefix) + ) + + +def command_candidates(skills: SkillRegistry) -> list[tuple[str, str]]: + """Built-in commands plus skill-registered ones (first name wins).""" + commands = list(BUILTIN_COMMANDS) + known = {name for name, _ in commands} + for info in skill_commands(skills).values(): + if info["name"] not in known: + commands.append((info["name"], info["description"])) + return commands + + +def trigger_token(document: Document) -> tuple[str, str] | None: """``(trigger, token)`` for ``@``/``/``/``.``, or ``None`` off-trigger.""" before = document.text_before_cursor word = before.split(" ")[-1] if before else "" @@ -107,23 +125,12 @@ def __init__( self._agents = agents self._skills = skills - # -- candidates --------------------------------------------------------- - - def _command_candidates(self) -> list[tuple[str, str]]: - """Built-in commands plus skill-registered ones (first name wins).""" - commands = list(BUILTIN_COMMANDS) - known = {name for name, _ in commands} - for info in skill_commands(self._skills).values(): - if info["name"] not in known: - commands.append((info["name"], info["description"])) - return commands - # -- completions ---------------------------------------------------------- async def get_completions_async( self, document: Document, complete_event: CompleteEvent ) -> AsyncGenerator[Completion, None]: - trigger = _trigger_token(document) + trigger = trigger_token(document) if trigger is None: return kind, token = trigger @@ -145,7 +152,7 @@ async def get_completions_async( display_meta="file", ) elif kind == "/": - for name, description in _ranked(token, self._command_candidates()): + for name, description in prefix_matches(token, command_candidates(self._skills)): yield Completion( f"/{name} ", start_position=-word_len, diff --git a/src/lecode/tui/themes.py b/src/lecode/tui/themes.py index 068874d..d9d0a83 100644 --- a/src/lecode/tui/themes.py +++ b/src/lecode/tui/themes.py @@ -40,6 +40,10 @@ class Theme: permission: str +#: Picker-dropdown panel colors, part of the one fixed theme. +PICKER_MENU_BG = "#1c162b" +PICKER_MENU_SELECTED_BG = "#35264f" + #: The one and only theme: Kalvad purple on dark. THEME = Theme( name="kalvad", diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index d24529d..65e4341 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -88,8 +88,9 @@ async def test_unknown_model_keeps_configured_window(tmp_path, monkeypatch): def test_layout_is_chatbox_above_statusline(tmp_path, monkeypatch): """The input is a framed chatbox directly above the 3-line statusline; the frame's bottom border is the split between them. A conditional live - region for streamed text sits above the chatbox.""" - from prompt_toolkit.layout.containers import ConditionalContainer, Window + region for streamed text sits above the chatbox, and the themed picker + panel anchors below the input for all trigger menus.""" + from prompt_toolkit.layout.containers import ConditionalContainer, Window, to_container from prompt_toolkit.widgets import Frame app, _, _ = make_app(tmp_path, monkeypatch, []) @@ -97,13 +98,239 @@ def test_layout_is_chatbox_above_statusline(tmp_path, monkeypatch): pt_app = app._build_app(input=inp, output=DummyOutput()) assert isinstance(app._chatbox, Frame) and app._chatbox.body is app._input_area children = pt_app.layout.container.children - # Frame unwraps to its internal container; the statusline stays last. - assert len(children) == 3 + assert len(children) == 4 assert isinstance(children[0], ConditionalContainer) # live stream region - assert isinstance(children[2], Window) and children[2].height == 3 + assert children[1] is to_container(app._chatbox) # Frame unwraps to its HSplit + assert isinstance(children[2], ConditionalContainer) # picker panel sizes to its rows + assert isinstance(children[3], Window) and children[3].height == 3 assert app._live_buffer is not None +def _buffer(app): + return app._input_area.buffer + + +def _command_texts(state) -> list[str]: + return [c.text for c in state.completions] + + +async def test_slash_menu_opens_with_all_commands(tmp_path, monkeypatch): + """Typing '/' at input start opens the dropdown with every command.""" + app, _, _ = make_app(tmp_path, monkeypatch, []) + with create_pipe_input() as inp: + task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) + await wait_for(lambda: app._input_area is not None) + inp.send_text("/") + await wait_for(lambda: _buffer(app).complete_state is not None) + texts = _command_texts(_buffer(app).complete_state) + assert "/quit " in texts and "/queue " in texts + assert len([t for t in texts if t.startswith("/model")]) > 1 # several model-* rows + inp.send_text("\x15/quit\r") + assert await task == 0 + + +async def test_slash_menu_prefix_filters_while_typing(tmp_path, monkeypatch): + """'/cop' narrows to commands starting with 'cop'.""" + app, _, _ = make_app(tmp_path, monkeypatch, []) + with create_pipe_input() as inp: + task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) + await wait_for(lambda: app._input_area is not None) + inp.send_text("/cop") + await wait_for( + lambda: ( + _buffer(app).complete_state is not None + and "/copy " in _command_texts(_buffer(app).complete_state) + ) + ) + for text in _command_texts(_buffer(app).complete_state): + assert text.lower().startswith("/cop") + inp.send_text("\x15/quit\r") + assert await task == 0 + + +async def test_slash_menu_enter_fills_without_submitting(tmp_path, monkeypatch): + """Enter fills the first match; only a second Enter submits it.""" + app, provider, out = make_app(tmp_path, monkeypatch, []) + with create_pipe_input() as inp: + task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) + await wait_for(lambda: app._input_area is not None) + inp.send_text("/cop") + await wait_for( + lambda: ( + _buffer(app).complete_state is not None + and "/copy " in _command_texts(_buffer(app).complete_state) + ) + ) + inp.send_text("\r") # accept: fill, do not submit + await wait_for( + lambda: _buffer(app).text == "/copy " and _buffer(app).complete_state is None + ) + assert provider.requests == [] + assert "nothing to copy" not in out.getvalue() + inp.send_text("\r") # now submit the filled command + await wait_for(lambda: "nothing to copy" in out.getvalue()) + inp.send_text("\x15/quit\r") + assert await task == 0 + + +async def test_slash_menu_arrows_navigate_and_wrap(tmp_path, monkeypatch): + """Up/Down move through matches (Down = first, Up past first selects last).""" + app, _, _ = make_app(tmp_path, monkeypatch, []) + with create_pipe_input() as inp: + task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) + await wait_for(lambda: app._input_area is not None) + inp.send_text("/mod") + await wait_for( + lambda: ( + _buffer(app).complete_state is not None + and "/mode " in _command_texts(_buffer(app).complete_state) + ) + ) + state = _buffer(app).complete_state + first, last = state.completions[0].text, state.completions[-1].text + inp.send_text("\x1b[B") # Down -> first + await wait_for(lambda: _buffer(app).text == first) + inp.send_text("\x1b[A") # Up from first -> deselect, restores what was typed + await wait_for(lambda: _buffer(app).text == "/mod") + inp.send_text("\x1b[A") # Up again -> wrap to last + await wait_for(lambda: _buffer(app).text == last) + inp.send_text("\x15/quit\r") + assert await task == 0 + + +async def test_slash_menu_tab_accepts_first_match(tmp_path, monkeypatch): + """Tab fills the first match like Enter does.""" + app, provider, _ = make_app(tmp_path, monkeypatch, []) + with create_pipe_input() as inp: + task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) + await wait_for(lambda: app._input_area is not None) + inp.send_text("/mod") + await wait_for( + lambda: ( + _buffer(app).complete_state is not None + and "/mode " in _command_texts(_buffer(app).complete_state) + ) + ) + inp.send_text("\t") + await wait_for(lambda: _buffer(app).text == "/mode ") + assert provider.requests == [] + inp.send_text("\x15/quit\r") + assert await task == 0 + + +async def test_slash_menu_tab_accepts_navigated_match_without_submitting(tmp_path, monkeypatch): + app, provider, out = make_app(tmp_path, monkeypatch, []) + with create_pipe_input() as inp: + task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) + await wait_for(lambda: app._input_area is not None) + inp.send_text("/mod") + await wait_for(lambda: _buffer(app).complete_state is not None) + inp.send_text("\x1b[B\x1b[B") + await wait_for(lambda: _buffer(app).text == "/model ") + inp.send_text("\t") + await wait_for(lambda: _buffer(app).complete_state is None) + assert _buffer(app).text == "/model " + assert provider.requests == [] + assert out.getvalue() == "" + inp.send_text("\x15/quit\r") + assert await task == 0 + + +async def test_slash_menu_escape_dismisses_and_restores(tmp_path, monkeypatch): + """Escape closes the menu and restores the typed text; later tabs reopen it.""" + app, provider, _ = make_app(tmp_path, monkeypatch, []) + with create_pipe_input() as inp: + task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) + await wait_for(lambda: app._input_area is not None) + inp.send_text("/mod") + await wait_for(lambda: _buffer(app).complete_state is not None) + inp.send_text("\x1b[B") # navigate: text becomes '/mode ' ... first + await wait_for(lambda: _buffer(app).text != "/mod") + inp.send_text("\x1b") # Escape: restore '/mod', close menu + await wait_for(lambda: _buffer(app).text == "/mod" and _buffer(app).complete_state is None) + inp.send_text("\t") # Tab reopens the menu + await wait_for(lambda: _buffer(app).complete_state is not None) + inp.send_text("\x1b") # Escape again: dismiss without filling + await wait_for(lambda: _buffer(app).complete_state is None and _buffer(app).text == "/mod") + assert provider.requests == [] + inp.send_text("\x15/quit\r") + assert await task == 0 + + +async def test_slash_menu_closes_after_command_name(tmp_path, monkeypatch): + """A space after the command closes the menu and it stays closed.""" + app, _, _ = make_app(tmp_path, monkeypatch, []) + with create_pipe_input() as inp: + task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) + await wait_for(lambda: app._input_area is not None) + inp.send_text("/mod") + await wait_for(lambda: _buffer(app).complete_state is not None) + inp.send_text(" ") + await wait_for(lambda: _buffer(app).text == "/mod ") + await asyncio.sleep(0.3) # give any spurious recompletion time to fire + assert _buffer(app).complete_state is None + inp.send_text("\x15/quit\r") + assert await task == 0 + + +async def test_slash_no_match_row_escape_dismisses_and_reopens(tmp_path, monkeypatch): + """Escape dismisses the inert no-match row; editing or Tab brings it back.""" + app, _, _ = make_app(tmp_path, monkeypatch, []) + with create_pipe_input() as inp: + task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) + await wait_for(lambda: app._input_area is not None) + inp.send_text("/zz") + await wait_for(lambda: app._slash_menu_empty()) + inp.send_text("\x1b") # Escape dismisses the row (no completion state) + await wait_for(lambda: not app._slash_menu_empty()) + inp.send_text("z") # editing the prefix reopens it + await wait_for(lambda: app._slash_menu_empty()) + inp.send_text("\x1b") # dismiss again + await wait_for(lambda: not app._slash_menu_empty()) + inp.send_text("\t") # Tab reopens the dismissed row + await wait_for(lambda: app._slash_menu_empty()) + inp.send_text("\x15/quit\r") + assert await task == 0 + + +async def test_personas_trigger_does_not_list_dotfiles(tmp_path, monkeypatch): + """A leading '.' belongs to the personas picker: dotfiles don't flood the + menu at input start, but they still complete mid-message.""" + app, _, _ = make_app(tmp_path, monkeypatch, []) + (tmp_path / ".env").write_text("KEY=1", encoding="utf-8") + with create_pipe_input() as inp: + task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) + await wait_for(lambda: app._input_area is not None) + inp.send_text(".en") + await asyncio.sleep(0.4) # give any spurious path completion time to land + state = _buffer(app).complete_state + texts = _command_texts(state) if state is not None else [] + assert all(text.startswith(".") for text in texts), f"personas only, got: {texts}" + assert ".env" not in texts, "dotfiles must not complete at input start" + inp.send_text("\x15read .en") # mid-message: the dotfile is a path token again + await wait_for( + lambda: ( + _buffer(app).complete_state is not None + and ".env" in _command_texts(_buffer(app).complete_state) + ) + ) + inp.send_text("\x15/quit\r") + assert await task == 0 + + +async def test_slash_menu_mid_message_does_not_open(tmp_path, monkeypatch): + """The menu only responds to '/' at the start of the input.""" + app, _, _ = make_app(tmp_path, monkeypatch, []) + with create_pipe_input() as inp: + task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) + await wait_for(lambda: app._input_area is not None) + inp.send_text("hey /qu") + await asyncio.sleep(0.3) + assert _buffer(app).complete_state is None + inp.send_text("\x15/quit\r") + assert await task == 0 + + async def test_resume_restores_status_usage(tmp_path, monkeypatch): """A session with stored usage opens with the statusline pre-filled.""" monkeypatch.setenv("LECODE_CONFIG_DIR", str(tmp_path / "cfg")) diff --git a/tests/test_tui_input.py b/tests/test_tui_input.py index 6d9e7d9..10a932b 100644 --- a/tests/test_tui_input.py +++ b/tests/test_tui_input.py @@ -11,6 +11,7 @@ from lecode.extras.proc import ProcResult from lecode.session import SessionStore from lecode.tui.input import ( + PATH_COMPLETION_LIMIT, KillRing, PathCompleter, SessionHistory, @@ -184,6 +185,14 @@ def test_path_token_detection(): assert _path_token_before_cursor(Document("open ~/.con", 11)) == "~/.con" assert _path_token_before_cursor(Document("plain word", 10)) is None assert _path_token_before_cursor(Document("", 0)) is None + # "/..." at buffer start is the slash-command trigger and "...." the + # personas trigger (their pickers own them); mid-message both stay path + # tokens, and absolute paths complete anywhere after a space. + assert _path_token_before_cursor(Document("/mod", 4)) is None + assert _path_token_before_cursor(Document("/", 1)) is None + assert _path_token_before_cursor(Document(".per", 4)) is None + assert _path_token_before_cursor(Document("read /etc/ho", 12)) == "/etc/ho" + assert _path_token_before_cursor(Document("read .env", 9)) == ".env" def _fd_result(stdout: str) -> ProcResult: @@ -213,6 +222,16 @@ async def test_path_completer_plain_word_yields_nothing(tmp_path, monkeypatch): assert await _complete(completer, "just a word") == [] +async def test_path_completer_caps_file_completions(tmp_path, monkeypatch): + """The path completer yields at most PATH_COMPLETION_LIMIT matches.""" + listing = "\n".join(f"logs/file{i:02d}.txt" for i in range(30)) + monkeypatch.setattr("lecode.tui.input.run_proc", lambda *a, **k: _async(_fd_result(listing))) + completer = PathCompleter(tmp_path) + completions = await _complete(completer, "logs/") + assert len(completions) == PATH_COMPLETION_LIMIT + assert completions[0] == "logs/file00.txt" + + async def test_path_completer_scandir_fallback(tmp_path, monkeypatch): (tmp_path / "src").mkdir() (tmp_path / "README.md").write_text("x") diff --git a/tests/test_tui_pickers.py b/tests/test_tui_pickers.py index 85cad07..c87b333 100644 --- a/tests/test_tui_pickers.py +++ b/tests/test_tui_pickers.py @@ -142,6 +142,23 @@ async def test_slash_includes_skill_commands(lister, agents): assert str(completions[0].display_meta_text) == "/deploy " +async def test_slash_prefix_filter_is_case_insensitive_and_alphabetical(lister, agents, skills): + """Slash completion matches command-name prefixes (not fuzzy), case-insensitively.""" + completions = await _complete(TriggerCompleter(lister, agents, skills), "/MOD") + texts = [c.text for c in completions] + assert texts, "several built-ins share the mod prefix" + assert texts == sorted(texts), "alphabetical order" + assert all(text.lower().startswith("/mod") for text in texts) + assert texts == [ + c.text for c in await _complete(TriggerCompleter(lister, agents, skills), "/mod") + ] + + +async def test_slash_requires_prefix_match(lister, agents, skills): + """No fuzzy gap matching for slash: "qt" is not a prefix of "quit".""" + assert await _complete(TriggerCompleter(lister, agents, skills), "/qt") == [] + + async def test_slash_mid_word_does_not_trigger(lister, agents, skills): assert await _complete(TriggerCompleter(lister, agents, skills), "hey /qu") == [] diff --git a/tests/test_tui_streaming_pty.py b/tests/test_tui_streaming_pty.py index ec7d374..442cb23 100644 --- a/tests/test_tui_streaming_pty.py +++ b/tests/test_tui_streaming_pty.py @@ -28,6 +28,7 @@ from prompt_toolkit.data_structures import Size # noqa: E402 from prompt_toolkit.input.vt100 import Vt100Input # noqa: E402 +from prompt_toolkit.output import ColorDepth # noqa: E402 from prompt_toolkit.output.vt100 import Vt100_Output # noqa: E402 from rich.console import Console # noqa: E402 from tests.fakes import FakeProvider, sample_catalog # noqa: E402 @@ -105,6 +106,7 @@ async def _run_pty_app( *, configure: Callable[[Config], None] | None = None, delay: float = 0.3, + color_depth: ColorDepth | None = None, ) -> list[str]: master, slave = pty.openpty() fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0)) @@ -158,7 +160,9 @@ def reader_thread() -> None: catalog=sample_catalog(), ) inp = Vt100Input(os.fdopen(os.dup(slave), "r")) - out = Vt100_Output(real_stdout, lambda: Size(rows=ROWS, columns=COLS)) + out = Vt100_Output( + real_stdout, lambda: Size(rows=ROWS, columns=COLS), default_color_depth=color_depth + ) # patch_stdout's proxy binds the current AppSession's output at # creation, and its flush thread always resolves the default session. @@ -235,6 +239,147 @@ async def test_tool_round_then_answer(tmp_path, monkeypatch): assert "all done here" in dump, "final answer missing:\n" + dump +async def test_slash_menu_renders_and_no_match_row(tmp_path, monkeypatch): + """Typing '/mod' shows the dropdown on the real terminal (several rows at + once, not clipped); an unknown prefix shows the inert 'No matching + commands' row and Enter still submits.""" + script = [{"text": ["ok"], "usage": {"input_tokens": 10, "output_tokens": 2}}] + + async def drive(master: int, screen: pyte.HistoryScreen) -> list[str]: + lines = lambda: _screen_lines(screen) # noqa: E731 + await asyncio.sleep(0.8) + os.write(master, b"/mod") + await _wait_for(lines, "Switch permission mode") # menu row 1 of /mod + menu = "\n".join(lines()) + assert "List available models" in menu, menu # row 4 visible, not clipped + os.write(master, b"\x15/zzzz") # clear line, unknown command prefix + await _wait_for(lines, "No matching commands") + snapshot = [ln for ln in lines() if "No matching commands" in ln] + os.write(master, b"\r") + await _wait_for(lines, "unknown command: /zzzz") + os.write(master, b"\x15/quit\r") + return [*lines(), "", *snapshot] + + lines = await _run_pty_app(tmp_path, monkeypatch, script, drive) + dump = "\n".join(lines) + assert "No matching commands" in dump, "no-match row never rendered:\n" + dump + assert "unknown command: /zzzz" in dump, "enter did not submit normally:\n" + dump + + +async def test_at_files_use_the_themed_panel(tmp_path, monkeypatch): + """@ file completions render in the same themed panel as /commands.""" + (tmp_path / "notes.md").write_text("hello", encoding="utf-8") + + async def drive(master: int, screen: pyte.HistoryScreen) -> list[str]: + visible = lambda: screen.display # noqa: E731 + try: + await asyncio.sleep(0.8) + os.write(master, b"@not") + await _wait_for(visible, "context 1 match") + heading = next(i for i, line in enumerate(screen.display) if "context" in line) + row = heading + 1 + assert "notes.md" in screen.display[row] + x = screen.display[row].index("notes.md") + meta_x = screen.display[row].index("file") + assert screen.buffer[row][x].fg == "ece7f7" # theme.text + assert screen.buffer[row][meta_x].fg == "8a80a3" # theme.muted + assert screen.buffer[row][x].bg == "1c162b" # panel background + assert screen.display[heading - 1].startswith("┌") + os.write(master, b"\x15/quit\r") + return screen.display[:] + finally: + os.write(master, b"\x03\x04") + + await _run_pty_app(tmp_path, monkeypatch, [], drive, color_depth=ColorDepth.DEPTH_24_BIT) + + +async def test_slash_panel_is_anchored_compact_and_styled(tmp_path, monkeypatch): + async def drive(master: int, screen: pyte.HistoryScreen) -> list[str]: + visible = lambda: screen.display # noqa: E731 + try: + await asyncio.sleep(0.8) + os.write(master, b"/mod") + await _wait_for(visible, "commands 5 matches") + heading = next(i for i, line in enumerate(screen.display) if "commands" in line) + footer = next(i for i, line in enumerate(screen.display) if "Enter/Tab" in line) + assert footer == heading + 6, "exactly five command rows, no padding" + assert "↑↓ navigate Enter/Tab select Esc close" in screen.display[footer] + assert screen.display[heading - 1].startswith("┌") + assert screen.display[footer + 1].startswith("└") + assert screen.display[footer + 2].strip(), "statusline follows without a blank gap" + row = heading + 1 + command_x = screen.display[row].index("mode") + meta_x = screen.display[row].index("Switch permission mode") + assert screen.buffer[row][command_x].fg == "ece7f7" # theme.text + assert screen.buffer[row][meta_x].fg == "8a80a3" + assert screen.buffer[row][command_x].bg == "1c162b" + assert screen.buffer[heading - 1][0].fg == "8a80a3" + + os.write(master, b"\x1b[B\x1b[B") + await _wait_for(visible, "> model") + selected = next(i for i, line in enumerate(screen.display) if "> model" in line) + x = screen.display[selected].index("model") + assert screen.buffer[selected][x].bold + assert screen.buffer[selected][x].fg == "a78bfa" + assert screen.buffer[selected][x].bg == "35264f" + meta_x = screen.display[selected].index("Switch the model") + assert screen.buffer[selected][meta_x].fg == "8a80a3" + assert not screen.buffer[selected][meta_x].bold + assert screen.buffer[selected][COLS - 2].bg == "35264f" + assert screen.display[heading - 1].startswith("┌"), "panel must not follow cursor" + + os.write(master, b"\x15/cop") + await _wait_for(visible, "commands 1 match") + heading = next(i for i, line in enumerate(screen.display) if "commands" in line) + assert "copy" in screen.display[heading + 1] + assert "Enter/Tab" in screen.display[heading + 2] + assert screen.display[heading + 4].strip(), "one match leaves no eight-row gap" + return screen.display[:] + finally: + os.write(master, b"\x03\x04") + + await _run_pty_app(tmp_path, monkeypatch, [], drive, color_depth=ColorDepth.DEPTH_24_BIT) + + +@pytest.mark.parametrize("columns", [60, 100]) +async def test_slash_panel_caps_rows_scrolls_and_collapses(tmp_path, monkeypatch, columns): + monkeypatch.setattr(sys.modules[__name__], "COLS", columns) + + async def drive(master: int, screen: pyte.HistoryScreen) -> list[str]: + visible = lambda: screen.display # noqa: E731 + try: + await asyncio.sleep(0.8) + os.write(master, b"/") + await _wait_for(visible, "commands") + heading = next(i for i, line in enumerate(screen.display) if "commands" in line) + footer = next(i for i, line in enumerate(screen.display) if "Enter/Tab" in line) + assert footer == heading + 9, "only eight matches visible" + assert "Esc close" in screen.display[footer], "footer fits narrow terminals" + os.write(master, b"\x1b[A") # Up from no selection wraps to the last match. + await _wait_for(visible, "> wt-merge") + assert "Merge the worktree back" in "\n".join(screen.display) + assert "commands" in screen.display[heading] + assert "Enter/Tab" in screen.display[footer] + + os.write(master, b"\x1b") + await _wait_for(visible, "│> / ") # wait for Escape's Alt-sequence timeout + assert not any("commands" in line or "Enter/Tab" in line for line in screen.display) + prompt = next(i for i, line in enumerate(screen.display) if "> /" in line) + assert "dir:" in screen.display[prompt + 2], "closed panel reserves no space" + + os.write(master, b"\x15/zzzz") + await _wait_for(visible, "commands 0 matches") + heading = next(i for i, line in enumerate(screen.display) if "commands 0" in line) + assert "No matching commands" in screen.display[heading + 1] + assert "Enter/Tab" in screen.display[heading + 2] + assert "dir:" in screen.display[heading + 4], "empty state has only one row" + return screen.display[:] + finally: + os.write(master, b"\x03\x04") + + await _run_pty_app(tmp_path, monkeypatch, [], drive) + + async def test_permission_prompt_approves_tool(tmp_path, monkeypatch): """An ask rule prompts inline; pressing y runs the tool and shows output."""