Coverage for src/lilbee/cli/tui/widgets/message.py: 100%

143 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +0000

1"""Chat message widgets: user and assistant bubbles.""" 

2 

3from __future__ import annotations 

4 

5import time 

6from collections.abc import Sequence 

7from pathlib import Path 

8from typing import ClassVar 

9 

10from markdown_it import MarkdownIt 

11from textual.app import ComposeResult 

12from textual.containers import Vertical 

13from textual.content import Content 

14from textual.widgets import Collapsible, Markdown, Static 

15from typing_extensions import override 

16 

17from lilbee.cli.tui import messages as msg 

18from lilbee.cli.tui.widgets.thinking_header import ThinkingHeader 

19from lilbee.core.config import cfg 

20 

21# Minimum interval (seconds) between markdown widget updates during streaming 

22_MD_UPDATE_INTERVAL = 0.1 

23 

24_SPEAKER_YOU = "[bold $primary]you[/]" 

25_SPEAKER_LILBEE = "[bold $success]lilbee[/]" 

26 

27 

28class _AnswerMarkdownIt(MarkdownIt): 

29 """Markdown parser for answers, additionally allowing ``file:`` links. 

30 

31 markdown-it rejects ``file:`` destinations by default (a web-context XSS 

32 guard), which left the Sources block's citation links rendering as raw 

33 ``[label](file://...)`` text. Answers link to the reader's own documents on 

34 disk, so re-admit the scheme; everything else keeps the default validation. 

35 """ 

36 

37 @override 

38 def validateLink(self, url: str) -> bool: 

39 return url.startswith("file://") or super().validateLink(url) 

40 

41 

42def _answer_markdown_parser() -> MarkdownIt: 

43 """Textual's default gfm-like parser with ``file:`` links admitted.""" 

44 return _AnswerMarkdownIt("gfm-like") 

45 

46 

47_REASONING_BLOCK_CLASS = "reasoning-block" 

48_REASONING_STREAMING_CLASS = "-streaming" 

49 

50_CSS_FILE = Path(__file__).parent / "message.tcss" 

51_MESSAGE_CSS = _CSS_FILE.read_text(encoding="utf-8") 

52 

53 

54class UserMessage(Vertical): 

55 """A user's question in the chat log.""" 

56 

57 DEFAULT_CSS: ClassVar[str] = _MESSAGE_CSS 

58 

59 def __init__(self, text: str) -> None: 

60 super().__init__(classes="user-message") 

61 self._text = text 

62 

63 def compose(self) -> ComposeResult: 

64 yield Static(_SPEAKER_YOU, classes="speaker-label") 

65 # Content() renders the question literally: a user asking about e.g. arr[0] 

66 # or "[/]" must not have it parsed as console markup (which would crash). 

67 yield Static(Content(self._text), classes="message-content") 

68 

69 

70class AssistantMessage(Vertical): 

71 """An assistant's response with streaming markdown, reasoning, and citations.""" 

72 

73 DEFAULT_CSS: ClassVar[str] = _MESSAGE_CSS 

74 

75 def __init__(self, content: str = "", sources: Sequence[str] = ()) -> None: 

76 """A live answer bubble, or a finished one restored from a saved session. 

77 

78 A restored turn's *content* must be passed here, not appended after 

79 mounting: mount() is async, so compose has not run and appends no-op 

80 against a still-None ``_content_widget``, silently dropping the text. 

81 

82 Sources render ONE way: the clickable numbered ``Sources:`` list a live 

83 answer carries in its text. A turn arriving with structured *sources* 

84 but no in-text list (seeded or written over HTTP/MCP, where the answer 

85 text and the sources array are stored side by side) gets the same list 

86 synthesized into its content, so a mixed transcript reads uniformly 

87 instead of alternating between two citation styles. 

88 """ 

89 super().__init__(classes="assistant-message") 

90 self._reasoning_parts: list[str] = [] 

