Render assistant messages with ratatui-markdown.

142c3f705dea · AtlantisPleb · · parent 03bbdf9353b8

Render assistant messages with ratatui-markdown.

- Replace the plain word-wrap renderer for assistant output with
  ratatui-markdown, so **bold**, *italic*, lists, code fences, etc. are
  formatted instead of printed as raw markdown.
- Add a CoderTheme that pins all foreground/background to the existing
  TEXT_COLOR/BACKGROUND_COLOR while preserving modifiers.
- Add a test that verifies markdown asterisks are consumed and the words
  are rendered.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified Cargo.lock
  • modified crates/coder-lite/Cargo.toml
  • modified crates/coder-lite/src/tui.rs
  • added crates/coder-lite/tests/markdown.rs

Diff

4 files changed, +189 -31

Cargo.lock modified +11

@@ -383,6 +383,7 @@ dependencies = [

383 383
 "futures",
384 384
 "openresponses-rust",
385 385
 "ratatui",
386
 "ratatui-markdown",
386 387
 "tokio",
387 388
]
388 389

@@ -2013,6 +2014,16 @@ dependencies = [

2013 2014
 "unicode-width 0.2.0",
2014 2015
]
2015 2016
2017
[[package]]
2018
name = "ratatui-markdown"
2019
version = "0.3.6"
2020
source = "registry+https://github.com/rust-lang/crates.io-index"
2021
checksum = "e44e5c1fcb6b71a3e639b5218ea181ef42b8b05ae87ba4afdc962b48548079ab"
2022
dependencies = [
2023
 "ratatui",
2024
 "unicode-width 0.2.0",
2025
]
2026
2016 2027
[[package]]
2017 2028
name = "redox_syscall"
2018 2029
version = "0.5.18"
crates/coder-lite/Cargo.toml modified +1

@@ -15,3 +15,4 @@ crossterm = { version = "0.28", features = ["event-stream"] }

15 15
ratatui = { version = "0.29", default-features = false, features = ["crossterm"] }
16 16
openresponses-rust = "2026.7.26"
17 17
futures = "0.3"
18
ratatui-markdown = { version = "0.3.6", default-features = false, features = ["markdown"] }
crates/coder-lite/src/tui.rs modified +131 -31

