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
}