Files
llmx/codex-rs/tui/src/markdown.rs

106 lines
3.4 KiB
Rust
Raw Normal View History

feat: introduce the use of tui-markdown (#851) This introduces the use of the `tui-markdown` crate to parse an assistant message as Markdown and style it using ANSI for a better user experience. As shown in the screenshot below, it has support for syntax highlighting for _tagged_ fenced code blocks: <img width="907" alt="image" src="https://github.com/user-attachments/assets/900dc229-80bb-46e8-b1bb-efee4c70ba3c" /> That said, `tui-markdown` is not as configurable (or stylish!) as https://www.npmjs.com/package/marked-terminal, which is what we use in the TypeScript CLI. In particular: * The styles are hardcoded and `tui_markdown::from_str()` does not take any options whatsoever. It uses "bold white" for inline code style which does not stand out as much as the yellow used by `marked-terminal`: https://github.com/joshka/tui-markdown/blob/65402cbda70325f34e7ddf6fe1ec629bcd9459cf/tui-markdown/src/lib.rs#L464 I asked Codex to take a first pass at this and it came up with: https://github.com/joshka/tui-markdown/pull/80 * If a fenced code block is not tagged, then it does not get highlighted. I would rather add some logic here: https://github.com/joshka/tui-markdown/blob/65402cbda70325f34e7ddf6fe1ec629bcd9459cf/tui-markdown/src/lib.rs#L262 that uses something like https://pypi.org/project/guesslang/ to examine the value of `text` and try to use the appropriate syntax highlighter. * When we have a fenced code block, we do not want to show the opening and closing triple backticks in the output. To unblock ourselves, we might want to bundle our own fork of `tui-markdown` temporarily until we figure out what the shape of the API should be and then try to upstream it.
2025-05-07 10:46:32 -07:00
use ratatui::text::Line;
pub(crate) fn append_markdown(
markdown_source: &str,
width: Option<usize>,
lines: &mut Vec<Line<'static>>,
) {
let rendered = crate::markdown_render::render_markdown_text_with_width(markdown_source, width);
crate::render::line_utils::push_owned_lines(&rendered.lines, lines);
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::text::Line;
fn lines_to_strings(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect()
}
#[test]
fn citations_render_as_plain_text() {
let src = "Before 【F:/x.rs†L1】\nAfter 【F:/x.rs†L3】\n";
let mut out = Vec::new();
append_markdown(src, None, &mut out);
let rendered = lines_to_strings(&out);
assert_eq!(
rendered,
vec![
"Before 【F:/x.rs†L1】".to_string(),
"After 【F:/x.rs†L3】".to_string()
]
);
}
#[test]
fn indented_code_blocks_preserve_leading_whitespace() {
// Basic sanity: indented code with surrounding blank lines should produce the indented line.
let src = "Before\n\n code 1\n\nAfter\n";
let mut out = Vec::new();
append_markdown(src, None, &mut out);
let lines = lines_to_strings(&out);
assert_eq!(lines, vec!["Before", "", " code 1", "", "After"]);
}
#[test]
fn append_markdown_preserves_full_text_line() {
let src = "Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?\n";
let mut out = Vec::new();
append_markdown(src, None, &mut out);
assert_eq!(
out.len(),
1,
"expected a single rendered line for plain text"
);
let rendered: String = out
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("");
assert_eq!(
rendered,
"Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?"
);
}
#[test]
fn append_markdown_matches_tui_markdown_for_ordered_item() {
let mut out = Vec::new();
append_markdown("1. Tight item\n", None, &mut out);
let lines = lines_to_strings(&out);
assert_eq!(lines, vec!["1. Tight item".to_string()]);
}
#[test]
fn append_markdown_keeps_ordered_list_line_unsplit_in_context() {
let src = "Loose vs. tight list items:\n1. Tight item\n";
let mut out = Vec::new();
append_markdown(src, None, &mut out);
let lines = lines_to_strings(&out);
// Expect to find the ordered list line rendered as a single line,
// not split into a marker-only line followed by the text.
assert!(
lines.iter().any(|s| s == "1. Tight item"),
"expected '1. Tight item' rendered as a single line; got: {lines:?}"
);
assert!(
!lines
.windows(2)
.any(|w| w[0].trim_end() == "1." && w[1] == "Tight item"),
"did not expect a split into ['1.', 'Tight item']; got: {lines:?}"
);
}
}