From 38c3e020ef6f979a4f570de220bfe4e32bd01128 Mon Sep 17 00:00:00 2001 From: Mouhand-Kaddo Date: Tue, 8 Sep 2026 09:10:32 +0400 Subject: [PATCH 1/3] feat: slash-command dropdown with a themed panel Typing '/' at input start opens a dropdown of every registered command (built-ins + skills), prefix-filtered case-insensitively and alphabetical. Up/Down navigate with wrap, Enter/Tab fill '/name ' without submitting, a second Enter submits, Escape dismisses (restoring typed text; the inert no-match row stays dismissed until the prefix changes or Tab reopens it). A space after the command closes the menu; editing the prefix reopens it. The panel is in-flow below the input, styled from the fixed theme: dark purple background, muted border and descriptions, lavender commands, violet selection with a '>' marker, match count and key hints; rows size to content, capped at eight. The floating completion menu keeps serving @/./path pickers, with space reserved so it is not clipped; '/'-tokens at buffer start belong to the slash picker, mid-message absolute paths stay path tokens. --- src/lecode/tui/app.py | 195 +++++++++++++++++++++++++-- src/lecode/tui/input.py | 2 + src/lecode/tui/pickers.py | 35 +++-- src/lecode/tui/themes.py | 4 + tests/test_tui_app.py | 225 +++++++++++++++++++++++++++++++- tests/test_tui_input.py | 5 + tests/test_tui_pickers.py | 17 +++ tests/test_tui_streaming_pty.py | 120 ++++++++++++++++- 8 files changed, 573 insertions(+), 30 deletions(-) diff --git a/src/lecode/tui/app.py b/src/lecode/tui/app.py index 362bd7c..00f97a9 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 @@ -28,12 +29,15 @@ from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.keys import Keys -from prompt_toolkit.layout import Layout +from prompt_toolkit.layout import Float, FloatContainer, Layout from prompt_toolkit.layout.containers import ConditionalContainer, HSplit, Window from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl from prompt_toolkit.layout.dimension import Dimension +from prompt_toolkit.layout.menus import CompletionsMenu 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 +100,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 SLASH_MENU_BG, SLASH_MENU_SELECTED_BG, THEME if TYPE_CHECKING: from lecode.agent.builder import Runtime @@ -116,6 +126,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 +280,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 +552,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 +623,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 +680,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 +750,108 @@ 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 slash_menu_visible() -> bool: + if buffer.complete_state is not None: + return self._slash_prefix() is not None + return self._slash_menu_empty() + + def menu_heading() -> str: + state = buffer.complete_state + count = len(state.completions) if state else 0 + return f" commands {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")] + 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:slash-menu.selected" if selected else "class:slash-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 + + slash_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:slash-menu", + ), + slash_menu_visible, + ) + + def dropdown_space() -> int: + # Rows reserved below the input for the floating @/./path menu: + # the app renders at natural height and floats clip past it. + # (The slash panel is in-flow and needs no reservation.) + state = buffer.complete_state + if state is None or slash_menu_visible(): + return 0 + return min(DROPDOWN_MAX_ROWS, len(state.completions)) + + chat_area = FloatContainer( + HSplit([live_area, self._chatbox, slash_panel, Window(height=dropdown_space)]), + floats=[ + Float( + xcursor=True, + ycursor=True, + content=CompletionsMenu( + max_height=DROPDOWN_MAX_ROWS, + extra_filter=~slash_menu_visible, + ), + ), + ], + ) return Application( - layout=Layout(HSplit([live_area, self._chatbox, toolbar])), + layout=Layout(HSplit([chat_area, toolbar])), + style=Style.from_dict( + { + "slash-menu": f"bg:{SLASH_MENU_BG} {self._theme.muted}", + "slash-menu frame.border": self._theme.muted, + "slash-menu.command": self._theme.text, + "slash-menu.selected": f"{self._theme.accent} bold", + "slash-menu cursor-line": f"bg:{SLASH_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..708fed3 100644 --- a/src/lecode/tui/input.py +++ b/src/lecode/tui/input.py @@ -212,6 +212,8 @@ def _path_token_before_cursor(document: Document) -> str | None: token = before.split(" ")[-1] if before else "" if not token: return None + if token.startswith("/") and before == token: + return None # buffer-start slash-command trigger; the / picker owns it if "/" in token or token.startswith(("~", ".")): return token return None 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..efefa8e 100644 --- a/src/lecode/tui/themes.py +++ b/src/lecode/tui/themes.py @@ -40,6 +40,10 @@ class Theme: permission: str +#: Slash-dropdown panel colors, part of the one fixed theme. +SLASH_MENU_BG = "#1c162b" +SLASH_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..0c27a46 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -88,8 +88,14 @@ 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 completion menu + anchors below the input for slash commands.""" + from prompt_toolkit.layout.containers import ( + ConditionalContainer, + FloatContainer, + Window, + to_container, + ) from prompt_toolkit.widgets import Frame app, _, _ = make_app(tmp_path, monkeypatch, []) @@ -97,13 +103,220 @@ 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 isinstance(children[0], ConditionalContainer) # live stream region - assert isinstance(children[2], Window) and children[2].height == 3 + assert len(children) == 2 + floats_host = children[0] + assert isinstance(floats_host, FloatContainer) + assert len(floats_host.floats) == 1 # other trigger menus still follow the cursor + inner = floats_host.content.children + assert len(inner) == 4 + assert isinstance(inner[0], ConditionalContainer) # live stream region + assert inner[1] is to_container(app._chatbox) # Frame unwraps to its HSplit + assert isinstance(inner[2], ConditionalContainer) # slash panel sizes to its rows + assert isinstance(inner[3], Window) # other dropdowns' space reservation + assert isinstance(children[1], Window) and children[1].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_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..4960251 100644 --- a/tests/test_tui_input.py +++ b/tests/test_tui_input.py @@ -184,6 +184,11 @@ 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 (the / picker owns + # it); mid-message absolute paths are still path tokens. + 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("read /etc/ho", 12)) == "/etc/ho" def _fd_result(stdout: str) -> ProcResult: 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..07263f7 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,120 @@ 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_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.""" From 06440002c9e5eb0bed0728571eb6779a8e6bf73c Mon Sep 17 00:00:00 2001 From: Mouhand-Kaddo Date: Tue, 8 Sep 2026 09:39:37 +0400 Subject: [PATCH 2/3] feat: themed picker panel for @ and . menus too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The themed panel now renders every completion in the app — commands, @ files/agents, . personas, and bare path tokens — with a per-kind heading (commands / context / personas / files). The stock grey CompletionsMenu float and its space reservation are deleted; the in-flow panel needs neither. Completions stream in asynchronously (the @/path pickers await the fd listing before their first row), so the panel waits for the first row and menu_rows tolerates the empty window — max() over an empty list there crashed the renderer into an unhandled-exception storm that hung the app on any path completion. --- src/lecode/tui/app.py | 71 ++++++++++++++------------------- src/lecode/tui/themes.py | 6 +-- tests/test_tui_app.py | 27 ++++--------- tests/test_tui_streaming_pty.py | 27 +++++++++++++ 4 files changed, 68 insertions(+), 63 deletions(-) diff --git a/src/lecode/tui/app.py b/src/lecode/tui/app.py index 00f97a9..58f2b4e 100644 --- a/src/lecode/tui/app.py +++ b/src/lecode/tui/app.py @@ -29,11 +29,10 @@ from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.keys import Keys -from prompt_toolkit.layout import Float, FloatContainer, Layout +from prompt_toolkit.layout import Layout from prompt_toolkit.layout.containers import ConditionalContainer, HSplit, Window from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl from prompt_toolkit.layout.dimension import Dimension -from prompt_toolkit.layout.menus import CompletionsMenu from prompt_toolkit.output import Output from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.styles import Style @@ -113,7 +112,7 @@ StatusState, render_statusline, ) -from lecode.tui.themes import SLASH_MENU_BG, SLASH_MENU_SELECTED_BG, THEME +from lecode.tui.themes import PICKER_MENU_BG, PICKER_MENU_SELECTED_BG, THEME if TYPE_CHECKING: from lecode.agent.builder import Runtime @@ -753,25 +752,38 @@ def _build_app(self, input: Input | None = None, output: Output | None = None) - buffer = self._input_area.buffer @Condition - def slash_menu_visible() -> bool: - if buffer.complete_state is not None: - return self._slash_prefix() is not None - return self._slash_menu_empty() + 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 - return f" commands {count} {'match' if count == 1 else 'matches'}" + 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:slash-menu.selected" if selected else "class:slash-menu.command" + style = "class:picker-menu.selected" if selected else "class:picker-menu.command" if index: rows.append(("", "\n")) rows.extend( @@ -784,7 +796,7 @@ def menu_rows() -> list[tuple[str, str]]: ) return rows - slash_panel = ConditionalContainer( + picker_panel = ConditionalContainer( Frame( HSplit( [ @@ -814,42 +826,19 @@ def menu_rows() -> list[tuple[str, str]]: ), ] ), - style="class:slash-menu", + style="class:picker-menu", ), - slash_menu_visible, - ) - - def dropdown_space() -> int: - # Rows reserved below the input for the floating @/./path menu: - # the app renders at natural height and floats clip past it. - # (The slash panel is in-flow and needs no reservation.) - state = buffer.complete_state - if state is None or slash_menu_visible(): - return 0 - return min(DROPDOWN_MAX_ROWS, len(state.completions)) - - chat_area = FloatContainer( - HSplit([live_area, self._chatbox, slash_panel, Window(height=dropdown_space)]), - floats=[ - Float( - xcursor=True, - ycursor=True, - content=CompletionsMenu( - max_height=DROPDOWN_MAX_ROWS, - extra_filter=~slash_menu_visible, - ), - ), - ], + picker_menu_visible, ) return Application( - layout=Layout(HSplit([chat_area, toolbar])), + layout=Layout(HSplit([live_area, self._chatbox, picker_panel, toolbar])), style=Style.from_dict( { - "slash-menu": f"bg:{SLASH_MENU_BG} {self._theme.muted}", - "slash-menu frame.border": self._theme.muted, - "slash-menu.command": self._theme.text, - "slash-menu.selected": f"{self._theme.accent} bold", - "slash-menu cursor-line": f"bg:{SLASH_MENU_SELECTED_BG}", + "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(), diff --git a/src/lecode/tui/themes.py b/src/lecode/tui/themes.py index efefa8e..d9d0a83 100644 --- a/src/lecode/tui/themes.py +++ b/src/lecode/tui/themes.py @@ -40,9 +40,9 @@ class Theme: permission: str -#: Slash-dropdown panel colors, part of the one fixed theme. -SLASH_MENU_BG = "#1c162b" -SLASH_MENU_SELECTED_BG = "#35264f" +#: 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( diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 0c27a46..dc6f97b 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -88,14 +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, and the completion menu - anchors below the input for slash commands.""" - from prompt_toolkit.layout.containers import ( - ConditionalContainer, - FloatContainer, - Window, - to_container, - ) + 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, []) @@ -103,17 +98,11 @@ 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 - assert len(children) == 2 - floats_host = children[0] - assert isinstance(floats_host, FloatContainer) - assert len(floats_host.floats) == 1 # other trigger menus still follow the cursor - inner = floats_host.content.children - assert len(inner) == 4 - assert isinstance(inner[0], ConditionalContainer) # live stream region - assert inner[1] is to_container(app._chatbox) # Frame unwraps to its HSplit - assert isinstance(inner[2], ConditionalContainer) # slash panel sizes to its rows - assert isinstance(inner[3], Window) # other dropdowns' space reservation - assert isinstance(children[1], Window) and children[1].height == 3 + assert len(children) == 4 + assert isinstance(children[0], ConditionalContainer) # live stream region + 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 diff --git a/tests/test_tui_streaming_pty.py b/tests/test_tui_streaming_pty.py index 07263f7..442cb23 100644 --- a/tests/test_tui_streaming_pty.py +++ b/tests/test_tui_streaming_pty.py @@ -266,6 +266,33 @@ async def drive(master: int, screen: pyte.HistoryScreen) -> list[str]: 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 From 84228da095a16f20e834391b6e6dfdb4190041ea Mon Sep 17 00:00:00 2001 From: Mouhand-Kaddo Date: Tue, 8 Sep 2026 09:48:59 +0400 Subject: [PATCH 3/3] fix: . personas picker owns input start; cap path completions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A leading '.' at buffer start collided with the path completer (a leading '.' is also a path token), so typing '.' listed the fd listing's dotfiles as 'personas' — thousands of rows and a very slow menu. The buffer-start guard now covers '.' alongside '/', except './' and '../', which stay explicit path prefixes; mid-message dotfiles and absolute paths complete as before. The path completer also yielded unbounded matches (every file under a broad prefix), which made the panel rebuild thousands of rows per keystroke. It now caps at PATH_COMPLETION_LIMIT = 20, matching the @ picker's file branch. --- src/lecode/tui/input.py | 16 +++++++++++----- tests/test_tui_app.py | 25 +++++++++++++++++++++++++ tests/test_tui_input.py | 18 ++++++++++++++++-- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/lecode/tui/input.py b/src/lecode/tui/input.py index 708fed3..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,8 +216,10 @@ def _path_token_before_cursor(document: Document) -> str | None: token = before.split(" ")[-1] if before else "" if not token: return None - if token.startswith("/") and before == token: - return None # buffer-start slash-command trigger; the / picker owns it + # 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 @@ -300,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/tests/test_tui_app.py b/tests/test_tui_app.py index dc6f97b..65e4341 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -293,6 +293,31 @@ async def test_slash_no_match_row_escape_dismisses_and_reopens(tmp_path, monkeyp 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, []) diff --git a/tests/test_tui_input.py b/tests/test_tui_input.py index 4960251..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,11 +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 (the / picker owns - # it); mid-message absolute paths are still path tokens. + # "/..." 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: @@ -218,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")