@hej / sigit / commits / 44d58d2

Render bold text as rich format

Seto Elkahfi committed May 29, 2026 at 11:16 UTC 44d58d2f7104edf0c2d7a42114b8c15e76cd5fcb
1 file changed +80 -6
src/chat.rs
+80 -6
@@ -37,6 +37,31 @@ pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) {
37 (thinking, remainder.trim().to_string())
38 }
39
40 +pub(crate) fn parse_rich_text_segments(text: &str) -> Vec<(String, bool)> {
41 + let mut segments = Vec::new();
42 + let mut current = String::new();
43 + let mut chars = text.chars().peekable();
44 + let mut bold = false;
45 +
46 + while let Some(ch) = chars.next() {
47 + if ch == '*' && chars.peek() == Some(&'*') {
48 + chars.next();
49 + if !current.is_empty() {
50 + segments.push((std::mem::take(&mut current), bold));
51 + }
52 + bold = !bold;
53 + } else {
54 + current.push(ch);
55 + }
56 + }
57 +
58 + if !current.is_empty() {
59 + segments.push((current, bold));
60 + }
61 +
62 + segments
63 +}
64 +
65 // ── Unix-only TUI ─────────────────────────────────────────────────────────────
66 //
67 // macOS + Linux only. Windows uses ACP mode instead.
@@ -192,6 +217,21 @@ mod tui {
217
218 const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
219
220 + fn rich_text_spans(text: &str, base_style: Style, bold_style: Style) -> Vec<Span<'static>> {
221 + let mut spans = Vec::new();
222 +
223 + for (segment, is_bold) in super::parse_rich_text_segments(text) {
224 + let style = if is_bold { bold_style } else { base_style };
225 + spans.push(Span::styled(segment, style));
226 + }
227 +
228 + if spans.is_empty() {
229 + spans.push(Span::styled(String::new(), base_style));
230 + }
231 +
232 + spans
233 + }
234 +
235 impl App {
236 fn new(load_model_name: String) -> Self {
237 let items = build_model_picker_items();
@@ -923,16 +963,19 @@ mod tui {
963 .fg(Color::Cyan)
964 .add_modifier(Modifier::BOLD),
965 );
966 + let body_style = Style::default();
967 + let bold_style = Style::default().add_modifier(Modifier::BOLD);
968 let mut first = true;
969 for text_line in msg.text.split('\n') {
970 if first {
929 - lines.push(Line::from(vec![
930 - prefix.clone(),
931 - Span::raw(text_line.to_string()),
932 - ]));
971 + let mut spans = vec![prefix.clone()];
972 + spans.extend(rich_text_spans(text_line, body_style, bold_style));
973 + lines.push(Line::from(spans));
974 first = false;
975 } else {
935 - lines.push(Line::from(Span::raw(format!(" {text_line}"))));
976 + let mut spans = vec![Span::raw(" ".to_string())];
977 + spans.extend(rich_text_spans(text_line, body_style, bold_style));
978 + lines.push(Line::from(spans));
979 }
980 }
981 }
@@ -1618,7 +1661,7 @@ pub use tui::run_with;
1661
1662 #[cfg(test)]
1663 mod tests {
1621 - use super::strip_think_blocks;
1664 + use super::{parse_rich_text_segments, strip_think_blocks};
1665
1666 #[test]
1667 fn strip_think_blocks_separates_thinking_and_visible_reply() {
@@ -1646,4 +1689,35 @@ mod tests {
1689 assert_eq!(thinking, "");
1690 assert_eq!(visible, "No hidden reasoning here.");
1691 }
1692 +
1693 + #[test]
1694 + fn parse_rich_text_segments_marks_bold_runs() {
1695 + let segments = parse_rich_text_segments(
1696 + "The current weather is **72°F** with **Partly Cloudy** conditions.",
1697 + );
1698 +
1699 + assert_eq!(
1700 + segments,
1701 + vec![
1702 + ("The current weather is ".to_string(), false),
1703 + ("72°F".to_string(), true),
1704 + (" with ".to_string(), false),
1705 + ("Partly Cloudy".to_string(), true),
1706 + (" conditions.".to_string(), false),
1707 + ]
1708 + );
1709 + }
1710 +
1711 + #[test]
1712 + fn parse_rich_text_segments_treats_unclosed_marker_as_bold_to_end() {
1713 + let segments = parse_rich_text_segments("Prefix **bold");
1714 +
1715 + assert_eq!(
1716 + segments,
1717 + vec![
1718 + ("Prefix ".to_string(), false),
1719 + ("bold".to_string(), true),
1720 + ]
1721 + );
1722 + }
1723 }