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

1"""Multi-line chat prompt: TextArea with submit-on-Enter semantics. 

2 

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. 

8 

9The completion overlay listens to :class:`textual.widgets.TextArea.Changed` 

10events from this widget; no additional event plumbing is required here. 

11""" 

12 

13from __future__ import annotations 

14 

15from dataclasses import dataclass 

16from typing import ClassVar 

17 

18from textual import on 

19from textual.binding import Binding, BindingType 

20from textual.message import Message 

21from textual.widgets import TextArea 

22 

23 

24class ChatInput(TextArea): 

25 """A TextArea variant where Enter submits and Shift+Enter inserts a newline.""" 

26 

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 ] 

31 

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() 

39 

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`. 

45 

46 @dataclass 

47 class Submitted(Message): 

48 """Posted when the user presses Enter to send the current text.""" 

49 

50 chat_input: ChatInput 

51 value: str 

52 

53 @property 

54 def control(self) -> ChatInput: 

55 return self.chat_input 

56 

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) 

64 

65 @property 

66 def value(self) -> str: 

67 """The current text, named for parity with ``Input.value`` callers.""" 

68 return self.text 

69 

70 @value.setter 

71 def value(self, new_value: str) -> None: 

72 self.load_text(new_value) 

73 self.action_end() 

74 

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) 

80 

81 def action_submit(self) -> None: 

82 self.post_message(self.Submitted(chat_input=self, value=self.text)) 

83 

84 def action_newline(self) -> None: 

85 self.insert("\n") 

86 

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)) 

92 

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")