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
index 85e52ba..baf9081 100644
--- a/src/chat.rs
+++ b/src/chat.rs
@@ -37,6 +37,31 @@ pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) {
(thinking, remainder.trim().to_string())
}
+pub(crate) fn parse_rich_text_segments(text: &str) -> Vec<(String, bool)> {
+ let mut segments = Vec::new();
+ let mut current = String::new();
+ let mut chars = text.chars().peekable();
+ let mut bold = false;
+
+ while let Some(ch) = chars.next() {
+ if ch == '*' && chars.peek() == Some(&'*') {
+ chars.next();
+ if !current.is_empty() {
+ segments.push((std::mem::take(&mut current), bold));
+ }
+ bold = !bold;
+ } else {
+ current.push(ch);
+ }
+ }
+
+ if !current.is_empty() {
+ segments.push((current, bold));
+ }
+
+ segments
+}
+
// ── Unix-only TUI ─────────────────────────────────────────────────────────────
//
// macOS + Linux only. Windows uses ACP mode instead.
@@ -192,6 +217,21 @@ mod tui {
const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
+ fn rich_text_spans(text: &str, base_style: Style, bold_style: Style) -> Vec<Span<'static>> {
+ let mut spans = Vec::new();
+
+ for (segment, is_bold) in super::parse_rich_text_segments(text) {
+ let style = if is_bold { bold_style } else { base_style };
+ spans.push(Span::styled(segment, style));
+ }
+
+ if spans.is_empty() {
+ spans.push(Span::styled(String::new(), base_style));
+ }
+
+ spans
+ }
+
impl App {
fn new(load_model_name: String) -> Self {
let items = build_model_picker_items();
@@ -923,16 +963,19 @@ mod tui {
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
+ let body_style = Style::default();
+ let bold_style = Style::default().add_modifier(Modifier::BOLD);
let mut first = true;
for text_line in msg.text.split('\n') {
if first {
- lines.push(Line::from(vec![
- prefix.clone(),
- Span::raw(text_line.to_string()),
- ]));
+ let mut spans = vec![prefix.clone()];
+ spans.extend(rich_text_spans(text_line, body_style, bold_style));
+ lines.push(Line::from(spans));
first = false;
} else {
- lines.push(Line::from(Span::raw(format!(" {text_line}"))));
+ let mut spans = vec![Span::raw(" ".to_string())];
+ spans.extend(rich_text_spans(text_line, body_style, bold_style));
+ lines.push(Line::from(spans));
}
}
}
@@ -1618,7 +1661,7 @@ pub use tui::run_with;
#[cfg(test)]
mod tests {
- use super::strip_think_blocks;
+ use super::{parse_rich_text_segments, strip_think_blocks};
#[test]
fn strip_think_blocks_separates_thinking_and_visible_reply() {
@@ -1646,4 +1689,35 @@ mod tests {
assert_eq!(thinking, "");
assert_eq!(visible, "No hidden reasoning here.");
}
+
+ #[test]
+ fn parse_rich_text_segments_marks_bold_runs() {
+ let segments = parse_rich_text_segments(
+ "The current weather is **72°F** with **Partly Cloudy** conditions.",
+ );
+
+ assert_eq!(
+ segments,
+ vec![
+ ("The current weather is ".to_string(), false),
+ ("72°F".to_string(), true),
+ (" with ".to_string(), false),
+ ("Partly Cloudy".to_string(), true),
+ (" conditions.".to_string(), false),
+ ]
+ );
+ }
+
+ #[test]
+ fn parse_rich_text_segments_treats_unclosed_marker_as_bold_to_end() {
+ let segments = parse_rich_text_segments("Prefix **bold");
+
+ assert_eq!(
+ segments,
+ vec![
+ ("Prefix ".to_string(), false),
+ ("bold".to_string(), true),
+ ]
+ );
+ }
}