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`:


65402cbda7/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:


65402cbda7/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.
This commit is contained in:
Michael Bolin
2025-05-07 10:46:32 -07:00
committed by GitHub
parent a080d7b0fd
commit 0360b4d0d7
5 changed files with 313 additions and 2 deletions

View File

@@ -14,6 +14,7 @@ use std::time::Duration;
use std::time::Instant;
use crate::exec_command::escape_command;
use crate::markdown::append_markdown;
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
@@ -96,7 +97,7 @@ impl HistoryCell {
pub(crate) fn new_agent_message(message: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("codex".magenta().bold()));
lines.extend(message.lines().map(|l| Line::from(l.to_string())));
append_markdown(&message, &mut lines);
lines.push(Line::from(""));
HistoryCell::AgentMessage { lines }

View File

@@ -25,6 +25,7 @@ mod exec_command;
mod git_warning_screen;
mod history_cell;
mod log_layer;
mod markdown;
mod scroll_event_helper;
mod status_indicator_widget;
mod tui;

View File

@@ -0,0 +1,30 @@
use ratatui::text::Line;
use ratatui::text::Span;
pub(crate) fn append_markdown(markdown_source: &str, lines: &mut Vec<Line<'static>>) {
let markdown = tui_markdown::from_str(markdown_source);
// `tui_markdown` returns a `ratatui::text::Text` where every `Line` borrows
// from the input `message` string. Since the `HistoryCell` stores its lines
// with a `'static` lifetime we must create an **owned** copy of each line
// so that it is no longer tied to `message`. We do this by cloning the
// content of every `Span` into an owned `String`.
for borrowed_line in markdown.lines {
let mut owned_spans = Vec::with_capacity(borrowed_line.spans.len());
for span in &borrowed_line.spans {
// Create a new owned String for the span's content to break the lifetime link.
let owned_span = Span::styled(span.content.to_string(), span.style);
owned_spans.push(owned_span);
}
let owned_line: Line<'static> = Line::from(owned_spans).style(borrowed_line.style);
// Preserve alignment if it was set on the source line.
let owned_line = match borrowed_line.alignment {
Some(alignment) => owned_line.alignment(alignment),
None => owned_line,
};
lines.push(owned_line);
}
}