返回 CodeWhale
message.rs
根目录 / crates / tui / src / tui / history / message.rs
1 //! User, assistant, and system message transcript rendering.
2
3 use ratatui::style::{Modifier, Style};
4 use ratatui::text::{Line, Span};
5 use unicode_width::UnicodeWidthStr;
6
7 use crate::palette;
8 use crate::tui::markdown_render;
9 use crate::tui::ui_text::CopyLineSeparator;
10
11 use super::{ASSISTANT_GLYPH, USER_GLYPH};
12
13 pub(crate) struct RenderedTranscriptLine {
14 pub line: Line<'static>,
15 pub links: Vec<crate::tui::osc8::LineLink>,
16 pub copy_prefix_width: usize,
17 pub copy_separator_after: CopyLineSeparator,
18 }
19
20 pub(super) fn render_message(
21 prefix: &str,
22 label_style: Style,
23 body_style: Style,
24 content: &str,
25 width: u16,
26 ) -> Vec<Line<'static>> {
27 render_message_with_copy_metadata(prefix, label_style, body_style, content, width)
28 .into_iter()
29 .map(|rendered| rendered.line)
30 .collect()
31 }
32
33 pub(super) fn render_message_with_copy_metadata(
34 prefix: &str,
35 label_style: Style,
36 body_style: Style,
37 content: &str,
38 width: u16,
39 ) -> Vec<RenderedTranscriptLine> {
40 render_message_with_copy_metadata_for_palette(
41 prefix,
42 label_style,
43 body_style,
44 content,
45 width,
46 markdown_render::detected_palette_mode(),
47 )
48 }
49
50 pub(super) fn render_message_with_copy_metadata_for_palette(
51 prefix: &str,
52 label_style: Style,
53 body_style: Style,
54 content: &str,
55 width: u16,
56 palette_mode: palette::PaletteMode,
57 ) -> Vec<RenderedTranscriptLine> {
58 // An assistant cell whose content is entirely whitespace (e.g. a stray
59 // newline streamed between reasoning and a tool call) would otherwise
60 // render as a bare, orphaned role glyph floating on its own line — the
61 // "blue dots with nothing after them" artifact. Render nothing so the
62 // transcript doesn't accumulate empty markers. Real prose, including
63 // messages that merely start with blank lines, still renders normally.
64 if prefix == ASSISTANT_GLYPH && content.trim().is_empty() {
65 return Vec::new();
66 }
67 let prefix_width = UnicodeWidthStr::width(prefix);
68 let prefix_width_u16 = u16::try_from(prefix_width.saturating_add(2)).unwrap_or(u16::MAX);
69 let content_width = usize::from(width.saturating_sub(prefix_width_u16).max(1));
70 let mut lines = Vec::new();
71 // Pre-process $...$ and $$...$$ LaTeX math expressions into Unicode
72 let content_rendered = super::latex_render::render_latex_in_text(content);
73 let rendered = markdown_render::render_markdown_tagged_with_palette(
74 &content_rendered,
75 content_width as u16,
76 body_style,
77 palette_mode,
78 );
79 for (idx, rendered_line) in rendered.into_iter().enumerate() {
80 lines.push(decorate_markdown_line(
81 prefix,
82 prefix_width,
83 label_style,
84 idx,
85 rendered_line,
86 ));
87 }
88 if lines.is_empty() {
89 lines.push(RenderedTranscriptLine {
90 line: Line::from(""),
91 links: Vec::new(),
92 copy_prefix_width: 0,
93 copy_separator_after: CopyLineSeparator::Newline,
94 });
95 }
96 lines
97 }
98
99 #[allow(clippy::too_many_arguments)]
100 pub(crate) fn update_streaming_message_render(
101 cache: &mut markdown_render::IncrementalMarkdownRenderCache,
102 content: &str,
103 width: u16,
104 label_style: Style,
105 body_style: Style,
106 palette_mode: palette::PaletteMode,
107 verified_append: bool,
108 lines: &mut Vec<Line<'static>>,
109 links: &mut Vec<Vec<crate::tui::osc8::LineLink>>,
110 copy_separators: &mut Vec<CopyLineSeparator>,
111 copy_prefix_widths: &mut Vec<usize>,
112 ) -> usize {
113 let prefix = ASSISTANT_GLYPH;
114 let prefix_width = UnicodeWidthStr::width(prefix);
115 let prefix_width_u16 = u16::try_from(prefix_width.saturating_add(2)).unwrap_or(u16::MAX);
116 let content_width = width.saturating_sub(prefix_width_u16).max(1);
117 // Pre-process $...$ and $$...$$ LaTeX math expressions into Unicode
118 let content = super::latex_render::render_latex_in_text(content);
119 let delta = cache.update(
120 &content,
121 content_width,
122 body_style,
123 palette_mode,
124 verified_append,
125 );
126
127 let replace_from = delta.replace_from;
128 lines.truncate(replace_from);
129 links.truncate(replace_from);
130 copy_separators.truncate(replace_from);
131 copy_prefix_widths.truncate(replace_from);
132
133 for rendered_line in delta.lines {
134 let index = lines.len();
135 let rendered =
136 decorate_markdown_line(prefix, prefix_width, label_style, index, rendered_line);
137 lines.push(rendered.line);
138 links.push(rendered.links);
139 copy_separators.push(rendered.copy_separator_after);
140 copy_prefix_widths.push(rendered.copy_prefix_width);
141 }
142 replace_from
143 }
144
145 fn decorate_markdown_line(
146 prefix: &str,
147 prefix_width: usize,
148 label_style: Style,
149 index: usize,
150 rendered_line: markdown_render::RenderedMarkdownLine,
151 ) -> RenderedTranscriptLine {
152 let display_prefix_width = if prefix.is_empty() {
153 0
154 } else {
155 prefix_width + 1
156 };
157 let links = rendered_line
158 .links
159 .iter()
160 .map(|link| link.shifted(display_prefix_width))
161 .collect();
162 let line = if index == 0 {
163 let mut spans = Vec::new();
164 if !prefix.is_empty() {
165 spans.push(Span::styled(
166 prefix.to_string(),
167 label_style.add_modifier(Modifier::BOLD),
168 ));
169 spans.push(Span::raw(" "));
170 }
171 spans.extend(rendered_line.line.spans);
172 Line::from(spans)
173 } else {
174 let indent = if prefix.is_empty() {
175 String::new()
176 } else if rendered_line.is_code {
177 " ".repeat(prefix_width + 1)
178 } else {
179 let mut s = String::with_capacity(prefix_width + 1);
180 s.push('\u{258F}');
181 s.extend(std::iter::repeat_n(' ', prefix_width));
182 s
183 };
184 let rail_style = Style::default().fg(palette::TEXT_DIM);
185 let mut spans = vec![Span::styled(indent, rail_style)];
186 spans.extend(rendered_line.line.spans);
187 Line::from(spans)
188 };
189 RenderedTranscriptLine {
190 line,
191 links,
192 copy_prefix_width: rendered_line.copy_prefix_width
193 + history_copy_prefix_width(prefix, prefix_width, rendered_line.is_code, index),
194 copy_separator_after: rendered_line.copy_separator_after,
195 }
196 }
197
198 fn history_copy_prefix_width(
199 prefix: &str,
200 prefix_width: usize,
201 is_code: bool,
202 line_index: usize,
203 ) -> usize {
204 if line_index > 0 && is_code && !prefix.is_empty() {
205 prefix_width + 1
206 } else {
207 0
208 }
209 }
210
211 pub(super) fn hard_break_copy_lines(lines: Vec<Line<'static>>) -> Vec<RenderedTranscriptLine> {
212 lines
213 .into_iter()
214 .map(|line| RenderedTranscriptLine {
215 line,
216 links: Vec::new(),
217 copy_prefix_width: 0,
218 copy_separator_after: CopyLineSeparator::Newline,
219 })
220 .collect()
221 }
222
223 /// Render a plain-text user message: split on newlines, word-wrap each line,
224 /// preserve leading whitespace. No markdown interpretation (headings, lists,
225 /// code blocks, etc. are rendered as literal text).
226 pub(super) fn render_plain_message(
227 prefix: &str,
228 label_style: Style,
229 body_style: Style,
230 content: &str,
231 width: u16,
232 ) -> Vec<Line<'static>> {
233 let prefix_width = UnicodeWidthStr::width(prefix);
234 let prefix_width_u16 = u16::try_from(prefix_width.saturating_add(2)).unwrap_or(u16::MAX);
235 let content_width = width.saturating_sub(prefix_width_u16).max(1);
236 // Pre-process $...$ and $$...$$ LaTeX math expressions into Unicode
237 let content_rendered = super::latex_render::render_latex_in_text(content);
238 let rendered = markdown_render::render_plain_text(&content_rendered, content_width, body_style);
239 let mut lines = Vec::with_capacity(rendered.len());
240
241 for (idx, line) in rendered.into_iter().enumerate() {
242 if idx == 0 {
243 let mut spans = Vec::new();
244 if !prefix.is_empty() {
245 spans.push(Span::styled(
246 prefix.to_string(),
247 label_style.add_modifier(Modifier::BOLD),
248 ));
249 spans.push(Span::raw(" "));
250 }
251 spans.extend(line.spans);
252 lines.push(Line::from(spans));
253 } else {
254 let indent = if prefix.is_empty() {
255 String::new()
256 } else {
257 let mut s = String::with_capacity(prefix_width + 1);
258 s.push('\u{258F}');
259 s.extend(std::iter::repeat_n(' ', prefix_width));
260 s
261 };
262 let rail_style = Style::default().fg(palette::TEXT_DIM);
263 let mut spans = vec![Span::styled(indent, rail_style)];
264 spans.extend(line.spans);
265 lines.push(Line::from(spans));
266 }
267 }
268
269 if lines.is_empty() {
270 lines.push(Line::from(""));
271 }
272 lines
273 }
274
275 pub(super) fn render_user_message(content: &str, width: u16) -> Vec<Line<'static>> {
276 render_plain_message(
277 USER_GLYPH,
278 user_label_style(),
279 user_body_style(),
280 content,
281 width,
282 )
283 .into_iter()
284 .map(|line| apply_user_message_highlight(line, width))
285 .collect()
286 }
287
288 fn apply_user_message_highlight(mut line: Line<'static>, width: u16) -> Line<'static> {
289 let bg = palette::SURFACE_ELEVATED;
290 line.style = line.style.bg(bg);
291
292 let target_width = usize::from(width);
293 let line_width = line.width();
294 if line_width < target_width {
295 line.spans.push(Span::styled(
296 " ".repeat(target_width - line_width),
297 Style::default().bg(bg),
298 ));
299 }
300
301 line
302 }
303
304 pub(super) fn user_label_style() -> Style {
305 Style::default().fg(palette::USER_BODY)
306 }
307
308 pub(super) fn user_body_style() -> Style {
309 Style::default().fg(palette::USER_BODY)
310 }
311
312 /// Style for the assistant glyph (`●`). When the cell is streaming and
313 /// motion is allowed, the foreground pulses on a 2s cycle between 30% and
314 /// 100% brightness — the only deliberately animated element in a calm
315 /// transcript. When idle (or low_motion is on) it sits at the full DeepSeek
316 /// sky color so finished turns read as solid rather than dim.
317 pub(super) fn assistant_label_style_for(streaming: bool, low_motion: bool) -> Style {
318 let color = if streaming && !low_motion {
319 let now_ms = std::time::SystemTime::now()
320 .duration_since(std::time::UNIX_EPOCH)
321 .map(|d| d.as_millis() as u64)
322 .unwrap_or(0);
323 palette::pulse_brightness(palette::WHALE_INFO, now_ms)
324 } else {
325 palette::WHALE_INFO
326 };
327 Style::default().fg(color)
328 }
329
330 pub(super) fn system_label_style() -> Style {
331 Style::default().fg(palette::TEXT_DIM)
332 }
333
334 pub(super) fn message_body_style() -> Style {
335 Style::default().fg(palette::TEXT_PRIMARY)
336 }
337
338 pub(super) fn system_body_style() -> Style {
339 Style::default().fg(palette::TEXT_MUTED).italic()
340 }
341
341 lines RUST