返回 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::tui::markdown_render;
8 use crate::tui::ui_text::CopyLineSeparator;
9 use codewhale_palette as palette;
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 /// A user turn is marked by its `▎` rail and the adaptive `USER_BODY` ink.
276 /// Only the newest one also carries the elevated-surface background, so the
277 /// eye lands on the turn in play; every older prompt renders on the bare
278 /// ground. (A fill behind *every* user row read as striping on every theme,
279 /// which is why the block is newest-only rather than per-role.)
280 pub(super) fn render_user_message(
281 content: &str,
282 width: u16,
283 highlight: bool,
284 ) -> Vec<Line<'static>> {
285 let lines = render_plain_message(
286 USER_GLYPH,
287 user_label_style(),
288 user_body_style(),
289 content,
290 width,
291 );
292 if !highlight {
293 return lines;
294 }
295 let bg = palette::SURFACE_ELEVATED;
296 lines
297 .into_iter()
298 .map(|mut line| {
299 line.style = line.style.bg(bg);
300 let target_width = usize::from(width);
301 let line_width = line.width();
302 if line_width < target_width {
303 line.spans.push(Span::styled(
304 " ".repeat(target_width - line_width),
305 Style::default().bg(bg),
306 ));
307 }
308 line
309 })
310 .collect()
311 }
312
313 pub(super) fn user_label_style() -> Style {
314 Style::default().fg(palette::USER_BODY)
315 }
316
317 pub(super) fn user_body_style() -> Style {
318 Style::default().fg(palette::USER_BODY)
319 }
320
321 /// Style for the assistant glyph (`●`). When the cell is streaming and
322 /// motion is allowed, the foreground pulses on a 2s cycle between 30% and
323 /// 100% brightness — the only deliberately animated element in a calm
324 /// transcript. When idle (or low_motion is on) it sits at the full DeepSeek
325 /// sky color so finished turns read as solid rather than dim.
326 pub(super) fn assistant_label_style_for(streaming: bool, low_motion: bool) -> Style {
327 let color = if streaming && !low_motion {
328 let now_ms = std::time::SystemTime::now()
329 .duration_since(std::time::UNIX_EPOCH)
330 .map(|d| d.as_millis() as u64)
331 .unwrap_or(0);
332 palette::pulse_brightness(palette::WHALE_ACTION, now_ms)
333 } else {
334 palette::WHALE_ACTION
335 };
336 Style::default().fg(color)
337 }
338
339 pub(super) fn system_label_style() -> Style {
340 Style::default().fg(palette::TEXT_DIM)
341 }
342
343 pub(super) fn message_body_style() -> Style {
344 Style::default().fg(palette::TEXT_PRIMARY)
345 }
346
347 pub(super) fn system_body_style() -> Style {
348 Style::default().fg(palette::TEXT_MUTED).italic()
349 }
350
350 lines RUST