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

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 events, on 

19from textual.actions import SkipAction 

20from textual.binding import Binding, BindingType 

21from textual.message import Message 

22from textual.widgets import TextArea 

23 

24 

25class ChatInput(TextArea): 

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

27 

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 ] 

32 

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

40 

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

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

90 

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

97 

98 def action_newline(self) -> None: 

99 self.insert("\n") 

100 

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

106 

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

110 

111 @on(TextArea.Changed) 

112 def _track_multiline(self, _event: TextArea.Changed) -> None: 

113 self._sync_multiline() 

114 

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)