"""Input box widget for the TUI.
A multi-line prompt: ``Enter`` sends the message and long lines soft-wrap
visually (``TextArea`` defaults to ``soft_wrap=True``). The field grows with
content up to ``max-height`` and then scrolls internally.
A hard line break cannot be inserted via a modifier+Enter combo because the
common terminals do not distinguish Ctrl/Alt/Shift+Enter from plain Enter (they
all send ``\\r``), so any such binding would collide with the submit key. Soft
wrap covers the visual multiline case; revisit if a distinguishable newline key
is needed.
"""
from __future__ import annotations
from textual import events
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.widgets import TextArea
from clients.terminal.tui.events import UserSubmitted
from clients.terminal.tui.widgets.command_hints import CommandHints
class _PromptInput(TextArea):
"""Multi-line text area that submits on Enter.
``Tab`` completes an in-progress slash command: if the input starts with
``/`` and has no whitespace yet, Tab replaces the text with the canonical
name of the top matching command (plus a trailing space) and moves the
cursor to the end. ``Enter`` submits as before.
"""
async def _on_key(self, event: events.Key) -> None:
# Intercept before TextArea's own _on_key, which maps "enter" -> "\n".
if event.key == "enter":
event.stop()
event.prevent_default()
self.action_submit()
return
if event.key == "tab" and self._complete_command():
event.stop()
event.prevent_default()
return
await super()._on_key(event)
def _complete_command(self) -> bool:
"""If typing a slash command, complete to the top match. Returns True if handled."""
text = self.text
if not text.startswith("/") or any(ch.isspace() for ch in text):
return False
from clients.terminal.tui.commands.registry import get_registry
matches = get_registry().match(text[1:])
if not matches:
return False
self.text = f"/{matches[0].meta.name} "
self.move_cursor((0, len(self.text)))
return True
def action_submit(self) -> None:
text = self.text
if text.strip():
self.post_message(UserSubmitted(text))
self.text = ""
class InputBox(Vertical):
"""Bottom prompt frame with a multi-line input field."""
DEFAULT_CSS = """
InputBox {
height: auto;
min-height: 3;
border: heavy $tui-prompt-border;
background: $tui-surface;
color: $tui-text;
padding: 0 1;
}
InputBox > TextArea {
height: auto;
min-height: 1;
max-height: 12;
width: 100%;
border: none;
background: $tui-surface;
color: $tui-text;
padding: 0;
}
InputBox > TextArea:focus {
border: none;
background-tint: transparent;
}
"""
def __init__(self) -> None:
super().__init__()
self._hints = CommandHints()
self._input = _PromptInput(
text="",
placeholder="Ask anything... (Enter to send)",
classes="input-field",
show_line_numbers=False,
soft_wrap=True,
)
def compose(self) -> ComposeResult:
yield self._hints
yield self._input
def on_mount(self) -> None:
self._input.focus()
def on_text_area_changed(self, event: TextArea.Changed) -> None:
# Only the prompt's own textarea drives the hints.
if event.text_area is self._input:
self._hints.update_for(self._input.text)
def focus_input(self) -> None:
self._input.focus()
def set_placeholder(self, text: str) -> None:
self._input.placeholder = text