Newer
Older
navi-1 / clients / terminal / tui / widgets / input_box.py
"""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

# How many submitted messages to keep for Up/Down recall (per session).
_HISTORY_CAP = 10


class _PromptInput(TextArea):
    """Multi-line text area that submits on Enter.

    Slash-command interaction while the hints list is open (input starts with
    ``/`` and has no whitespace yet):

    - ``Up``/``Down`` move the highlight through the matching commands.
    - ``Enter`` runs the highlighted command (it is dispatched via
      :class:`UserSubmitted` as ``/<name> ``, which the app routes to
      ``_run_command`` — not sent to the agent).
    - ``Tab`` completes the input to the highlighted command's canonical name
      (plus a trailing space) and keeps focus in the field for typing args.

    When the hints are not open, ``Enter`` submits the text as before (a
    ``/cmd args`` line is still routed to ``_run_command`` by the app).
    """

    def __init__(self, *args, hints: CommandHints | None = None, box=None, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        # Sibling hints widget. Named with a suffix to avoid shadowing any
        # Textual Widget internals (see the _context/_render pitfall).
        self._hints_ref: CommandHints | None = hints
        # Owning InputBox — owns the per-session message-history state.
        self._box_ref = box

    async def _on_key(self, event: events.Key) -> None:
        hints = self._hints_ref
        # Intercept before TextArea's own _on_key, which maps "enter" -> "\n".
        if event.key == "enter":
            event.stop()
            event.prevent_default()
            self._submit()
            return
        if event.key == "tab" and self._complete_command():
            event.stop()
            event.prevent_default()
            return
        if event.key in ("up", "down"):
            if hints is not None and hints.visible():
                # Hints list open: Up/Down navigate the command matches.
                delta = -1 if event.key == "up" else 1
                if hints.move_highlight(delta):
                    event.stop()
                    event.prevent_default()
                    return
                # Hints visible but nothing to move (single match) — fall through
                # to TextArea's own cursor movement, NOT history.
            elif self._maybe_history_nav(event.key):
                # Empty field + no hints: Up/Down walk the message history.
                event.stop()
                event.prevent_default()
                return
            # else fall through to TextArea (multiline cursor move)
        else:
            # Any non-Up/Down key (typing, submit, etc.) leaves history-browsing
            # mode so further Up/Down start a fresh recall from the newest.
            box = self._box_ref
            if box is not None and box.browsing:
                box.cancel_browsing()
        await super()._on_key(event)

    def _maybe_history_nav(self, key: str) -> bool:
        """Handle Up/Down as message-history recall. Returns True if consumed.

        Only enters history mode on ``Up`` when the field is empty; ``Down``
        only acts while already browsing (otherwise an empty field + Down is a
        no-op left to TextArea).
        """
        box = self._box_ref
        if box is None:
            return False
        if key == "up":
            if not box.browsing and self.text != "":
                return False  # non-empty field → multiline cursor, not history
            value = box.history_up(self.text)
            if value is None:
                # Empty history, or already at the oldest entry — consume only
                # while browsing (stay at the oldest), else let TextArea handle.
                return box.browsing
            self._set_history_text(value)
            return True
        else:  # down
            if not box.browsing:
                return False  # not browsing → TextArea cursor move
            value = box.history_down()
            if value is None:
                return False
            self._set_history_text(value)
            return True

    def _set_history_text(self, value: str) -> None:
        """Replace the field with a recalled history entry, cursor at the end."""
        self.text = value
        self.move_cursor((0, len(self.text)))

    def _submit(self) -> None:
        """Submit, but if a command hint is open, run the highlighted command."""
        hints = self._hints_ref
        if hints is not None and hints.visible():
            cmd = hints.current_match()
            if cmd is not None:
                # Route through action_submit so the app's _run_command handles it.
                self.text = f"/{cmd.meta.name} "
        self.action_submit()

    def _complete_command(self) -> bool:
        """If typing a slash command, complete to the highlighted match."""
        text = self.text
        if not text.startswith("/") or any(ch.isspace() for ch in text):
            return False
        cmd = None
        hints = self._hints_ref
        if hints is not None and hints.visible():
            cmd = hints.current_match()
        if cmd is None:
            # A bare "/" with no command name typed: do not auto-pick the first
            # command in the registry — the user has chosen nothing. (If the
            # hints list is open, current_match above already reflects their
            # highlighted choice.)
            if not text[1:].strip():
                return False
            from clients.terminal.tui.commands.registry import get_registry

            matches = get_registry().match(text[1:])
            cmd = matches[0] if matches else None
        if cmd is None:
            return False
        self.text = f"/{cmd.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__()
        # Per-session message history (bash-style recall). In-memory only — the
        # app records submitted plain messages and resets this on session
        # switch. Capped at the most recent ``_HISTORY_CAP`` entries with
        # consecutive-duplicate suppression.
        self._history: list[str] = []
        self._history_index: int = 0
        self._browsing: bool = False
        self._draft: str = ""
        self._hints = CommandHints()
        self._input = _PromptInput(
            text="",
            placeholder="Ask anything... (Enter to send)",
            classes="input-field",
            show_line_numbers=False,
            soft_wrap=True,
            hints=self._hints,
            box=self,
        )

    @property
    def browsing(self) -> bool:
        """True while Up/Down are walking the history (between entering and the
        Down-past-end that restores the draft)."""
        return self._browsing

    def append_history(self, text: str) -> None:
        """Record a submitted message (raw text the user typed).

        Slash commands and ``!`` shell invocations are NOT recorded — only
        plain messages to the agent. Consecutive duplicates are suppressed; the
        list is capped at the most recent ``_HISTORY_CAP`` entries.
        """
        if not text.startswith("/") and not text.startswith("!"):
            entry = text
            if self._history and self._history[-1] == entry:
                return  # dedup consecutive identical
            self._history.append(entry)
            if len(self._history) > _HISTORY_CAP:
                self._history = self._history[-_HISTORY_CAP:]
        # Either way, a submit ends any in-progress browsing and parks the index
        # past the newest entry so the next Up recalls the latest.
        self._browsing = False
        self._history_index = len(self._history)

    def history_up(self, current: str) -> str | None:
        """Recall the previous (older) entry. Enter browsing on first call.

        Returns the entry to show, or None if history is empty / already at the
        oldest (caller stays put).
        """
        if not self._history:
            return None
        if not self._browsing:
            self._browsing = True
            self._draft = current
            self._history_index = len(self._history)  # past the newest
        if self._history_index > 0:
            self._history_index -= 1
            return self._history[self._history_index]
        return None  # already at the oldest — stay

    def history_down(self) -> str | None:
        """Recall the next (newer) entry, or restore the draft past the newest.

        Returns the text to show (the draft, possibly empty, on past-end); None
        only if not browsing (should not be called then).
        """
        if not self._browsing:
            return None
        if self._history_index < len(self._history) - 1:
            self._history_index += 1
            return self._history[self._history_index]
        # Past the newest → restore the draft and leave browsing mode.
        self._browsing = False
        self._history_index = len(self._history)
        return self._draft

    def cancel_browsing(self) -> None:
        """Leave history-browsing mode (e.g. the user started typing)."""
        self._browsing = False
        self._history_index = len(self._history)

    def reset_history(self) -> None:
        """Clear the per-session history (on session switch/resume)."""
        self._history = []
        self._history_index = 0
        self._browsing = False
        self._draft = ""

    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