91 if content and sources: 

92 # Lazy: formatting transitively imports the store stack, which widget 

93 # import must not pay at TUI startup. 

94 from lilbee.retrieval.query.formatting import with_sources_block 

95 

96 content = with_sources_block(content, sources) 

97 self._content_parts: list[str] = [content] if content else [] 

98 # A restored turn is finished by definition: it must not raise a spinner. 

99 self._finished = bool(content) 

100 self._content_widget: Markdown | Static | None = None 

101 self._reasoning_widget: Collapsible | None = None 

102 self._reasoning_static: Static | None = None 

103 self._thinking_header: ThinkingHeader | None = None 

104 self._last_md_update: float = 0.0 

105 self._last_reasoning_update: float = 0.0 

106 self._use_markdown: bool = cfg.markdown_rendering 

107 

108 def compose(self) -> ComposeResult: 

109 yield Static(_SPEAKER_LILBEE, classes="speaker-label") 

110 # Built with the restored text (empty for a live turn, which streams in). 

111 self._content_widget = self._build_content_widget("".join(self._content_parts)) 

112 yield self._content_widget 

113 

114 def on_mount(self) -> None: 

115 """Raise the thinking header, unless this turn is already finished. 

116 

117 ``compose`` populates ``_content_widget`` before this hook runs. A 

118 restored turn's answer is already on screen, so a spinner would claim 

119 it is still being written. 

120 """ 

121 if self._content_widget is None or self._finished: 

122 return 

123 header = ThinkingHeader() 

124 self._thinking_header = header 

125 self.mount(header, before=self._content_widget) 

126 

127 def _build_content_widget(self, text: str = "") -> Markdown | Static: 

128 """Create the content widget based on the current rendering mode. 

129 

130 *text* must be passed at construction rather than via a later 

131 ``update()``: Textual's Markdown re-renders from its constructor 

132 argument on mount, discarding any pre-mount update. 

133 

134 ``open_links=False``: clicks route to the chat screen's link handler, 

135 which opens ``file:`` citations with the OS opener instead of the 

136 browser the default handling would use. 

137 """ 

138 if self._use_markdown: 

139 return Markdown( 

140 text, 

141 classes="response-md", 

142 parser_factory=_answer_markdown_parser, 

143 open_links=False, 

144 ) 

145 return Static(Content(text), classes="response-md") 

146 

147 @property 

148 def use_markdown(self) -> bool: 

149 """Whether this message is using Markdown rendering.""" 

150 return self._use_markdown 

151 

152 async def rebuild_content_widget(self, use_markdown: bool) -> None: 

153 """Replace the content widget with a different rendering mode.""" 

154 if self._content_widget is None: 

155 return 

156 self._use_markdown = use_markdown 

157 old = self._content_widget 

158 new_widget = self._build_content_widget("".join(self._content_parts)) 

159 await self.mount(new_widget, after=old) 

160 self._content_widget = new_widget 

161 await old.remove() 

162 

163 @staticmethod 

164 def _set_content(widget: Markdown | Static, text: str) -> None: 

165 """Update a content widget with raw model text. A Markdown widget consumes 

166 the raw markdown string, but a Static parses console markup -- so wrap the 

167 text as literal Content. Otherwise a ``[..]`` in the answer (quoted code, an 

168 option like ``[/path]``) raises MarkupError and crashes the whole TUI. 

169 """ 

170 if isinstance(widget, Markdown): 

171 widget.update(text) 

172 else: 

173 widget.update(Content(text)) 

174 

175 def append_reasoning(self, text: str) -> None: 

176 """Append a reasoning token; debounced at ``_MD_UPDATE_INTERVAL``.""" 

177 first_token = not self._reasoning_parts 

178 self._reasoning_parts.append(text) 

179 if first_token and self._reasoning_widget is None: 

180 self._mount_reasoning_collapsible() 

181 now = time.monotonic() 

