Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 174 additions & 8 deletions src/lecode/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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 -------------------------------

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 11 additions & 3 deletions src/lecode/tui/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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_")

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
35 changes: 21 additions & 14 deletions src/lecode/tui/pickers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/lecode/tui/themes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading