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

147 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +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 content = _ensure_sources_block(content, sources) 

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

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

95 self._finished = bool(content) 

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

97 self._reasoning_widget: Collapsible | None = None 

98 self._reasoning_static: Static | None = None 

99 self._thinking_header: ThinkingHeader | None = None 

100 self._last_md_update: float = 0.0 

101 self._last_reasoning_update: float = 0.0 

102 self._use_markdown: bool = cfg.markdown_rendering 

103 

104 def compose(self) -> ComposeResult: 

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

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

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

108 yield self._content_widget 

109 

110 def on_mount(self) -> None: 

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

112 

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

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

115 it is still being written. 

116 """ 

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

118 return 

119 header = ThinkingHeader() 

120 self._thinking_header = header 

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

122 

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

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

125 

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

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

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

129 

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

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

132 browser the default handling would use. 

133 """ 

134 if self._use_markdown: 

135 return Markdown( 

136 text, 

137 classes="response-md", 

138 parser_factory=_answer_markdown_parser, 

139 open_links=False, 

140 ) 

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

142 

143 @property 

144 def use_markdown(self) -> bool: 

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

146 return self._use_markdown 

147 

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

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

150 if self._content_widget is None: 

151 return 

152 self._use_markdown = use_markdown 

153 old = self._content_widget 

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

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

156 self._content_widget = new_widget 

157 await old.remove() 

158 

159 @staticmethod 

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

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

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

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

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

165 """ 

166 if isinstance(widget, Markdown): 

167 widget.update(text) 

168 else: 

169 widget.update(Content(text)) 

170 

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

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

173 first_token = not self._reasoning_parts 

174 self._reasoning_parts.append(text) 

175 if first_token and self._reasoning_widget is None: 

176 self._mount_reasoning_collapsible() 

177 now = time.monotonic() 

178 ready = now - self._last_reasoning_update >= _MD_UPDATE_INTERVAL 

179 if self._reasoning_static is not None and ready: 

180 self._last_reasoning_update = now 

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

182 

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

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

185 if self._thinking_header is not None: 

186 self._thinking_header.set_status(detail) 

187 

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

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

190 first_token = not self._content_parts 

191 self._content_parts.append(text) 

192 if first_token and not self._reasoning_parts: 

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

194 self._dismiss_thinking_header() 

195 now = time.monotonic() 

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

197 self._last_md_update = now 

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

199 self.refresh() 

200 

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

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

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

204 self._finished = True 

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

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

207 self._dismiss_thinking_header() 

208 if sources and self._content_parts: 

209 joined = _ensure_sources_block("".join(self._content_parts), sources) 

210 self._content_parts = [joined] 

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

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

213 self.refresh() 

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

215 if self._reasoning_static is not None: 

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

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

218 self._reasoning_widget.remove_class(_REASONING_STREAMING_CLASS) 

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

220 self._reasoning_widget.collapsed = True 

221 

222 def _mount_reasoning_collapsible(self) -> None: 

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

224 

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

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

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

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

229 """ 

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

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

232 collapsible = Collapsible( 

233 self._reasoning_static, 

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

235 collapsed=False, 

236 classes=classes, 

237 ) 

238 self._reasoning_widget = collapsible 

239 header = self._thinking_header 

240 if header is not None and header.is_mounted: 

241 self.mount(collapsible, after=header) 

242 return 

243 content = self._content_widget 

244 if content is not None: 

245 self.mount(collapsible, before=content) 

246 

247 def _dismiss_thinking_header(self) -> None: 

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

249 header = self._thinking_header 

250 if header is None: 

251 return 

252 header.stop() 

253 if header.is_mounted: 

254 header.remove() 

255 self._thinking_header = None 

256 

257 

258def _ensure_sources_block(content: str, sources: Sequence[str]) -> str: 

259 """Append the same numbered, clickable ``Sources:`` list a live answer 

260 carries, unless *content* already ends in one. One citation rendering 

261 everywhere: a transcript mixing TUI-saved and API-saved turns must not 

262 alternate styles.""" 

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

264 # import must not pay at TUI startup. 

265 from lilbee.retrieval.query.formatting import SOURCES_BLOCK_MARKER, source_markdown_link 

266 

267 if SOURCES_BLOCK_MARKER in content: 

268 return content 

269 lines = [f"{i}. {source_markdown_link(s)}" for i, s in enumerate(sources, 1)] 

270 return content.rstrip() + SOURCES_BLOCK_MARKER + "\n" + "\n".join(lines)