@@ -7,11 +7,87 @@ use ratatui::{

7 7
    widgets::{Block, Borders, Paragraph},
8 8
    Frame,
9 9
};
10
use ratatui_markdown::markdown::MarkdownRenderer;
11
use ratatui_markdown::theme::{CodeColors, Generation, RichTextTheme};
10 12
11 13
const TEXT_COLOR: Color = Color::Rgb(255, 176, 0);
12 14
const BACKGROUND_COLOR: Color = Color::Rgb(8, 6, 0);
13 15
const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
14 16
17
#[derive(Debug, Clone, Copy)]
18
struct CoderTheme;
19
20
impl RichTextTheme for CoderTheme {
21
    fn generation(&self) -> Generation {
22
        Generation(1)
23
    }
24
    fn get_text_color(&self) -> Color {
25
        TEXT_COLOR
26
    }
27
    fn get_muted_text_color(&self) -> Color {
28
        TEXT_COLOR
29
    }
30
    fn get_primary_color(&self) -> Color {
31
        TEXT_COLOR
32
    }
33
    fn get_popup_selected_background(&self) -> Color {
34
        BACKGROUND_COLOR
35
    }
36
    fn get_border_color(&self) -> Color {
37
        TEXT_COLOR
38
    }
39
    fn get_focused_border_color(&self) -> Color {
40
        TEXT_COLOR
41
    }
42
    fn get_secondary_color(&self) -> Color {
43
        TEXT_COLOR
44
    }
45
    fn get_info_color(&self) -> Color {
46
        TEXT_COLOR
47
    }
48
    fn get_json_key_color(&self) -> Color {
49
        TEXT_COLOR
50
    }
51
    fn get_json_string_color(&self) -> Color {
52
        TEXT_COLOR
53
    }
54
    fn get_json_number_color(&self) -> Color {
55
        TEXT_COLOR
56
    }
57
    fn get_json_bool_color(&self) -> Color {
58
        TEXT_COLOR
59
    }
60
    fn get_json_null_color(&self) -> Color {
61
        TEXT_COLOR
62
    }
63
    fn get_accent_yellow(&self) -> Color {
64
        TEXT_COLOR
65
    }
66
    fn get_background_color(&self) -> Color {
67
        BACKGROUND_COLOR
68
    }
69
    fn get_code_colors(&self) -> CodeColors {
70
        CodeColors {
71
            comment: TEXT_COLOR,
72
            keyword: TEXT_COLOR,
73
            string: TEXT_COLOR,
74
            string_escape: TEXT_COLOR,
75
            number: TEXT_COLOR,
76
            constant: TEXT_COLOR,
77
            function: TEXT_COLOR,
78
            r#type: TEXT_COLOR,
79
            variable: TEXT_COLOR,
80
            property: TEXT_COLOR,
81
            operator: TEXT_COLOR,
82
            punctuation: TEXT_COLOR,
83
            attribute: TEXT_COLOR,
84
            tag: TEXT_COLOR,
85
            label: TEXT_COLOR,
86
            error: TEXT_COLOR,
87
        }
88
    }
89
}
90
15 91
#[derive(Debug, Clone)]
16 92
pub enum Role {
17 93
    You,

@@ -45,8 +121,8 @@ pub struct CoderUi {

45 121
}
46 122
47 123
fn wrap_text(text: &str, width: usize) -> Vec<String> {
48
    if width == 0 {
49
        return vec![text.to_string()];
124
    if width == 0 || text.is_empty() {
125
        return Vec::new();
50 126
    }
51 127
    let mut lines = Vec::new();
52 128
    let mut current = String::new();

@@ -177,10 +253,7 @@ impl CoderUi {

177 253
178 254
        if self.loading {
179 255
            let spinner = SPINNER_FRAMES[self.tick as usize % SPINNER_FRAMES.len()];
180
            all_lines.push(Line::from(vec![Span::styled(
181
                format!("> {}", spinner),
182
                style,
183
            )]));
256
            all_lines.push(Line::from(vec![Span::styled(spinner.to_string(), style)]));
184 257
        }
185 258
186 259
        let total = all_lines.len() as u16;

@@ -221,33 +294,60 @@ impl CoderUi {

221 294
    }
222 295
223 296
    fn render_entry(&self, entry: &Entry, width: usize) -> Vec<Line<'static>> {
224
        let text_style = Style::default().fg(TEXT_COLOR).bg(BACKGROUND_COLOR);
225
226
        let (first_prefix, marker, marker_space, rest_indent, first_body) = match entry.role {
227
            Role::You => ("", ">", " ", "  ", width.saturating_sub(2)),
228
            Role::Assistant => ("", "", "", "", width),
229
            _ => ("  ", "⏺", " ", "     ", width.saturating_sub(4)),
230
        };
231
232
        let chunks = wrap_text(&entry.text, first_body);
233
234
        let mut lines = Vec::new();
235
        for (i, chunk) in chunks.iter().enumerate() {
236
            if i == 0 {
237
                lines.push(Line::from(vec![
238
                    Span::styled(first_prefix, text_style),
239
                    Span::styled(marker, text_style),
240
                    Span::styled(marker_space, text_style),
241
                    Span::styled(chunk.clone(), text_style),
242
                ]));
243
            } else {
244
                lines.push(Line::from(vec![
245
                    Span::styled(rest_indent, text_style),
246
                    Span::styled(chunk.clone(), text_style),
247
                ]));
297
        match entry.role {
298
            Role::Assistant if !entry.text.is_empty() => {
299
                let renderer = MarkdownRenderer::new(width.max(1));
300
                let blocks = renderer.parse(&entry.text);
301
                let mut lines = renderer.render(&blocks, &CoderTheme);
302
303
                for line in &mut lines {
304
                    let mapped = line
305
                        .spans
306
                        .drain(..)
307
                        .map(|span| {
308
                            let style = span.style.fg(TEXT_COLOR).bg(BACKGROUND_COLOR);
309
                            Span::styled(span.content.to_string(), style)
310
                        })
311
                        .collect::<Vec<_>>();
312
                    *line = Line::from(mapped);
313
                }
314
315
                if !self.loading {
316
                    lines.push(Line::default());
317
                }
318
319
                lines
320
            }
321
            _ => {
322
                let text_style = Style::default().fg(TEXT_COLOR).bg(BACKGROUND_COLOR);
323
324
                let (first_prefix, marker, marker_space, rest_indent, first_body) = match entry.role {
325
                    Role::You => ("", ">", " ", "  ", width.saturating_sub(2)),
326
                    Role::Assistant => ("", "", "", "", width),
327
                    _ => ("  ", "⏺", " ", "     ", width.saturating_sub(4)),
328
                };
329
330
                let chunks = wrap_text(&entry.text, first_body);
331
332
                let mut lines = Vec::new();
333
                for (i, chunk) in chunks.iter().enumerate() {
334
                    if i == 0 {
335
                        lines.push(Line::from(vec![
336
                            Span::styled(first_prefix, text_style),
337
                            Span::styled(marker, text_style),
338
                            Span::styled(marker_space, text_style),
339
                            Span::styled(chunk.clone(), text_style),
340
                        ]));
341
                    } else {
342
                        lines.push(Line::from(vec![
343
                            Span::styled(rest_indent, text_style),
344
                            Span::styled(chunk.clone(), text_style),
345
                        ]));
346
                    }
347
                }
348
                lines
248 349
            }
249 350
        }
250
        lines
251 351
    }
252 352
253 353
    /// Calculate the scroll offset that keeps the viewport at the bottom
crates/coder-lite/tests/markdown.rs added +46

@@ -0,0 +1,46 @@

1
use coder_lite::tui::{CoderUi, Entry, Role};
2
use ratatui::Terminal;
3
use ratatui::backend::TestBackend;
4
5
#[test]
6
fn renders_markdown_bold_and_italic() {
7
    let mut ui = CoderUi::new();
8
    ui.entries.push(Entry {
9
        role: Role::Assistant,
10
        text: "**bold** and *italic*".to_string(),
11
    });
12
13
    let backend = TestBackend::new(80, 24);
14
    let mut terminal = Terminal::new(backend).unwrap();
15
16
    terminal
17
        .draw(|f| {
18
            let area = f.area();
19
            ui.render(f, area);
20
        })
21
        .unwrap();
22
23
    let text = terminal
24
        .backend()
25
        .buffer()
26
        .content
27
        .iter()
28
        .map(|c| c.symbol())
29
        .collect::<String>();
30
31
    assert!(
32
        text.contains("bold"),
33
        "expected rendered text to contain 'bold'\n{}",
34
        text
35
    );
36
    assert!(
37
        text.contains("italic"),
38
        "expected rendered text to contain 'italic'\n{}",
39
        text
40
    );
41
    assert!(
42
        !text.contains("**"),
43
        "expected markdown asterisks to be consumed\n{}",
44
        text
45
    );
46
}

This page updates live while a promote is in flight · changelog