diff --git a/src/lecode/slash/handlers.py b/src/lecode/slash/handlers.py index 8466c23..38b4b73 100644 --- a/src/lecode/slash/handlers.py +++ b/src/lecode/slash/handlers.py @@ -37,7 +37,9 @@ from lecode.slash.catalog import BUILTIN_COMMANDS from lecode.slash.registry import ( AmbiguousCommandError, + ArgCompletions, CommandRegistry, + CompletionRow, SlashCommand, UnknownCommandError, ) @@ -1278,6 +1280,179 @@ async def cmd_quit(app: TuiApp, args: list[str]) -> None: app.request_quit() +# -- argument-picker providers ------------------------------------------------------ +# Rows for the shared completion panel: ``(insert, display, meta)``, read +# live from app state (no snapshots — the catalog loads in the background). +# Each provider gates on the args already typed, so a consumed position +# offers nothing (the picker closes) while a nested one offers the next +# stage. ponytail: session rows read the initial metadata record, so a +# renamed session can show its old label — inserting canonical ids keeps +# the handler resolving the right session. + + +def _model_rows(app: TuiApp) -> list[CompletionRow]: + hidden = app.config.ui.hidden_models + rows: list[CompletionRow] = [] + for entry in app.catalog.all(): + if _is_hidden(entry.id, hidden): + continue + current = " · current" if entry.id == app.config.llm.model else "" + rows.append( + ( + entry.id, + entry.id, + f"{entry.name} — ctx {human_tokens(entry.context_window)} · " + f"${entry.pricing.prompt}/M in · ${entry.pricing.completion}/M out{current}", + ) + ) + return rows + + +def _complete_model(app: TuiApp, args: list[str]) -> list[CompletionRow]: + if args: + return [] + return _model_rows(app) + + +def _complete_model_subagent(app: TuiApp, args: list[str]) -> list[CompletionRow]: + if args: + return [] + return [("default", "default", "inherit the main model"), *_model_rows(app)] + + +def _complete_pierre(app: TuiApp, args: list[str]) -> list[CompletionRow]: + if not args: + return [ + ("on", "on", "enable the post-task reviewer"), + ("off", "off", "disable the reviewer"), + ("model", "model", "set the reviewer model"), + ] + if args[0] == "model" and len(args) == 1: + return _model_rows(app) + return [] + + +def _complete_thinking(app: TuiApp, args: list[str]) -> list[CompletionRow]: + if args: + return [] + return [(level, level, "thinking level") for level in get_args(ThinkingLevel)] + + +def _complete_mode(app: TuiApp, args: list[str]) -> list[CompletionRow]: + if args: + return [] + return [(mode, mode, "permission mode") for mode in PERMISSION_MODES] + + +def _complete_notifications(app: TuiApp, args: list[str]) -> list[CompletionRow]: + if args: + return [] + return [ + ("on", "on", "enable audio notifications"), + ("off", "off", "disable notifications"), + ] + + +def _complete_help(app: TuiApp, args: list[str]) -> list[CompletionRow]: + if args: + return [] + return [(c.name, c.name, c.description) for c in app.commands.list()] + + +def _complete_tutor(app: TuiApp, args: list[str]) -> list[CompletionRow]: + if args: + return [] + return [(topic, topic, "help topic") for topic in sorted(TUTOR_TOPICS)] + + +def _complete_memory(app: TuiApp, args: list[str]) -> list[CompletionRow]: + if args: + return [] + return [ + ("show", "show", "read MEMORY.md"), + ("edit", "edit", "edit MEMORY.md"), + ("search", "search", "search memory "), + ("log", "log", "daily log [date]"), + ("notes", "notes", "named notes"), + ] + + +def _complete_resume(app: TuiApp, args: list[str]) -> list[CompletionRow]: + if args not in ([], ["--delete"]): + return [] # one reference only; --delete takes one too + rows: list[CompletionRow] = [] + for meta in app.store.list_sessions(app.runtime.ctx.cwd): + marker = " (current)" if meta.id == app.session.id else "" + pid = app.store.lock_holder(meta.id) + in_use = f" · in use (pid {pid})" if pid else "" + rows.append((meta.id, meta.name, f"{meta.id} — {meta.created_at}{marker}{in_use}")) + return rows + + +def _complete_rewind(app: TuiApp, args: list[str]) -> list[CompletionRow]: + if args: + return [] + turns = [m for m in app.store.load_messages(app.session) if m.role == "user"] + return [ + (str(turn.seq), f"seq {turn.seq}", _clip(_text_of(turn.message), 60)) + for turn in turns[-REWIND_LIST_LIMIT:] + ] + + +def _complete_drop(app: TuiApp, args: list[str]) -> list[CompletionRow]: + if args: + return [] + return [ + (str(i), a.path.name, f"{a.media_kind}, {format_size(a.size_bytes)}") + for i, a in enumerate(app.attachments.list(), 1) + ] + + +def _complete_mcp(app: TuiApp, args: list[str]) -> list[CompletionRow]: + manager = app.runtime.ctx.extras.get("mcp") + statuses = list(manager.status()) if manager is not None else [] + if not statuses: + return [] # nothing configured: the inert hint renders instead + if not args: + return [ + ("tools", "tools", "list a server's tools"), + ("reconnect", "reconnect", "reconnect a server"), + ("auth", "auth", "OAuth login to a server (reuses credentials)"), + ("login", "login", "fresh OAuth login to a server"), + ("logout", "logout", "log out of a server"), + ] + if len(args) == 1 and args[0] in ("tools", "reconnect", "auth", "login", "logout"): + return [(s.name, s.name, "MCP server") for s in statuses] + return [] + + +def _complete_wt_exit(app: TuiApp, args: list[str]) -> list[CompletionRow]: + if app._worktree is None: + return [] + return [(f, f, "wt-exit flag") for f in ("--delete", "--force") if f not in args] + + +#: Providers + the inert-row hint for empty sources, per command. +_ARG_COMPLETIONS: dict[str, tuple[ArgCompletions, str | None]] = { + "model": (_complete_model, "no models available (catalog still loading?)"), + "model-subagent": (_complete_model_subagent, "no models available (catalog still loading?)"), + "resume": (_complete_resume, "no sessions in this folder"), + "rewind": (_complete_rewind, "no user turns yet"), + "drop": (_complete_drop, "no pending attachments"), + "mcp": (_complete_mcp, "no MCP servers configured"), + "thinking": (_complete_thinking, None), + "reasoning": (_complete_thinking, None), + "permissions": (_complete_mode, None), + "mode": (_complete_mode, None), + "notifications": (_complete_notifications, None), + "pierre": (_complete_pierre, None), + "help": (_complete_help, None), + "tutor": (_complete_tutor, None), + "memory": (_complete_memory, None), + "wt-exit": (_complete_wt_exit, None), +} + + # -- registry assembly ----------------------------------------------------------- #: ``/help`` grouping (display order). @@ -1451,7 +1626,17 @@ def build_registry(skills: SkillRegistry | None = None) -> CommandRegistry: registry = CommandRegistry() for name, description in BUILTIN_COMMANDS: handler = _HANDLERS.get(name) or _make_stub(name) - registry.register(SlashCommand(name, description, handler, arg_hint=ARG_HINTS.get(name))) + completions, empty_hint = _ARG_COMPLETIONS.get(name, (None, None)) + registry.register( + SlashCommand( + name, + description, + handler, + arg_hint=ARG_HINTS.get(name), + arg_completions=completions, + arg_empty_hint=empty_hint, + ) + ) if skills is not None: registry.register_skills(skills) return registry diff --git a/src/lecode/slash/registry.py b/src/lecode/slash/registry.py index 69bf790..a848606 100644 --- a/src/lecode/slash/registry.py +++ b/src/lecode/slash/registry.py @@ -20,6 +20,14 @@ #: Handler signature: the app (feed + state seams) plus split arguments. CommandHandler = Callable[["TuiApp", list[str]], Awaitable[None]] +#: One argument-picker row: ``(insert, display, meta)``. +CompletionRow = tuple[str, str, str] + +#: Argument-picker provider: ``(app, args typed so far) -> rows``. +#: Providers gate on ``args`` — a consumed position returns no rows (the +#: picker closes), a nested one returns the next stage's rows. +ArgCompletions = Callable[["TuiApp", "list[str]"], "list[CompletionRow]"] + class UnknownCommandError(KeyError): """No command matched the query.""" @@ -43,6 +51,12 @@ class SlashCommand: handler: CommandHandler #: Usage hint shown by ``/help `` (e.g. ``""``). arg_hint: str | None = None + #: Dropdown rows for the command's arguments (the shared picker panel); + #: ``None`` = free-text arguments, no argument picker. + arg_completions: ArgCompletions | None = None + #: Inert-row text when a fresh top-level picker finds no rows (empty + #: catalog, no sessions…). ``None`` = render nothing. + arg_empty_hint: str | None = None class CommandRegistry: diff --git a/src/lecode/tui/app.py b/src/lecode/tui/app.py index e112b37..e5512b7 100644 --- a/src/lecode/tui/app.py +++ b/src/lecode/tui/app.py @@ -15,12 +15,12 @@ import asyncio import contextlib import os +import sys from pathlib import Path from typing import TYPE_CHECKING, Any 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 @@ -97,13 +97,18 @@ from lecode.providers.types import ContentPart from lecode.session.stats import session_stats from lecode.slash.handlers import build_registry -from lecode.slash.registry import AmbiguousCommandError, CommandRegistry, UnknownCommandError +from lecode.slash.registry import ( + AmbiguousCommandError, + CommandRegistry, + CompletionRow, + SlashCommand, + UnknownCommandError, +) from lecode.tui.clipboard import copy_to_clipboard from lecode.tui.feed import Feed from lecode.tui.input import ( FileLister, KillRing, - PathCompleter, SessionHistory, kill_to_end_of_line, kill_to_start_of_line, @@ -113,7 +118,9 @@ from lecode.tui.notify import Notifier from lecode.tui.permission import ApprovalPrompt, approval_prompt_text from lecode.tui.pickers import ( - TriggerCompleter, + arg_ranked, + build_completer, + command_arg_context, command_candidates, persona_names, prefix_matches, @@ -294,17 +301,20 @@ def __init__( self._kill_ring = KillRing() # One shared fd-backed file list feeds the path completer and pickers. self._file_lister = FileLister(self._cwd) - self._completer = merge_completers( - [ - PathCompleter(self._cwd, lister=self._file_lister), - TriggerCompleter(self._file_lister, runtime.agents, runtime.skills), - ] + # Argument pickers first, then paths, then the @/./ trigger pickers. + self._completer = build_completer( + self, self._cwd, self._file_lister, runtime.agents, runtime.skills ) 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). + #: Slash prefix or typed text whose "no matching…" row Escape + #: dismissed (Tab or any edit clears it — see _close_completion_menu + #: / _tab). self._no_match_dismissed: str | None = None + #: Single-slot cache for the argument picker: (typed text, rows). + #: The panel re-renders often (spinner ticks), providers can hit + #: the disk — recompute only when the typed text changes. + self._arg_rows_cache: tuple[str, list[CompletionRow]] | None = None # -- public seams for slash-command handlers ------------------------------- @@ -491,11 +501,8 @@ def set_cwd(self, path: Path) -> None: if self._runtime.hooks is not None: self._runtime.hooks.cwd = path self._file_lister = FileLister(path) - self._completer = merge_completers( - [ - PathCompleter(path, lister=self._file_lister), - TriggerCompleter(self._file_lister, self._runtime.agents, self._runtime.skills), - ] + self._completer = build_completer( + self, path, self._file_lister, self._runtime.agents, self._runtime.skills ) if self._input_area is not None: self._input_area.completer = self._completer @@ -612,9 +619,10 @@ def _question_dismiss(event: Any) -> None: def _close_completion_menu(event: Any) -> None: # Approval and question prompts own 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. + # overwrote. The no-match rows (slash and argument pickers) have + # no completion state, so their dismissal is remembered per typed + # text (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() @@ -622,6 +630,10 @@ def _close_completion_menu(event: Any) -> None: prefix = self._slash_prefix() if prefix: self._no_match_dismissed = prefix + return + text = buffer.document.text_before_cursor + if text.startswith("/") and command_arg_context(buffer.document, self._commands): + self._no_match_dismissed = text # dismiss the argument inert row @kb.add("enter") def _enter(event: Any) -> None: @@ -745,12 +757,15 @@ async def _edit() -> None: return kb - @staticmethod - def _accept_completion(buffer: Buffer) -> None: + def _accept_completion(self, 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. + never submits, so browsing the dropdown can't run a command. Inside + a command's argument picker, accepting reopens completion for the + next token; providers gate on the args typed so far, so a final + value (``/model ``) lands on an empty picker and the menu stays + closed while a nested one (``/pierre model ``) gets its next stage. """ state = buffer.complete_state if state is None: @@ -758,6 +773,8 @@ def _accept_completion(buffer: Buffer) -> None: if state.complete_index is None: buffer.go_to_completion(0) buffer.complete_state = None + if command_arg_context(buffer.document, self._commands) is not None: + buffer.start_completion(select_first=False) def _slash_prefix(self) -> str | None: """The typed ``/`` prefix while the slash picker owns the input, else ``None``. @@ -783,6 +800,54 @@ def _slash_menu_empty(self) -> bool: return False return not prefix_matches(prefix, command_candidates(self._runtime.skills)) + def arg_completion_rows( + self, document: Document + ) -> tuple[SlashCommand, list[str], str, list[CompletionRow]] | None: + """``(command, args, partial, rows)`` for the argument picker the + document is in, else ``None``. + + Rows come from the command's provider on demand — live state, no + snapshots (the catalog loads in the background). The panel renders + often (spinner ticks, feed appends), so the last result is cached + keyed by the typed text; every edit is a fresh key. + """ + context = command_arg_context(document, self._commands) + if context is None: + return None + command, args, partial = context + key = document.text_before_cursor + if self._arg_rows_cache is not None and self._arg_rows_cache[0] == key: + return command, args, partial, self._arg_rows_cache[1] + rows = command.arg_completions(self, args) or [] + self._arg_rows_cache = (key, rows) + return command, args, partial, rows + + def _arg_no_match(self) -> tuple[SlashCommand, str] | None: + """``(command, inert-row text)`` while an argument picker is active + with nothing to render: a fresh top-level picker whose source is + empty (the command's ``arg_empty_hint``), or a filter that matches + none of the rows. ``None`` = no inert row (rows render, the position + is consumed, or Escape dismissed this exact text). + """ + if self._input_area is None: + return None + buffer = self._input_area.buffer + if buffer.complete_state is not None: + return None # rows render from the completion state + if buffer.document.text_before_cursor == self._no_match_dismissed: + return None + result = self.arg_completion_rows(buffer.document) + if result is None: + return None + command, args, partial, rows = result + if not rows: + if args: + return None # consumed position: nothing to offer by design + return (command, command.arg_empty_hint) if command.arg_empty_hint else None + if arg_ranked(partial, rows): + return None # matches exist — the completer renders them + return command, "no matching options" + def _build_app(self, input: Input | None = None, output: Output | None = None) -> Application: _register_shift_enter() draft = self._input_history.load_draft() @@ -820,28 +885,41 @@ def _build_app(self, input: Input | None = None, output: Output | None = None) - @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. + # (@/./commands), a command's argument picker, 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 rows (slash + # and argument pickers) render without completion state. state = buffer.complete_state - return (state is not None and bool(state.completions)) or self._slash_menu_empty() + return ( + (state is not None and bool(state.completions)) + or self._slash_menu_empty() + or self._arg_no_match() is not None + ) 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: + label = "commands" # default: the inert no-match row (slash picker) + if state is not None: + arg = self.arg_completion_rows(state.original_document) trigger = trigger_token(state.original_document) - label = {"@": "context", "/": "commands", ".": "personas"}.get( - trigger[0] if trigger else None, "files" + label = ( + f"/{arg[0].name}" + if arg is not None + else {"@": "context", "/": "commands", ".": "personas"}.get( + trigger[0] if trigger else None, "files" + ) ) + elif (no_match := self._arg_no_match()) is not None: + label = f"/{no_match[0].name}" 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: + if (no_match := self._arg_no_match()) is not None: + return [("", f" {no_match[1]}")] return [("", " No matching commands")] if not state.completions: return [] # rows still streaming in; nothing to render yet @@ -934,8 +1012,13 @@ async def run(self, *, input: Input | None = None, output: Output | None = None) with patch_stdout(raw=True): try: await self._app.run_async() - except (EOFError, KeyboardInterrupt): - self._quit = True # e.g. stdin EOF on a non-tty + except (EOFError, KeyboardInterrupt) as e: + # e.g. stdin EOF on a non-tty, Ctrl-C at the prompt, or a + # stray signal — a clean exit either way. Named on stderr + # because swallowed exceptions are undebuggable otherwise + # (a CI pty-test flake hid behind this for two runs). + self._quit = True + print(f"lecode: exiting on {type(e).__name__}", file=sys.stderr) finally: await self._fire_hook(SESSION_END) self._spinner_task.cancel() @@ -987,6 +1070,7 @@ def set_catalog(self, catalog: Any, *, origin: str, count: int) -> None: self._catalog = catalog self._runner._catalog = catalog self._runtime.ctx.catalog = catalog + self._arg_rows_cache = None # an open "/model " picker may now have rows with contextlib.suppress(Exception): # unknown model — keep the default self._status.context_window = catalog.get(self._config.llm.model).context_window if origin == "live": diff --git a/src/lecode/tui/pickers.py b/src/lecode/tui/pickers.py index 539d5bc..e1acc66 100644 --- a/src/lecode/tui/pickers.py +++ b/src/lecode/tui/pickers.py @@ -8,20 +8,30 @@ Insert-on-accept: an agent keeps the mention form (``@name `` — consumed by :func:`lecode.context.agents.parse_mentions`), a file inserts its path, a command inserts ``/name ``, a persona inserts ``.name ``. + +Commands with argument rows also pick their arguments: after ``/model `` +the menu offers the catalog, after ``/resume `` the folder's sessions, and +so on (:class:`CommandArgCompleter` routes ahead of path completion). """ from __future__ import annotations from collections.abc import AsyncGenerator +from pathlib import Path +from typing import TYPE_CHECKING -from prompt_toolkit.completion import CompleteEvent, Completer, Completion +from prompt_toolkit.completion import CompleteEvent, Completer, Completion, merge_completers from prompt_toolkit.document import Document from lecode.context.agents import AgentRegistry from lecode.context.resources import list_available from lecode.context.skills import SkillRegistry, skill_commands from lecode.slash.catalog import BUILTIN_COMMANDS -from lecode.tui.input import FileLister +from lecode.slash.registry import CommandRegistry, CompletionRow, SlashCommand +from lecode.tui.input import FileLister, PathCompleter + +if TYPE_CHECKING: + from lecode.tui.app import TuiApp #: Max file completions offered by the ``@`` picker. FILE_COMPLETION_LIMIT = 20 @@ -171,3 +181,102 @@ async def get_completions_async( def get_completions(self, document: Document, complete_event: CompleteEvent): # Unused: prompt_toolkit drives the async variant. return iter(()) + + +# -- command-argument pickers ------------------------------------------------------ + + +def command_arg_context( + document: Document, registry: CommandRegistry +) -> tuple[SlashCommand, list[str], str] | None: + """``(command, args, partial)`` when the buffer offers argument rows. + + ``args`` are the tokens before the cursor's token, ``partial`` the token + under the cursor. Resolution mirrors dispatch: exact name or unique + prefix, case-sensitive — unknown, ambiguous and free-text commands get + no picker. The command word itself belongs to the slash picker, so a + space must have been typed (``/model`` → ``None``, ``/model `` → context). + """ + text = document.text_before_cursor + if not text.startswith("/") or "\n" in text: + return None + words = text[1:].split(" ") + if len(words) < 2 or not words[0]: + return None + try: + command = registry.match(words[0]) + except KeyError: # unknown or ambiguous prefix — same as dispatch would say + return None + if command.arg_completions is None: + return None + # Empty tokens (double spaces) don't count as args — matches dispatch, + # which splits on any whitespace run. + return command, [w for w in words[1:-1] if w], words[-1] + + +def arg_ranked(partial: str, rows: list[CompletionRow]) -> list[CompletionRow]: + """Argument rows for ``partial``: provider order for an empty query + (catalog order, newest sessions first), otherwise fuzzy-filtered over + the inserted value and the display label, best match first. + """ + if not partial: + return rows + scored: list[tuple[int, CompletionRow]] = [] + for row in rows: + best = None + for field in (row[0], row[1]): + score = fuzzy_score(partial, field) + if score is not None and (best is None or score > best): + best = score + if best is not None: + scored.append((best, row)) + scored.sort(key=lambda item: (-item[0], item[1][0])) + return [row for _, row in scored] + + +class CommandArgCompleter(Completer): + """Routes completion: command-argument pickers, else paths + triggers. + + While the buffer is a command offering argument rows, those win and + path completion stays out — ``/model vendor/name`` is a model ref, not + a file. Providers can hit the disk (sessions, messages), so rows flow + through the app's single-slot cache (:meth:`TuiApp.arg_completion_rows`), + shared with the panel. + """ + + def __init__(self, app: TuiApp, fallback: Completer) -> None: + self._app = app + self._fallback = fallback + + async def get_completions_async( + self, document: Document, complete_event: CompleteEvent + ) -> AsyncGenerator[Completion, None]: + result = self._app.arg_completion_rows(document) + if result is None: + async for completion in self._fallback.get_completions_async(document, complete_event): + yield completion + return + _, _, partial, rows = result + for insert, display, meta in arg_ranked(partial, rows): + yield Completion( + f"{insert} ", # trailing space: the next token starts fresh + start_position=-len(partial), + display=display, + display_meta=meta, + ) + + def get_completions(self, document: Document, complete_event: CompleteEvent): + # Unused: prompt_toolkit drives the async variant. + return iter(()) + + +def build_completer( + app: TuiApp, cwd: Path, lister: FileLister, agents: AgentRegistry, skills: SkillRegistry +) -> CommandArgCompleter: + """The input completer: argument pickers first, then paths, then triggers.""" + return CommandArgCompleter( + app, + merge_completers( + [PathCompleter(cwd, lister=lister), TriggerCompleter(lister, agents, skills)] + ), + ) diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 65e4341..21f0a4a 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -16,6 +16,7 @@ from lecode.agent.builder import build_runtime from lecode.cli import app as cli_app from lecode.config.models import Config +from lecode.providers.catalog import Catalog from lecode.providers.types import Done, TokenDelta from lecode.session.storage import SessionStore from lecode.tui.app import QUEUE_LIMIT, TuiApp @@ -219,6 +220,7 @@ async def test_slash_menu_tab_accepts_first_match(tmp_path, monkeypatch): async def test_slash_menu_tab_accepts_navigated_match_without_submitting(tmp_path, monkeypatch): + """Tab fills the navigated command; with argument rows, its picker reopens.""" app, provider, out = make_app(tmp_path, monkeypatch, []) with create_pipe_input() as inp: task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) @@ -228,8 +230,15 @@ async def test_slash_menu_tab_accepts_navigated_match_without_submitting(tmp_pat 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 " + await wait_for( + lambda: ( + _buffer(app).text == "/model " + and _buffer(app).complete_state is not None + and _buffer(app).complete_state.original_document.text == "/model " + and _command_texts(_buffer(app).complete_state) + ) + ) # accepting '/model ' reopens completion: the model picker + assert "openai/gpt-5 " in _command_texts(_buffer(app).complete_state) assert provider.requests == [] assert out.getvalue() == "" inp.send_text("\x15/quit\r") @@ -258,7 +267,7 @@ async def test_slash_menu_escape_dismisses_and_restores(tmp_path, monkeypatch): async def test_slash_menu_closes_after_command_name(tmp_path, monkeypatch): - """A space after the command closes the menu and it stays closed.""" + """A space after an ambiguous command prefix closes the menu for good.""" app, _, _ = make_app(tmp_path, monkeypatch, []) with create_pipe_input() as inp: task = asyncio.ensure_future(app.run(input=inp, output=DummyOutput())) @@ -273,6 +282,82 @@ async def test_slash_menu_closes_after_command_name(tmp_path, monkeypatch): assert await task == 0 +# -- command-argument pickers ------------------------------------------------------ + + +async def test_model_argument_picker_opens_on_space(tmp_path, monkeypatch): + """/model + space opens the shared panel with the catalog rows.""" + 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("/model ") + await wait_for( + lambda: ( + _buffer(app).complete_state is not None + and _command_texts(_buffer(app).complete_state) + ) + ) + assert "openai/gpt-5 " in _command_texts(_buffer(app).complete_state) + inp.send_text("\x15/quit\r") + assert await task == 0 + + +async def test_model_picker_select_then_submit(tmp_path, monkeypatch): + """Down selects a row, Enter fills it (the menu closes), Enter executes.""" + 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("/model ") + await wait_for( + lambda: ( + _buffer(app).complete_state is not None + and _command_texts(_buffer(app).complete_state) + ) + ) + inp.send_text("\x1b[B") # Down -> first row (anthropic/claude-sonnet-4) + await wait_for(lambda: _buffer(app).text == "/model anthropic/claude-sonnet-4 ") + inp.send_text("\r") # accept: the position is consumed, the menu closes + await wait_for(lambda: _buffer(app).complete_state is None) + assert provider.requests == [] + inp.send_text("\r") # now submit the filled command + await wait_for(lambda: "model: anthropic/claude-sonnet-4" in out.getvalue()) + inp.send_text("\x15/quit\r") + assert await task == 0 + + +async def test_model_picker_inert_row_and_escape(tmp_path, monkeypatch): + """Empty catalog: the hint row shows; Escape dismisses it until edited.""" + 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) + app.set_catalog(Catalog([]), origin="live", count=0) # the fetch lands empty + inp.send_text("/model z") + await wait_for(lambda: app._arg_no_match() is not None) + assert "no models available" in app._arg_no_match()[1] + inp.send_text("\x1b") # Escape dismisses the inert row + await wait_for(lambda: app._arg_no_match() is None) + inp.send_text("z") # any edit: the hint is back for the new text + await wait_for(lambda: app._arg_no_match() is not None) + inp.send_text("\x15/quit\r") + assert await task == 0 + + +async def test_argument_picker_no_matching_options_row(tmp_path, monkeypatch): + """Rows exist but the filter misses: the generic no-match row shows.""" + 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("/model zzz") + await wait_for(lambda: app._arg_no_match() is not None) + assert app._arg_no_match()[1] == "no matching options" + 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, []) diff --git a/tests/test_tui_pickers.py b/tests/test_tui_pickers.py index c87b333..9aa583b 100644 --- a/tests/test_tui_pickers.py +++ b/tests/test_tui_pickers.py @@ -12,7 +12,7 @@ from prompt_toolkit.input import create_pipe_input from prompt_toolkit.output import DummyOutput from rich.console import Console -from tests.fakes import FakeProvider +from tests.fakes import FakeProvider, sample_catalog from lecode.agent.builder import build_runtime from lecode.config.models import Config @@ -20,9 +20,16 @@ from lecode.context.skills import Skill, SkillRegistry from lecode.extras.proc import ProcResult from lecode.session.storage import SessionStore +from lecode.slash.handlers import build_registry from lecode.tui.app import TuiApp from lecode.tui.input import FileLister -from lecode.tui.pickers import TriggerCompleter, fuzzy_score, persona_names +from lecode.tui.pickers import ( + TriggerCompleter, + arg_ranked, + command_arg_context, + fuzzy_score, + persona_names, +) # -- fuzzy_score --------------------------------------------------------------- @@ -219,6 +226,139 @@ async def _async(value): return value +# -- command-argument pickers ----------------------------------------------------- + + +def _doc(text: str) -> Document: + return Document(text, len(text)) + + +def _make_arg_app(tmp_path, monkeypatch, config=None): + """An app over the sample catalog and the real command registry.""" + monkeypatch.setenv("LECODE_CONFIG_DIR", str(tmp_path / "cfg")) + config = config or Config() + config.notifications.enabled = False # never play sounds in tests + store = SessionStore() + session = store.create("s", tmp_path, model=config.llm.model) + runtime = build_runtime(config, tmp_path, session=session, store=store) + return TuiApp( + config, + runtime, + FakeProvider([]), + session, + store, + console=Console(record=True, file=StringIO(), width=200), + catalog=sample_catalog(), + ) + + +def test_arg_context_needs_known_command_and_space(): + registry = build_registry() + assert command_arg_context(_doc("/model"), registry) is None # still in the command word + assert command_arg_context(_doc("/mod "), registry) is None # ambiguous prefix + assert command_arg_context(_doc("/nope "), registry) is None # unknown command + assert command_arg_context(_doc("/copy "), registry) is None # free-text args + assert command_arg_context(_doc("hello "), registry) is None + assert command_arg_context(_doc("/model one\ntwo"), registry) is None # multiline + command, args, partial = command_arg_context(_doc("/resume --delete ab"), registry) + assert (command.name, args, partial) == ("resume", ["--delete"], "ab") + + +def test_arg_ranked_keeps_provider_order_without_query(): + rows = [("3", "c", ""), ("1", "a", ""), ("2", "b", "")] + assert arg_ranked("", rows) == rows + + +def test_arg_ranked_matches_insert_or_display(): + rows = [("an/id", "Fancy Name", ""), ("zz/x", "Plain", "")] + assert arg_ranked("fancy", rows) == [("an/id", "Fancy Name", "")] # by display + assert arg_ranked("an/i", rows) == [("an/id", "Fancy Name", "")] # by insert + assert arg_ranked("q", rows) == [] + + +async def test_model_argument_picker_lists_catalog(tmp_path, monkeypatch): + app = _make_arg_app(tmp_path, monkeypatch) + completions = await _complete(app._completer, "/model ") + texts = [c.text for c in completions] + assert "openai/gpt-5 " in texts and "anthropic/claude-sonnet-4 " in texts + gpt = next(c for c in completions if c.text == "openai/gpt-5 ") + assert gpt.start_position == 0 # nothing typed yet: rows insert at the cursor + assert "GPT-5" in str(gpt.display_meta_text) # friendly label in the meta + + +async def test_model_argument_picker_filters_and_hides(tmp_path, monkeypatch): + config = Config() + config.ui.hidden_models = ["moonshotai/kimi-k2.6"] + app = _make_arg_app(tmp_path, monkeypatch, config) + texts = [c.text for c in await _complete(app._completer, "/model gpt5")] + assert "openai/gpt-5 " in texts # fuzzy over the id + assert "deepseek/deepseek-v4-flash " not in texts + texts = [c.text for c in await _complete(app._completer, "/model ")] + assert "moonshotai/kimi-k2.6 " not in texts # hidden_models honored + + +async def test_argument_picker_beats_path_completion(tmp_path, monkeypatch): + monkeypatch.setattr( + "lecode.tui.input.run_proc", + lambda *a, **k: _async(ProcResult(exit_code=0, stdout="anthropic/claude.md\n", stderr="")), + ) + app = _make_arg_app(tmp_path, monkeypatch) + texts = [c.text for c in await _complete(app._completer, "/model anthropic/cl")] + assert "anthropic/claude-sonnet-4 " in texts + assert "anthropic/claude.md" not in texts # a model ref is not a file path + texts = [c.text for c in await _complete(app._completer, "anthropic/claude.md")] + assert "anthropic/claude.md" in texts # fallback intact outside the arg context + + +async def test_resume_argument_picker_lists_sessions_newest_first(tmp_path, monkeypatch): + app = _make_arg_app(tmp_path, monkeypatch) + app.store.create("older", tmp_path, model="m") + newer = app.store.create("newer", tmp_path, model="m") + completions = await _complete(app._completer, "/resume ") + displays = [str(c.display_text) for c in completions] + assert displays.index("newer") < displays.index("older") # newest first + by_text = {c.text: c for c in completions} + assert f"{newer.id} " in by_text # canonical ids insert + assert "current" in str(by_text[f"{app.session.id} "].display_meta_text) + + +async def test_resume_delete_flag_completes_session_targets(tmp_path, monkeypatch): + app = _make_arg_app(tmp_path, monkeypatch) + other = app.store.create("other", tmp_path, model="m") + texts = [c.text for c in await _complete(app._completer, "/resume --delete ")] + assert f"{other.id} " in texts # deletion targets offered after the flag + + +async def test_literal_argument_pickers(tmp_path, monkeypatch): + app = _make_arg_app(tmp_path, monkeypatch) + assert [c.text for c in await _complete(app._completer, "/thinking ")] == [ + "none ", + "low ", + "medium ", + "high ", + ] + assert [c.text for c in await _complete(app._completer, "/mode ")] == ["readonly ", "yolo "] + assert [c.text for c in await _complete(app._completer, "/notifications ")] == [ + "on ", + "off ", + ] + + +async def test_nested_argument_stages(tmp_path, monkeypatch): + app = _make_arg_app(tmp_path, monkeypatch) + assert "model " in [c.text for c in await _complete(app._completer, "/pierre ")] + assert "openai/gpt-5 " in [c.text for c in await _complete(app._completer, "/pierre model ")] + assert "default " in [c.text for c in await _complete(app._completer, "/model-subagent ")] + assert "quit " in [c.text for c in await _complete(app._completer, "/help ")] + + +async def test_consumed_positions_offer_nothing(tmp_path, monkeypatch): + app = _make_arg_app(tmp_path, monkeypatch) + assert await _complete(app._completer, "/model openai/gpt-5 ") == [] + assert await _complete(app._completer, "/help quit ") == [] + assert await _complete(app._completer, "/thinking high ") == [] + + async def test_pipe_smoke_with_pickers(tmp_path, monkeypatch): """Completion machinery attached: submit flow still works end to end.""" monkeypatch.setenv("LECODE_CONFIG_DIR", str(tmp_path / "cfg")) diff --git a/tests/test_tui_streaming_pty.py b/tests/test_tui_streaming_pty.py index 442cb23..c8d8ef9 100644 --- a/tests/test_tui_streaming_pty.py +++ b/tests/test_tui_streaming_pty.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio +import contextlib import fcntl import os import pty @@ -98,7 +99,34 @@ async def drive(master: int, screen: pyte.HistoryScreen) -> list[str]: return drive +class _AppExitedEarly(AssertionError): + """The TUI exited while the driver was still mid-flow. + + A keystroke written after that echoes into a dead tty and every wait + times out with a misleading dump — the tell is the "Session …" totals + line printed *below* the frozen UI. TuiApp.run swallows the EOFError + / KeyboardInterrupt and names it on stderr; this fails fast with the + same fact. Known CI-runner-only flake (never reproduced locally: + dozens of loaded runs on both 3.14.3 and 3.14.7) — _run_pty_app + retries once on exactly this signature. + """ + + async def _run_pty_app( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + script: list[dict[str, Any]], + driver: Driver, + **kwargs: Any, +) -> list[str]: + try: + return await _run_pty_app_once(tmp_path, monkeypatch, script, driver, **kwargs) + except _AppExitedEarly: + print("pty test: app exited early (known CI flake) — retrying once", file=sys.stderr) + return await _run_pty_app_once(tmp_path, monkeypatch, script, driver, **kwargs) + + +async def _run_pty_app_once( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, script: list[dict[str, Any]], @@ -176,7 +204,28 @@ def reader_thread() -> None: session_pt._output = out try: task = asyncio.ensure_future(app.run(input=inp, output=out)) + # Gate keystrokes on the first real render: prompt_toolkit only + # enters raw mode (and flushes pre-start input) as the app takes + # over the terminal — a byte written before that is echoed by the + # line discipline instead of delivered to the input. + await _wait_for(lambda: _screen_lines(screen), "dir:") driven = asyncio.ensure_future(driver(master, screen)) + # The driver normally finishes first and its exit keystrokes end + # the app; wait on whichever completes first so an app that dies + # mid-flow fails fast instead of every driver wait timing out on + # a dead tty (the two CI flakes looked exactly like that). + done, _ = await asyncio.wait({task, driven}, return_when=asyncio.FIRST_COMPLETED) + if task in done and driven not in done: + driven.cancel() + with contextlib.suppress(asyncio.CancelledError): + await driven + exc = task.exception() + if exc is not None: + raise exc # a real crash: full traceback, no flake-retry + raise _AppExitedEarly( + "the TUI exited early — TuiApp.run swallowed the " + "EOFError/KeyboardInterrupt and named it on stderr" + ) await asyncio.wait_for(task, timeout=30) return await driven finally: @@ -411,3 +460,20 @@ async def drive(master: int, screen: pyte.HistoryScreen) -> list[str]: assert "allow bash" in dump, "approval prompt never shown:\n" + dump assert "pty-approved-out" in dump, "approved tool output missing:\n" + dump assert "approved done" in dump, "final answer missing:\n" + dump + + +async def test_app_exiting_mid_flow_fails_fast_and_retries(tmp_path, monkeypatch): + """A driver that outlives the app fails fast (not after a 15s stall) and + the known-flake signature gets exactly one retry.""" + attempts: list[int] = [] + + async def drive(master: int, screen: pyte.HistoryScreen) -> list[str]: + attempts.append(1) + lines = lambda: _screen_lines(screen) # noqa: E731 + os.write(master, b"/quit\r") # the app exits while the driver keeps waiting + await _wait_for(lines, "answer:") # never appears + return lines() + + with pytest.raises(_AppExitedEarly, match="exited early"): + await _run_pty_app(tmp_path, monkeypatch, [], drive) + assert len(attempts) == 2 # first attempt + the single flake retry