182 ready = now - self._last_reasoning_update >= _MD_UPDATE_INTERVAL 

183 if self._reasoning_static is not None and ready: 

184 self._last_reasoning_update = now 

185 self._reasoning_static.update(Content("".join(self._reasoning_parts))) 

186 

187 def set_thinking_status(self, detail: str) -> None: 

188 """Show *detail* beside the thinking animator (e.g. an engine-load phase).""" 

189 if self._thinking_header is not None: 

190 self._thinking_header.set_status(detail) 

191 

192 def append_content(self, text: str) -> None: 

193 """Append response content token (debounced markdown updates).""" 

194 first_token = not self._content_parts 

195 self._content_parts.append(text) 

196 if first_token and not self._reasoning_parts: 

197 # No reasoning ever arrived; drop the standalone header. 

198 self._dismiss_thinking_header() 

199 now = time.monotonic() 

200 if self._content_widget is not None and now - self._last_md_update >= _MD_UPDATE_INTERVAL: 

201 self._last_md_update = now 

202 self._set_content(self._content_widget, "".join(self._content_parts)) 

203 self.refresh() 

204 

205 def finish(self, sources: list[str] | None = None) -> None: 

206 """Mark response as complete, folding any structured *sources* into the 

207 answer's own ``Sources:`` list (live RAG answers already carry one).""" 

208 self._finished = True 

209 # Always retire the standalone header on finish; the reasoning fold 

210 # (if mounted) carries the post-stream title. 

211 self._dismiss_thinking_header() 

212 if sources and self._content_parts: 

213 # Lazy: formatting transitively imports the store stack, which widget 

214 # import must not pay at TUI startup. 

215 from lilbee.retrieval.query.formatting import with_sources_block 

216 

217 joined = with_sources_block("".join(self._content_parts), sources) 

218 self._content_parts = [joined] 

219 if self._content_widget is not None and self._content_parts: 

220 self._set_content(self._content_widget, "".join(self._content_parts)) 

221 self.refresh() 

222 if self._reasoning_widget is not None and self._reasoning_parts: 

223 if self._reasoning_static is not None: 

224 self._reasoning_static.update(Content("".join(self._reasoning_parts))) 

225 token_count = len("".join(self._reasoning_parts).split()) 

226 self._reasoning_widget.remove_class(_REASONING_STREAMING_CLASS) 

227 self._reasoning_widget.title = msg.CHAT_REASONING_FINISHED.format(tokens=token_count) 

228 self._reasoning_widget.collapsed = True 

229 

230 def _mount_reasoning_collapsible(self) -> None: 

231 """Mount the reasoning Collapsible with the streaming-state class. 

232 

233 Called from ``append_reasoning`` on the first reasoning token, after 

234 the message itself is mounted. The Collapsible slots in beneath the 

235 ``ThinkingHeader`` so the animator continues to drive the visual 

236 weight while the toggle row is hidden by the ``-streaming`` rule. 

237 """ 

238 classes = f"{_REASONING_BLOCK_CLASS} {_REASONING_STREAMING_CLASS}" 

239 self._reasoning_static = Static("", classes="reasoning-text") 

240 collapsible = Collapsible( 

241 self._reasoning_static, 

242 title=msg.CHAT_REASONING_FINISHED.format(tokens=0), 

243 collapsed=False, 

244 classes=classes, 

245 ) 

246 self._reasoning_widget = collapsible 

247 header = self._thinking_header 

248 if header is not None and header.is_mounted: 

249 self.mount(collapsible, after=header) 

250 return 

251 content = self._content_widget 

252 if content is not None: 

253 self.mount(collapsible, before=content) 

254 

255 def _dismiss_thinking_header(self) -> None: 

256 """Stop the animator and remove the standalone header from the DOM.""" 

257 header = self._thinking_header 

258 if header is None: 

259 return 

260 header.stop() 

261 if header.is_mounted: 

262 header.remove() 

263 self._thinking_header = None