Coverage for src/lilbee/cli/tui/widgets/chat_input.py: 100%
55 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +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 events, on
19from textual.actions import SkipAction
20from textual.binding import Binding, BindingType
21from textual.message import Message
22from textual.widgets import TextArea
25class ChatInput(TextArea):
26 """A TextArea variant where Enter submits and Shift+Enter inserts a newline."""
28 BINDINGS: ClassVar[list[BindingType]] = [
29 Binding("enter", "submit", "Send", show=False, priority=True),
30 Binding("shift+enter", "newline", "Newline", show=False, priority=True),
31 ]
33 # Keys we deliberately let bubble up to the App-level binding chain
34 # even though the underlying TextArea is happy to type them. Empty
35 # by default so printable characters (including ``?``) land as literal
36 # text in the input; the user explicitly asked for help to NOT pop
37 # mid-typing. ``?`` opens help from any screen where a text field does
38 # not have focus, and from an empty prompt via _on_key below.
39 _UNCONSUMED_KEYS: ClassVar[frozenset[str]] = frozenset()
41 # Per-keystroke layout cost is dominated by ``height: auto`` reflow.
42 # Pin the visual height to a single row while the content fits one row;
43 # flip to auto-grow once it wraps or holds a newline. The CSS hook is the
44 # ``-multiline`` 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 async def _on_key(self, event: events.Key) -> None:
82 if event.key == "question_mark" and not self.text:
83 # An empty prompt can't be mid-typing, so ? opens the help panel;
84 # with any text present it stays a literal character.
85 event.prevent_default()
86 event.stop()
87 await self.app.run_action("push_help")
88 return
89 await super()._on_key(event)
91 def action_submit(self) -> None:
92 # Enter is a priority binding; when a drawer toggle holds focus, yield so
93 # Enter reaches that widget instead of submitting the prompt.
94 if not self.has_focus:
95 raise SkipAction()
96 self.post_message(self.Submitted(chat_input=self, value=self.text))
98 def action_newline(self) -> None:
99 self.insert("\n")
101 def action_end(self) -> None:
102 """Move cursor to end of all text (Input-compatible behavior)."""
103 last_line = self.document.line_count - 1
104 last_col = len(self.document.get_line(last_line))
105 self.move_cursor((last_line, last_col))
107 def _sync_multiline(self) -> None:
108 """Add ``-multiline`` when the prompt spans more than one wrapped row."""
109 self.set_class(self.wrapped_document.height > 1, "-multiline")
111 @on(TextArea.Changed)
112 def _track_multiline(self, _event: TextArea.Changed) -> None:
113 self._sync_multiline()
115 def on_resize(self, _event: events.Resize) -> None:
116 # A narrower terminal can wrap a prompt that fit one row when typed; re-check
117 # after the refresh (so the document has re-wrapped) and grow instead of clip.
118 self.call_after_refresh(self._sync_multiline)