diff --git a/src/slashed/textual_adapter/command_input.py b/src/slashed/textual_adapter/command_input.py new file mode 100644 index 0000000..78afc2d --- /dev/null +++ b/src/slashed/textual_adapter/command_input.py @@ -0,0 +1,301 @@ +"""Command input widget with completion for Slashed.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any, ClassVar + +from prompt_toolkit.document import Document +from textual import on +from textual.binding import Binding +from textual.message import Message +from textual.widgets import Input + +from slashed.completion import CompletionContext, CompletionItem +from slashed.textual_adapter.dropdown import CommandDropdown, CompletionOption + + +if TYPE_CHECKING: + from textual.events import Key + + from slashed import CommandStore + from slashed.base import CommandContext + from slashed.textual_adapter.app import TextualOutputWriter + + +class CommandInput(Input): + """Input widget for entering slash commands with completion support.""" + + DEFAULT_CSS = """ + CommandInput { + height: 3; + border: solid $primary; + } + """ + value: str + cursor_position: int + + BINDINGS: ClassVar[list[Binding]] = [ + Binding("up", "navigate_up", "Previous suggestion", show=False), + Binding("down", "navigate_down", "Next suggestion", show=False), + Binding("escape", "hide_dropdown", "Hide suggestions", show=False), + ] + + def __init__( + self, + store: CommandStore, + data: Any | None = None, + output_writer: TextualOutputWriter | None = None, + placeholder: str = "Type a command...", + *, + id: str | None = None, # noqa: A002 + ): + super().__init__(placeholder=placeholder, id=id) + self.store = store + self.context: CommandContext[Any] = store.create_context( + data=data, output_writer=output_writer + ) + self._showing_dropdown = False + self._debug = True # Add debug flag + self._command_tasks: set[asyncio.Task[None]] = set() + + def on_key(self, event: Key) -> None: + """Handle special keys.""" + # Handle completion navigation keys + if self._showing_dropdown: + match event.key: + case "up": + self.action_navigate_up() + event.prevent_default() + event.stop() + case "down": + self.action_navigate_down() + event.prevent_default() + event.stop() + case "escape": + self.action_hide_dropdown() + event.prevent_default() + event.stop() + case "enter": + # Accept completion + self.action_accept_completion() + # If this was an argument completion, execute the command + if self.value.startswith("/") and " " in self.value: + self._create_command_task(self.value[1:]) + self.value = "" + event.prevent_default() + event.stop() + case "tab": + # Just accept completion without executing + self.action_accept_completion() + event.prevent_default() + event.stop() + + def on_mount(self) -> None: + """Mount the dropdown to the screen when input is mounted.""" + self._dropdown = CommandDropdown(id=f"{self.id}-dropdown") + self._dropdown.can_focus = False + self._dropdown.display = False + self.screen.mount(self._dropdown) + + def on_unmount(self) -> None: + """Cancel all running tasks when unmounting.""" + for task in self._command_tasks: + task.cancel() + + def _create_command_task(self, command: str) -> None: + """Create and store a command execution task.""" + task = asyncio.create_task(self._execute_command(command)) + + def _done_callback(t: asyncio.Task[None]) -> None: + self._command_tasks.discard(t) + + task.add_done_callback(_done_callback) + self._command_tasks.add(task) + + def _get_completions(self) -> list[CompletionItem]: + document = Document(text=self.value, cursor_position=self.cursor_position) + completion_context = CompletionContext( + document=document, command_context=self.context + ) + + parts = self.value[1:].split() + + if self._debug: + self.notify(f"Getting completions for parts: {parts}") + self.notify(f"Current word: {completion_context.current_word}") + + # Command name completion - only if we haven't typed a command yet + if not parts or (len(parts) == 1 and not self.value.endswith(" ")): + text = completion_context.current_word.lstrip("/") + if self._debug: + self.notify(f"Command completion for: '{text}'") + matches = [ + cmd for cmd in self.store.list_commands() if cmd.name.startswith(text) + ] + return [ + CompletionItem(text=cmd.name, metadata=cmd.description, kind="command") + for cmd in matches + ] + + # Argument completion + command_name = parts[0] + if self._debug: + self.notify(f"Looking for argument completions for command: {command_name}") + + if command := self.store.get_command(command_name): + if command_name == "help": + # Special case for help command + arg = parts[-1] if len(parts) > 1 else "" + matches = [ + cmd for cmd in self.store.list_commands() if cmd.name.startswith(arg) + ] + return [ + CompletionItem( + text=cmd.name, + metadata=cmd.description, + kind="command-arg", # type: ignore + ) + for cmd in matches + ] + + # For other commands, use their completer + if completer := command.get_completer(): + if self._debug: + self.notify(f"Found completer for command: {command_name}") + + # Create a new document for just the argument part + arg_text = parts[-1] if len(parts) > 1 else "" + arg_document = Document(text=arg_text, cursor_position=len(arg_text)) + arg_context = CompletionContext( + document=arg_document, command_context=self.context + ) + + completions = list(completer.get_completions(arg_context)) + if self._debug: + self.notify( + f"Got {len(completions)} completions from command completer" + ) + return completions + + return [] + + def _update_completions(self) -> None: + """Update the completion dropdown.""" + if self._debug: + self.notify("Updating completions...") + + completions = self._get_completions() + self._dropdown.clear_options() + + if completions: + # Add completion options to dropdown + options = [CompletionOption(completion) for completion in completions] + self._dropdown.add_options(options) + + if self._debug: + self.notify(f"Added {len(options)} options to dropdown") + + # Show dropdown + self._showing_dropdown = True + self._dropdown.display = True + + # Position dropdown using cursor_screen_offset + cursor_x, cursor_y = self.cursor_screen_offset + self._dropdown.styles.offset = (cursor_x, cursor_y + 1) + + # Update selection + if self._dropdown.option_count: + self._dropdown.highlighted = 0 + else: + if self._debug: + self.notify("No completions found, hiding dropdown") + self.action_hide_dropdown() + + def action_hide_dropdown(self) -> None: + """Hide the completion dropdown.""" + if self._showing_dropdown: + self._dropdown.display = False + self._showing_dropdown = False + + def action_navigate_up(self) -> None: + """Move selection up in dropdown.""" + if self._showing_dropdown and self._dropdown.option_count: + self._dropdown.action_cursor_up() + + def action_navigate_down(self) -> None: + """Move selection down in dropdown.""" + if self._showing_dropdown and self._dropdown.option_count: + self._dropdown.action_cursor_down() + + def action_accept_completion(self) -> None: + """Accept the currently selected completion.""" + if not self._showing_dropdown or self._dropdown.highlighted is None: + return + + option = self._dropdown.get_option_at_index(self._dropdown.highlighted) + if not isinstance(option, CompletionOption): + return + + completion = option.completion + + # Get current parts + parts = self.value[1:].split() + + if not parts or (len(parts) == 1 and not self.value.endswith(" ")): + # Command completion - replace whole value + new_value = f"/{completion.text}" + else: + # Argument completion - replace just the last part or add new part + if len(parts) > 1: + parts[-1] = completion.text + else: + parts.append(completion.text) + new_value = "/" + " ".join(parts) + + if self._debug: + self.notify(f"Accepting completion: {new_value}") + + # Update value and move cursor to end + self.value = new_value + self.cursor_position = len(new_value) + + self.action_hide_dropdown() + + async def _execute_command(self, command: str) -> None: + """Execute a command and emit result.""" + try: + await self.store.execute_command(command, self.context) + self.post_message(self.CommandExecuted(command)) + except Exception as e: # noqa: BLE001 + # Use output writer for errors too + await self.context.output.print(f"Error: {e}") + + @on(Input.Submitted) + async def _handle_submit(self, event: Input.Submitted) -> None: + """Handle command execution on submit.""" + if self._debug: + self.notify(f"Submit event received: {self.value}") + + if self.value.startswith("/"): + command = self.value[1:] # Remove leading slash + await self._execute_command(command) + self.value = "" + event.stop() + + def on_input_changed(self, message: Input.Changed) -> None: + """Update completions when input changes.""" + if self._debug: + self.notify(f"Input changed: {self.value}") + + if self.value.startswith("/"): + self._update_completions() + else: + self.action_hide_dropdown() + + class CommandExecuted(Message): + """Posted when a command is executed.""" + + def __init__(self, command: str): + self.command = command + super().__init__() diff --git a/src/slashed/textual_adapter/dropdown.py b/src/slashed/textual_adapter/dropdown.py new file mode 100644 index 0000000..0a29845 --- /dev/null +++ b/src/slashed/textual_adapter/dropdown.py @@ -0,0 +1,46 @@ +"""Dropdown widgets for command completion.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from rich.text import Text +from textual.widgets import OptionList +from textual.widgets.option_list import Option + + +if TYPE_CHECKING: + from slashed.completion import CompletionItem + + +class CompletionOption(Option): + """An option in the completion dropdown.""" + + def __init__(self, completion: CompletionItem): + display = Text.from_markup(f"[blue]{completion.text}[/]") + if completion.metadata: + display.append_text(Text.from_markup(f" - [green]{completion.metadata}[/]")) + super().__init__(display) + self.completion = completion + + +class CommandDropdown(OptionList): + """Dropdown list for command completions.""" + + DEFAULT_CSS = """ + CommandDropdown { + background: $surface; + border: solid $primary; + height: auto; + max-height: 10; + min-width: 30; + padding: 0 1; + layer: dropdown; + } + """ + + COMPONENT_CLASSES: ClassVar[set[str]] = { + "option-list--option", + "option-list--option-highlighted", + "option-list--option-hover-highlighted", + } diff --git a/src/slashed_examples/textual_command_input.py b/src/slashed_examples/textual_command_input.py new file mode 100644 index 0000000..f405c18 --- /dev/null +++ b/src/slashed_examples/textual_command_input.py @@ -0,0 +1,91 @@ +from textual.app import App, ComposeResult +from textual.containers import Container, VerticalScroll +from textual.widgets import Header, Label + +from slashed.base import CommandContext +from slashed.commands import SlashedCommand +from slashed.completers import ChoiceCompleter +from slashed.completion import CompletionProvider +from slashed.store import CommandStore +from slashed.textual_adapter.app import TextualOutputWriter +from slashed.textual_adapter.command_input import CommandInput + + +class ColorCommand(SlashedCommand): + """Change color scheme.""" + + name = "color" + category = "settings" + usage = "" + + def get_completer(self) -> CompletionProvider: + return ChoiceCompleter({ + "dark": "Dark color scheme", + "light": "Light color scheme", + "blue": "Blue theme", + "green": "Green theme", + "red": "Red theme", + }) + + async def execute_command( + self, + ctx: CommandContext, + scheme: str, + ): + """Change the color scheme.""" + await ctx.output.print(f"Changing color scheme to: {scheme}") + + +class NewDemoApp(App[None]): + """Demo app showing new command input with completion.""" + + CSS = """ + Screen { + layers: base dropdown; /* Ensure we have the dropdown layer */ + } + + CommandDropdown { + layer: dropdown; /* Put dropdown in correct layer */ + background: $surface; + border: solid red; + width: auto; + height: auto; + min-width: 30; + } + """ + + def __init__(self): + super().__init__() + self.store = CommandStore(enable_system_commands=True) + self.output_writer = TextualOutputWriter(self) + + def compose(self) -> ComposeResult: + """Create app layout.""" + yield Header() + + # Create containers for output + yield Container( + CommandInput( + store=self.store, + output_writer=self.output_writer, + id="command-input", + placeholder="Type /help or /greet ", + ) + ) + + # Output areas + yield VerticalScroll(id="main-output") + yield Label(id="status") + + async def on_mount(self) -> None: + """Set up output routing after widgets are mounted.""" + # Create and bind output writer + await self.store.initialize() + self.store.register_command(ColorCommand()) + self.output_writer.bind("main", "#main-output", default=True) + self.output_writer.bind("status", "#status") + + +if __name__ == "__main__": + app = NewDemoApp() + app.run()