Coverage for src / lilbee / cli / tui / widgets / chat_input.py: 100%
41 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
1"""Multi-line chat prompt: TextArea with submit-on-Enter semantics.
3Behaves like a chat input box: Enter submits, Shift+Enter inserts a literal
4newline, paste preserves newlines so multi-line content (logs, code,
5~/.zshrc, etc.) round-trips correctly. Posts a ``ChatInput.Submitted``
6message on Enter so the screen handler can stay shaped like the previous
7``Input.Submitted`` flow.
9The completion overlay listens to :class:`textual.widgets.TextArea.Changed`
10events from this widget; no additional event plumbing is required here.
11"""
13from __future__ import annotations
15from dataclasses import dataclass
16from typing import ClassVar
18from textual import on
19from textual.binding import Binding, BindingType
20from textual.message import Message
21from textual.widgets import TextArea
24class ChatInput(TextArea):
25 """A TextArea variant where Enter submits and Shift+Enter inserts a newline."""
27 BINDINGS: ClassVar[list[BindingType]] = [
28 Binding("enter", "submit", "Send", show=False, priority=True),
29 Binding("shift+enter", "newline", "Newline", show=False, priority=True),
30 ]
32 # Keys we deliberately let bubble up to the App-level binding chain
33 # even though the underlying TextArea is happy to type them. Empty
34 # by default so printable characters (including ``?``) land as literal
35 # text in the input; the user explicitly asked for help to NOT pop
36 # mid-typing. Help still opens via F1 / Ctrl+H, and ``?`` works as a
37 # binding any time the chat input does not have focus.
38 _UNCONSUMED_KEYS: ClassVar[frozenset[str]] = frozenset()
40 # Per-keystroke layout cost is dominated by ``height: auto`` reflow.
41 # Pin the visual height to a single row while the content has no
42 # newline; flip to auto-grow only once a newline appears (Shift+Enter
43 # or pasted multi-line text). The CSS hook is the ``-multiline``
44 # class added by :meth:`_track_multiline`.
46 @dataclass
47 class Submitted(Message):
48 """Posted when the user presses Enter to send the current text."""
50 chat_input: ChatInput
51 value: str
53 @property
54 def control(self) -> ChatInput:
55 return self.chat_input
57 def __init__(
58 self,
59 *,
60 placeholder: str = "",
61 id: str | None = None,
62 ) -> None:
63 super().__init__(id=id, placeholder=placeholder, soft_wrap=True)
65 @property
66 def value(self) -> str:
67 """The current text, named for parity with ``Input.value`` callers."""
68 return self.text
70 @value.setter
71 def value(self, new_value: str) -> None:
72 self.load_text(new_value)
73 self.action_end()
75 def check_consume_key(self, key: str, character: str | None = None) -> bool:
76 """Pass App-level help/global keys back up to the binding chain."""
77 if key in self._UNCONSUMED_KEYS:
78 return False
79 return super().check_consume_key(key, character)
81 def action_submit(self) -> None:
82 self.post_message(self.Submitted(chat_input=self, value=self.text))
84 def action_newline(self) -> None:
85 self.insert("\n")
87 def action_end(self) -> None:
88 """Move cursor to end of all text (Input-compatible behavior)."""
89 last_line = self.document.line_count - 1
90 last_col = len(self.document.get_line(last_line))
91 self.move_cursor((last_line, last_col))
93 @on(TextArea.Changed)
94 def _track_multiline(self, _event: TextArea.Changed) -> None:
95 """Toggle the ``-multiline`` class so CSS can pin height for the
96 single-line case and let it grow only when newlines are present."""
97 self.set_class("\n" in self.text, "-multiline")