use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::hash::Hasher;
use std::sync::Arc;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use rustc_hash::FxHasher;
use unicode_width::UnicodeWidthChar;
use crate::ui::style;
use crate::ui::text_util::wrap_styled_line;
const USER_PROMPT_PREFIX: &str = " › ";
const CLARIFICATION_HEADER: &str = "Clarifications:";
const CLARIFICATION_PROMPT_PREFIX: &str = USER_PROMPT_PREFIX;
const STATS_LABEL_WIDTH: usize = 22;
const MARKDOWN_RENDER_CACHE_ENTRY_LIMIT: usize = 64;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct MarkdownRenderCacheKey {
content_hash: u64,
content_len: usize,
version: u64,
width: u16,
}
struct MarkdownRenderCacheEntry {
key: MarkdownRenderCacheKey,
lines: Arc<[Line<'static>]>,
}
pub struct MarkdownRenderCache {
entries: RefCell<VecDeque<MarkdownRenderCacheEntry>>,
version: Cell<u64>,
}
impl Default for MarkdownRenderCache {
fn default() -> Self {
Self {
entries: RefCell::new(VecDeque::with_capacity(MARKDOWN_RENDER_CACHE_ENTRY_LIMIT)),
version: Cell::new(0),
}
}
}
impl MarkdownRenderCache {
pub fn render(&self, text: &str, width: usize) -> Arc<[Line<'static>]> {
let key = self.cache_key(text, width);
if let Some(lines) = self.cached_lines(key) {
return lines;
}
let lines = Arc::<[Line<'static>]>::from(render_markdown(text, width));
self.store_entry(MarkdownRenderCacheEntry {
key,
lines: Arc::clone(&lines),
});
lines
}
pub fn bump_version(&self) {
self.version.set(self.version.get().wrapping_add(1));
self.entries.borrow_mut().clear();
}
pub(crate) fn version(&self) -> u64 {
self.version.get()
}
fn cache_key(&self, text: &str, width: usize) -> MarkdownRenderCacheKey {
MarkdownRenderCacheKey {
content_hash: Self::hash_text(text),
content_len: text.len(),
version: self
.version
.get()
.wrapping_add(style::active_theme_cache_version()),
width: u16::try_from(width).unwrap_or(u16::MAX),
}
}
fn cached_lines(&self, key: MarkdownRenderCacheKey) -> Option<Arc<[Line<'static>]>> {
let mut entries = self.entries.borrow_mut();
let entry_index = entries.iter().position(|entry| entry.key == key)?;
let entry = entries.remove(entry_index)?;
let lines = Arc::clone(&entry.lines);
entries.push_front(entry);
Some(lines)
}
fn store_entry(&self, entry: MarkdownRenderCacheEntry) {
let mut entries = self.entries.borrow_mut();
entries.push_front(entry);
while entries.len() > MARKDOWN_RENDER_CACHE_ENTRY_LIMIT {
entries.pop_back();
}
}
fn hash_text(text: &str) -> u64 {
let mut hasher = FxHasher::default();
hasher.write(text.as_bytes());
hasher.finish()
}
}
#[derive(Clone, Copy)]
enum BlockState {
Paragraph,
FencedCode,
FencedStats,
}
#[derive(Clone, Copy)]
enum PromptBlockKind {
Clarification,
UserPrompt,
}
pub fn render_markdown(text: &str, width: usize) -> Vec<Line<'static>> {
let mut rendered_lines = Vec::new();
let mut block_state = BlockState::Paragraph;
let mut is_user_prompt_block = false;
let mut active_prompt_block_kind = PromptBlockKind::UserPrompt;
for raw_line in text.split('\n') {
let starts_user_prompt_block = raw_line.starts_with(USER_PROMPT_PREFIX);
if let Some(prompt_line) = user_prompt_block_line(raw_line, &mut is_user_prompt_block) {
if starts_user_prompt_block {
active_prompt_block_kind = prompt_block_kind(raw_line);
block_state = BlockState::Paragraph;
rendered_lines.push(prompt_block_padding_line(width, active_prompt_block_kind));
}
let closes_user_prompt_block = prompt_line.is_empty() && !is_user_prompt_block;
rendered_lines.extend(render_prompt_block_line(
prompt_line,
starts_user_prompt_block,
width,
active_prompt_block_kind,
));
if closes_user_prompt_block {
rendered_lines.push(Line::from(""));
}
continue;
}
if is_fence_delimiter(raw_line) {
block_state = match block_state {
BlockState::Paragraph => opening_fence_block_state(raw_line),
BlockState::FencedCode | BlockState::FencedStats => BlockState::Paragraph,
};
continue;
}
match block_state {
BlockState::Paragraph => rendered_lines.extend(render_markdown_line(raw_line, width)),
BlockState::FencedCode => rendered_lines.extend(render_code_line(raw_line, width)),
BlockState::FencedStats => rendered_lines.extend(render_stats_line(raw_line, width)),
}
}
if is_user_prompt_block {
rendered_lines.push(prompt_block_padding_line(width, active_prompt_block_kind));
}
if rendered_lines.is_empty() {
rendered_lines.push(Line::from(""));
}
rendered_lines
}
fn prompt_block_kind(raw_line: &str) -> PromptBlockKind {
let is_clarification_header = raw_line
.strip_prefix(USER_PROMPT_PREFIX)
.is_some_and(|content| content.trim() == CLARIFICATION_HEADER);
if is_clarification_header {
return PromptBlockKind::Clarification;
}
PromptBlockKind::UserPrompt
}
fn user_prompt_block_line<'a>(
raw_line: &'a str,
is_user_prompt_block: &mut bool,
) -> Option<&'a str> {
if *is_user_prompt_block && raw_line.is_empty() {
*is_user_prompt_block = false;
return Some(raw_line);
}
if raw_line.starts_with(USER_PROMPT_PREFIX) {
*is_user_prompt_block = true;
return Some(raw_line);
}
if *is_user_prompt_block {
return Some(raw_line);
}
None
}
fn render_prompt_block_line(
raw_line: &str,
starts_user_prompt_block: bool,
width: usize,
prompt_block_kind: PromptBlockKind,
) -> Vec<Line<'static>> {
match prompt_block_kind {
PromptBlockKind::Clarification => {
render_clarification_prompt_line(raw_line, starts_user_prompt_block, width)
}
PromptBlockKind::UserPrompt => {
render_user_prompt_line(raw_line, starts_user_prompt_block, width)
}
}
}
fn render_user_prompt_line(
raw_line: &str,
starts_user_prompt_block: bool,
width: usize,
) -> Vec<Line<'static>> {
if raw_line.is_empty() {
return vec![prompt_block_padding_line(
width,
PromptBlockKind::UserPrompt,
)];
}
let continuation_padding = prompt_block_continuation_padding();
let content_style = user_prompt_content_style();
let prompt_lines = if starts_user_prompt_block
&& let Some(content) = raw_line.strip_prefix(USER_PROMPT_PREFIX)
{
render_prefixed_verbatim_line(
USER_PROMPT_PREFIX,
&continuation_padding,
content,
user_prompt_prefix_style(),
content_style,
width,
user_prompt_lookup_spans,
)
} else {
let continuation_content = raw_line
.strip_prefix(continuation_padding.as_str())
.unwrap_or(raw_line);
render_prefixed_verbatim_line(
&continuation_padding,
&continuation_padding,
continuation_content,
content_style,
content_style,
width,
user_prompt_lookup_spans,
)
};
prompt_lines
.into_iter()
.map(|line| pad_line_to_width(line, width, content_style))
.collect()
}
fn render_clarification_prompt_line(
raw_line: &str,
starts_user_prompt_block: bool,
width: usize,
) -> Vec<Line<'static>> {
if raw_line.is_empty() {
return vec![prompt_block_padding_line(
width,
PromptBlockKind::Clarification,
)];
}
let continuation_padding = prompt_block_continuation_padding();
let content_style = clarification_content_style();
let prompt_lines = if starts_user_prompt_block
&& let Some(content) = raw_line.strip_prefix(USER_PROMPT_PREFIX)
{
render_prefixed_verbatim_line(
CLARIFICATION_PROMPT_PREFIX,
&continuation_padding,
content,
clarification_prompt_prefix_style(),
content_style,
width,
clarification_prompt_spans,
)
} else {
let continuation_content = raw_line
.strip_prefix(continuation_padding.as_str())
.unwrap_or(raw_line);
render_prefixed_verbatim_line(
&continuation_padding,
&continuation_padding,
continuation_content,
content_style,
content_style,
width,
clarification_prompt_spans,
)
};
prompt_lines
.into_iter()
.map(|line| pad_line_to_width(line, width, content_style))
.collect()
}
fn prompt_block_padding_line(width: usize, prompt_block_kind: PromptBlockKind) -> Line<'static> {
pad_line_to_width(
Line::from(""),
width,
prompt_block_content_style(prompt_block_kind),
)
}
fn prompt_block_content_style(prompt_block_kind: PromptBlockKind) -> Style {
match prompt_block_kind {
PromptBlockKind::Clarification => clarification_content_style(),
PromptBlockKind::UserPrompt => user_prompt_content_style(),
}
}
fn pad_line_to_width(mut line: Line<'static>, width: usize, style: Style) -> Line<'static> {
if width == 0 {
return line;
}
let line_width = line.width();
if line_width >= width {
return line;
}
line.spans
.push(Span::styled(" ".repeat(width - line_width), style));
line
}
fn render_markdown_line(raw_line: &str, width: usize) -> Vec<Line<'static>> {
if raw_line.is_empty() {
return vec![Line::from("")];
}
if raw_line.starts_with(USER_PROMPT_PREFIX) {
return render_prompt_block_line(raw_line, true, width, PromptBlockKind::UserPrompt);
}
if let Some((level, content)) = parse_heading(raw_line) {
return render_inline_line(content, heading_style(level), width);
}
if is_horizontal_rule(raw_line) {
return vec![horizontal_rule_line(width)];
}
if let Some(content) = raw_line.strip_prefix("> ") {
return render_prefixed_inline_line(
"│ ",
"│ ",
content,
blockquote_prefix_style(),
Style::default().fg(style::palette::text_muted()),
width,
);
}
if let Some(content) = parse_bullet_content(raw_line) {
return render_prefixed_inline_line(
"- ",
" ",
content,
list_prefix_style(),
Style::default(),
width,
);
}
if let Some((prefix, content)) = parse_numbered_content(raw_line) {
let continuation_prefix = " ".repeat(prefix.chars().count());
return render_prefixed_inline_line(
&prefix,
&continuation_prefix,
content,
list_prefix_style(),
Style::default(),
width,
);
}
render_inline_line(raw_line, Style::default(), width)
}
fn render_prefixed_inline_line(
prefix: &str,
continuation_prefix: &str,
content: &str,
prefix_style: Style,
content_style: Style,
width: usize,
) -> Vec<Line<'static>> {
let prefix_width = prefix.chars().count();
if width <= prefix_width {
let mut spans = vec![Span::styled(prefix.to_string(), prefix_style)];
spans.extend(parse_inline_spans(content, content_style));
return wrap_styled_line(spans, width);
}
let wrapped_content = render_inline_line(content, content_style, width - prefix_width);
let mut lines = Vec::with_capacity(wrapped_content.len());
for (index, line) in wrapped_content.into_iter().enumerate() {
let marker = if index == 0 {
prefix
} else {
continuation_prefix
};
let mut spans = vec![Span::styled(marker.to_string(), prefix_style)];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
lines
}
fn render_prefixed_verbatim_line(
prefix: &str,
continuation_prefix: &str,
content: &str,
prefix_style: Style,
content_style: Style,
width: usize,
content_span_builder: fn(&str, Style) -> Vec<Span<'static>>,
) -> Vec<Line<'static>> {
let prefix_width = prefix.chars().count();
if width <= prefix_width {
let mut spans = vec![Span::styled(prefix.to_string(), prefix_style)];
spans.extend(content_span_builder(content, content_style));
return wrap_styled_line(spans, width);
}
let wrapped_content = wrap_verbatim_spans_with_word_boundaries(
content_span_builder(content, content_style),
width - prefix_width,
);
let mut lines = Vec::with_capacity(wrapped_content.len());
for (index, line) in wrapped_content.into_iter().enumerate() {
let marker = if index == 0 {
prefix
} else {
continuation_prefix
};
let marker_style = if index == 0 {
prefix_style
} else {
content_style
};
let mut spans = vec![Span::styled(marker.to_string(), marker_style)];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
lines
}
fn user_prompt_lookup_spans(content: &str, content_style: Style) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut is_lookup = false;
let mut previous_character = None;
for character in content.chars() {
if character == '@' && previous_character.is_none_or(char::is_whitespace) {
is_lookup = true;
} else if character.is_whitespace() {
is_lookup = false;
}
let style = if is_lookup {
user_prompt_lookup_style()
} else {
content_style
};
push_verbatim_span_character(&mut spans, style, character);
previous_character = Some(character);
}
spans
}
fn clarification_prompt_spans(content: &str, content_style: Style) -> Vec<Span<'static>> {
if content.trim().is_empty() {
return vec![Span::styled(content.to_string(), content_style)];
}
let leading_padding_width = content
.chars()
.take_while(|character| character.is_whitespace())
.count();
let (leading_padding, trimmed_content) = content.split_at(leading_padding_width);
let mut spans = Vec::new();
if !leading_padding.is_empty() {
spans.push(Span::styled(leading_padding.to_string(), content_style));
}
if trimmed_content == CLARIFICATION_HEADER {
spans.push(Span::styled(
trimmed_content.to_string(),
clarification_header_style(),
));
return spans;
}
if let Some((question_index, question_text)) =
parse_clarification_question_line(trimmed_content)
{
spans.push(Span::styled(
question_index,
clarification_question_index_style(),
));
spans.push(Span::styled(
"Q: ".to_string(),
clarification_question_label_style(),
));
spans.push(Span::styled(question_text.to_string(), content_style));
return spans;
}
if let Some(answer_text) = trimmed_content.strip_prefix("A: ") {
spans.push(Span::styled(
"A: ".to_string(),
clarification_answer_label_style(),
));
spans.push(Span::styled(answer_text.to_string(), content_style));
return spans;
}
spans.push(Span::styled(trimmed_content.to_string(), content_style));
spans
}
fn render_inline_line(content: &str, base_style: Style, width: usize) -> Vec<Line<'static>> {
let inline_spans = parse_inline_spans(content, base_style);
wrap_styled_line(inline_spans, width)
}
fn render_code_line(raw_line: &str, width: usize) -> Vec<Line<'static>> {
wrap_verbatim_line(raw_line, code_block_style(), width)
}
fn render_stats_line(raw_line: &str, width: usize) -> Vec<Line<'static>> {
if raw_line.is_empty() {
return vec![Line::from("")];
}
if let Some((metric, value)) = parse_stats_metric_line(raw_line) {
let metric_cell = format!("{metric:<STATS_LABEL_WIDTH$}");
let spans = vec![
Span::styled(metric_cell, stats_metric_style()),
Span::styled(value.to_string(), stats_value_style()),
];
return wrap_verbatim_spans(spans, width);
}
if raw_line == "Tokens Usage" {
return wrap_verbatim_line(raw_line, stats_section_style(), width);
}
wrap_verbatim_line(raw_line, Style::default(), width)
}
fn wrap_verbatim_line(content: &str, style: Style, width: usize) -> Vec<Line<'static>> {
if width == 0 {
return vec![Line::from(vec![Span::styled(content.to_string(), style)])];
}
if content.is_empty() {
return vec![Line::from("")];
}
let mut wrapped_lines = Vec::new();
let mut current_segment = String::new();
let mut current_width = 0;
for character in content.chars() {
let character_width = character_display_width(character);
if current_width > 0 && character_width > 0 && current_width + character_width > width {
wrapped_lines.push(Line::from(vec![Span::styled(
std::mem::take(&mut current_segment),
style,
)]));
current_width = 0;
}
current_segment.push(character);
current_width += character_width;
}
if !current_segment.is_empty() {
wrapped_lines.push(Line::from(vec![Span::styled(current_segment, style)]));
}
if wrapped_lines.is_empty() {
wrapped_lines.push(Line::from(""));
}
wrapped_lines
}
fn wrap_verbatim_spans(spans: Vec<Span<'static>>, width: usize) -> Vec<Line<'static>> {
if width == 0 {
return vec![Line::from(spans)];
}
let mut wrapped_lines = Vec::new();
let mut current_spans = Vec::new();
let mut current_width = 0;
for span in spans {
let style = span.style;
let content = span.content.into_owned();
for character in content.chars() {
let character_width = character_display_width(character);
if current_width > 0 && character_width > 0 && current_width + character_width > width {
wrapped_lines.push(Line::from(std::mem::take(&mut current_spans)));
current_width = 0;
}
push_verbatim_span_character(&mut current_spans, style, character);
current_width += character_width;
}
}
if !current_spans.is_empty() {
wrapped_lines.push(Line::from(current_spans));
}
if wrapped_lines.is_empty() {
wrapped_lines.push(Line::from(""));
}
wrapped_lines
}
fn wrap_verbatim_spans_with_word_boundaries(
spans: Vec<Span<'static>>,
width: usize,
) -> Vec<Line<'static>> {
if width == 0 {
return vec![Line::from(spans)];
}
let mut wrapped_lines = Vec::new();
let mut current_spans = Vec::new();
let mut current_width = 0;
let mut pending_word_spans = Vec::new();
let mut pending_word_width = 0;
let mut has_characters = false;
for span in spans {
let style = span.style;
let content = span.content.into_owned();
for character in content.chars() {
has_characters = true;
if character.is_whitespace() {
flush_pending_word_with_wrap(
&mut wrapped_lines,
&mut current_spans,
&mut current_width,
&mut pending_word_spans,
&mut pending_word_width,
width,
);
push_character_with_hard_wrap(
&mut wrapped_lines,
&mut current_spans,
&mut current_width,
style,
character,
width,
);
continue;
}
push_verbatim_span_character(&mut pending_word_spans, style, character);
pending_word_width += character_display_width(character);
}
}
flush_pending_word_with_wrap(
&mut wrapped_lines,
&mut current_spans,
&mut current_width,
&mut pending_word_spans,
&mut pending_word_width,
width,
);
if !has_characters {
return vec![Line::from("")];
}
if !current_spans.is_empty() {
wrapped_lines.push(Line::from(current_spans));
}
if wrapped_lines.is_empty() {
wrapped_lines.push(Line::from(""));
}
wrapped_lines
}
fn flush_pending_word_with_wrap(
wrapped_lines: &mut Vec<Line<'static>>,
current_spans: &mut Vec<Span<'static>>,
current_width: &mut usize,
pending_word_spans: &mut Vec<Span<'static>>,
pending_word_width: &mut usize,
width: usize,
) {
if pending_word_spans.is_empty() {
return;
}
if *current_width > 0 && *current_width + *pending_word_width >= width {
wrapped_lines.push(Line::from(std::mem::take(current_spans)));
*current_width = 0;
}
let word_spans = std::mem::take(pending_word_spans);
for span in word_spans {
let style = span.style;
let content = span.content.into_owned();
for character in content.chars() {
push_character_with_hard_wrap(
wrapped_lines,
current_spans,
current_width,
style,
character,
width,
);
}
}
*pending_word_width = 0;
}
fn push_character_with_hard_wrap(
wrapped_lines: &mut Vec<Line<'static>>,
current_spans: &mut Vec<Span<'static>>,
current_width: &mut usize,
style: Style,
character: char,
width: usize,
) {
let character_width = character_display_width(character);
if *current_width > 0 && character_width > 0 && *current_width + character_width > width {
wrapped_lines.push(Line::from(std::mem::take(current_spans)));
*current_width = 0;
}
push_verbatim_span_character(current_spans, style, character);
*current_width += character_width;
}
fn push_verbatim_span_character(spans: &mut Vec<Span<'static>>, style: Style, character: char) {
if let Some(last_span) = spans.last_mut()
&& last_span.style == style
{
last_span.content.to_mut().push(character);
return;
}
spans.push(Span::styled(character.to_string(), style));
}
fn character_display_width(character: char) -> usize {
UnicodeWidthChar::width(character).unwrap_or(0)
}
pub fn parse_inline_spans(content: &str, base_style: Style) -> Vec<Span<'static>> {
let characters: Vec<char> = content.chars().collect();
let mut spans = Vec::new();
let mut literal = String::new();
let mut index = 0;
while index < characters.len() {
if characters[index] == '`'
&& let Some(end_index) = find_matching_backtick(&characters, index + 1)
&& end_index > index + 1
{
flush_literal_span(&mut spans, &mut literal, base_style);
let inline_code: String = characters[index + 1..end_index].iter().collect();
spans.push(Span::styled(inline_code, inline_code_style()));
index = end_index + 1;
continue;
}
if characters[index] == '*'
&& index + 1 < characters.len()
&& characters[index + 1] == '*'
&& let Some(end_index) = find_matching_double_asterisk(&characters, index + 2)
&& end_index > index + 2
{
flush_literal_span(&mut spans, &mut literal, base_style);
let bold_content: String = characters[index + 2..end_index].iter().collect();
spans.push(Span::styled(
bold_content,
base_style.add_modifier(Modifier::BOLD),
));
index = end_index + 2;
continue;
}
if characters[index] == '*'
&& let Some(end_index) = find_matching_single_asterisk(&characters, index + 1)
&& end_index > index + 1
{
flush_literal_span(&mut spans, &mut literal, base_style);
let italic_content: String = characters[index + 1..end_index].iter().collect();
spans.push(Span::styled(
italic_content,
base_style.add_modifier(Modifier::ITALIC),
));
index = end_index + 1;
continue;
}
literal.push(characters[index]);
index += 1;
}
flush_literal_span(&mut spans, &mut literal, base_style);
spans
}
fn flush_literal_span(spans: &mut Vec<Span<'static>>, literal: &mut String, style: Style) {
if literal.is_empty() {
return;
}
spans.push(Span::styled(std::mem::take(literal), style));
}
fn parse_heading(raw_line: &str) -> Option<(usize, &str)> {
if let Some(content) = raw_line.strip_prefix("#### ") {
return Some((4, content));
}
if let Some(content) = raw_line.strip_prefix("### ") {
return Some((3, content));
}
if let Some(content) = raw_line.strip_prefix("## ") {
return Some((2, content));
}
raw_line.strip_prefix("# ").map(|content| (1, content))
}
fn parse_bullet_content(raw_line: &str) -> Option<&str> {
if let Some(content) = raw_line.strip_prefix("- ") {
return Some(content);
}
raw_line.strip_prefix("* ")
}
fn parse_numbered_content(raw_line: &str) -> Option<(String, &str)> {
let digit_count = raw_line.chars().take_while(char::is_ascii_digit).count();
if digit_count == 0 {
return None;
}
let (digits, suffix) = raw_line.split_at(digit_count);
let content = suffix.strip_prefix(". ")?;
Some((format!("{digits}. "), content))
}
fn parse_clarification_question_line(raw_line: &str) -> Option<(String, &str)> {
let digit_count = raw_line.chars().take_while(char::is_ascii_digit).count();
if digit_count == 0 {
return None;
}
let (digits, suffix) = raw_line.split_at(digit_count);
let content = suffix.strip_prefix(". Q: ")?;
Some((format!("{digits}. "), content))
}
fn opening_fence_block_state(raw_line: &str) -> BlockState {
if is_stats_fence(raw_line) {
return BlockState::FencedStats;
}
BlockState::FencedCode
}
fn is_fence_delimiter(raw_line: &str) -> bool {
raw_line.trim().starts_with("```")
}
fn is_stats_fence(raw_line: &str) -> bool {
raw_line.trim().starts_with("```stats")
}
fn parse_stats_metric_line(raw_line: &str) -> Option<(&str, &str)> {
let (metric, value) = raw_line.split_once('\t')?;
Some((metric, value))
}
fn is_horizontal_rule(raw_line: &str) -> bool {
let trimmed = raw_line.trim();
if trimmed.len() < 3 {
return false;
}
trimmed.chars().all(|character| character == '-')
|| trimmed.chars().all(|character| character == '*')
}
fn horizontal_rule_line(width: usize) -> Line<'static> {
if width == 0 {
return Line::from("");
}
Line::from(vec![Span::styled(
"-".repeat(width),
horizontal_rule_style(),
)])
}
fn heading_style(level: usize) -> Style {
let color = match level {
1 => style::palette::accent(),
2 => style::palette::info(),
3 => style::palette::success(),
_ => style::palette::warning(),
};
Style::default().fg(color).add_modifier(Modifier::BOLD)
}
fn list_prefix_style() -> Style {
Style::default().fg(style::palette::text_subtle())
}
fn blockquote_prefix_style() -> Style {
Style::default()
.fg(style::palette::text_subtle())
.add_modifier(Modifier::DIM)
}
fn horizontal_rule_style() -> Style {
Style::default()
.fg(style::palette::text_subtle())
.add_modifier(Modifier::DIM)
}
fn code_block_style() -> Style {
Style::default()
.fg(style::palette::text_muted())
.bg(style::palette::surface_overlay())
}
fn stats_metric_style() -> Style {
Style::default()
.fg(style::palette::accent())
.add_modifier(Modifier::BOLD)
}
fn stats_section_style() -> Style {
Style::default()
.fg(style::palette::success())
.add_modifier(Modifier::BOLD)
}
fn stats_value_style() -> Style {
inline_code_style()
}
fn clarification_background_color() -> Color {
style::palette::surface_clarification()
}
fn clarification_prompt_prefix_style() -> Style {
Style::default()
.fg(style::palette::warning())
.bg(clarification_background_color())
.add_modifier(Modifier::BOLD)
}
fn clarification_header_style() -> Style {
Style::default()
.fg(style::palette::warning_soft())
.bg(clarification_background_color())
.add_modifier(Modifier::BOLD)
}
fn clarification_question_index_style() -> Style {
Style::default()
.fg(style::palette::text())
.bg(clarification_background_color())
.add_modifier(Modifier::BOLD)
}
fn clarification_question_label_style() -> Style {
Style::default()
.fg(style::palette::accent())
.bg(clarification_background_color())
.add_modifier(Modifier::BOLD)
}
fn clarification_answer_label_style() -> Style {
Style::default()
.fg(style::palette::success())
.bg(clarification_background_color())
.add_modifier(Modifier::BOLD)
}
fn clarification_content_style() -> Style {
Style::default()
.fg(style::palette::text_muted())
.bg(clarification_background_color())
}
fn user_prompt_background_color() -> Color {
style::palette::surface()
}
fn user_prompt_prefix_style() -> Style {
Style::default()
.fg(style::palette::accent())
.bg(user_prompt_background_color())
.add_modifier(Modifier::BOLD)
}
fn user_prompt_content_style() -> Style {
Style::default()
.fg(style::palette::text())
.bg(user_prompt_background_color())
}
fn user_prompt_lookup_style() -> Style {
Style::default()
.fg(style::palette::info())
.bg(user_prompt_background_color())
}
fn prompt_block_continuation_padding() -> String {
" ".repeat(USER_PROMPT_PREFIX.chars().count())
}
fn inline_code_style() -> Style {
Style::default().fg(style::palette::warning())
}
fn find_matching_backtick(characters: &[char], start_index: usize) -> Option<usize> {
characters[start_index..]
.iter()
.position(|character| *character == '`')
.map(|index| index + start_index)
}
fn find_matching_double_asterisk(characters: &[char], start_index: usize) -> Option<usize> {
let mut index = start_index;
while index + 1 < characters.len() {
if characters[index] == '*' && characters[index + 1] == '*' {
return Some(index);
}
index += 1;
}
None
}
fn find_matching_single_asterisk(characters: &[char], start_index: usize) -> Option<usize> {
characters[start_index..]
.iter()
.position(|character| *character == '*')
.map(|index| index + start_index)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
#[test]
fn test_render_markdown_styles_heading() {
let input = "# Heading";
let lines = render_markdown(input, 80);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "Heading");
assert_eq!(lines[0].spans[0].style, heading_style(1));
}
#[test]
fn test_render_markdown_styles_user_prompt() {
let input = " › /model antigravity";
let lines = render_markdown(input, 80);
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].to_string().trim_end(), "");
assert_eq!(lines[0].width(), 80);
assert_eq!(lines[1].to_string().trim_end(), input);
assert_eq!(lines[1].width(), 80);
assert_eq!(lines[1].spans[0].style, user_prompt_prefix_style());
assert_eq!(lines[1].spans[1].style, user_prompt_content_style());
assert_eq!(lines[1].spans[1].style.fg, Some(style::palette::text()));
assert_eq!(
lines[1].spans.last().expect("padding span").style,
user_prompt_content_style()
);
assert_eq!(lines[2].to_string().trim_end(), "");
assert_eq!(lines[2].width(), 80);
assert_eq!(lines[2].spans[0].style, user_prompt_content_style());
}
#[test]
fn test_render_markdown_styles_multiline_user_prompt() {
let input = " › first line\nsecond line\n\nassistant line";
let lines = render_markdown(input, 80);
assert_eq!(lines.len(), 6);
assert_eq!(lines[0].to_string().trim_end(), "");
assert_eq!(lines[1].to_string().trim_end(), " › first line");
assert_eq!(lines[2].to_string().trim_end(), " second line");
assert_eq!(lines[1].width(), 80);
assert_eq!(lines[2].width(), 80);
assert_eq!(lines[4].to_string(), "");
assert_eq!(lines[5].to_string(), "assistant line");
assert_eq!(lines[1].spans[0].style, user_prompt_prefix_style());
assert_eq!(lines[2].spans[0].content, " ");
assert_eq!(lines[2].spans[0].style, user_prompt_content_style());
assert_eq!(lines[2].spans[1].style, user_prompt_content_style());
assert_eq!(lines[5].spans[0].style, Style::default());
}
#[test]
fn test_render_markdown_styles_clarification_block_differently_from_user_prompt() {
let input = " › Clarifications:\n 1. Q: Need tests?\n A: Yes";
let lines = render_markdown(input, 80);
assert_eq!(lines[1].to_string().trim_end(), " › Clarifications:");
assert_eq!(lines[1].spans[0].style, clarification_prompt_prefix_style());
assert_eq!(lines[1].spans[1].style, clarification_header_style());
assert_ne!(lines[1].spans[1].style.bg, user_prompt_content_style().bg);
assert!(lines[2].spans.iter().any(|span| {
span.content.as_ref() == "1. " && span.style == clarification_question_index_style()
}));
assert!(lines[2].spans.iter().any(|span| {
span.content.as_ref() == "Q: " && span.style == clarification_question_label_style()
}));
assert!(lines[3].spans.iter().any(|span| {
span.content.as_ref() == "A: " && span.style == clarification_answer_label_style()
}));
}
#[test]
fn test_render_markdown_keeps_prompt_continuation_line_verbatim() {
let input = " › first line\n**bold**\n\nassistant";
let lines = render_markdown(input, 80);
assert_eq!(lines[2].to_string().trim_end(), " **bold**");
assert_eq!(lines[2].spans[0].style, user_prompt_content_style());
assert_eq!(lines[4].to_string(), "");
assert_eq!(lines[5].to_string(), "assistant");
}
#[test]
fn test_render_markdown_wraps_user_prompt_content_with_continuation_padding() {
let input = " › one two three";
let lines = render_markdown(input, 8);
assert!(lines.len() >= 4);
assert_eq!(lines[0].to_string().trim_end(), "");
assert!(lines[1].to_string().starts_with(" › "));
assert!(lines[2].to_string().starts_with(" "));
assert_eq!(lines[0].spans[0].style, user_prompt_content_style());
assert_eq!(lines[2].spans[0].style, user_prompt_content_style());
assert_eq!(
lines.last().expect("bottom padding").spans[0].style,
user_prompt_content_style()
);
}
#[test]
fn test_render_markdown_wraps_user_prompt_on_word_boundaries() {
let input = " › one two three";
let lines = render_markdown(input, 8);
let rendered_lines = lines
.iter()
.map(|line| line.to_string().trim_end().to_string())
.collect::<Vec<_>>();
assert!(rendered_lines.contains(&" › one".to_string()));
assert!(rendered_lines.contains(&" two".to_string()));
assert!(rendered_lines.contains(&" three".to_string()));
}
#[test]
fn test_render_markdown_wraps_clarification_answer_on_word_boundaries() {
let input =
" › Clarifications:\n 1. Q: Need tests?\n A: very long answer text for review";
let lines = render_markdown(input, 18);
let rendered_lines = lines
.iter()
.map(|line| line.to_string().trim_end().to_string())
.collect::<Vec<_>>();
assert!(rendered_lines.contains(&" A: very".to_string()));
assert!(
rendered_lines
.iter()
.any(|line| line.trim_start().starts_with("long answer"))
);
assert!(
rendered_lines
.iter()
.any(|line| line.trim_start().starts_with("text for"))
);
assert!(
rendered_lines
.iter()
.any(|line| line.trim_start().starts_with("review"))
);
}
#[test]
fn test_render_markdown_wraps_long_prompt_word_with_hard_fallback() {
let input = " › supercalifragilisticexpialidocious";
let lines = render_markdown(input, 8);
let rendered_lines = lines
.iter()
.map(|line| line.to_string().trim_end().to_string())
.collect::<Vec<_>>();
assert!(rendered_lines.contains(&" › super".to_string()));
assert!(rendered_lines.contains(&" calif".to_string()));
assert!(rendered_lines.contains(&" ragil".to_string()));
}
#[test]
fn test_wrap_verbatim_spans_with_word_boundaries_handles_wide_characters() {
let spans = vec![Span::raw("你好 你好".to_string())];
let lines = wrap_verbatim_spans_with_word_boundaries(spans, 5);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].to_string(), "你好 ");
assert_eq!(lines[0].width(), 5);
assert_eq!(lines[1].to_string(), "你好");
assert_eq!(lines[1].width(), 4);
}
#[test]
fn test_wrap_verbatim_spans_with_word_boundaries_wraps_when_word_reaches_edge() {
let spans = vec![Span::raw("foo bar".to_string())];
let lines = wrap_verbatim_spans_with_word_boundaries(spans, 7);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].to_string(), "foo ");
assert_eq!(lines[1].to_string(), "bar");
}
#[test]
fn test_wrap_verbatim_spans_handles_wide_characters() {
let spans = vec![Span::raw("你好你好".to_string())];
let lines = wrap_verbatim_spans(spans, 5);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].to_string(), "你好");
assert_eq!(lines[0].width(), 4);
assert_eq!(lines[1].to_string(), "你好");
assert_eq!(lines[1].width(), 4);
}
#[test]
fn test_render_markdown_highlights_file_lookups_in_user_prompt_block() {
let input = " › check @crates/agentty/src/ui/markdown.rs";
let lines = render_markdown(input, 80);
assert!(lines[1].spans.iter().any(|span| {
span.content.as_ref() == "@crates/agentty/src/ui/markdown.rs"
&& span.style == user_prompt_lookup_style()
}));
}
#[test]
fn test_render_markdown_does_not_highlight_non_lookup_at_symbol_in_user_prompt_block() {
let input = " › reach me at email@example.com";
let lines = render_markdown(input, 80);
assert!(
!lines[1]
.spans
.iter()
.any(|span| span.style == user_prompt_lookup_style())
);
}
#[test]
fn test_render_markdown_keeps_text_after_multiple_blank_lines_in_user_prompt_block() {
let input = " › first line\n \n \n after gap\n\nassistant";
let lines = render_markdown(input, 80);
assert!(lines.iter().any(|line| {
line.to_string().trim_end() == " after gap"
&& line
.spans
.iter()
.all(|span| span.style == user_prompt_content_style())
}));
assert_eq!(
lines.last().expect("assistant line").to_string(),
"assistant"
);
}
#[test]
fn test_render_markdown_parses_inline_styles() {
let input = "before **bold** *italic* `code`";
let lines = render_markdown(input, 80);
let line = &lines[0];
assert_eq!(lines.len(), 1);
assert_eq!(line.to_string(), "before bold italic code");
assert!(line.spans.iter().any(|span| {
span.content.as_ref() == "bold" && span.style.add_modifier.contains(Modifier::BOLD)
}));
assert!(line.spans.iter().any(|span| {
span.content.as_ref() == "italic" && span.style.add_modifier.contains(Modifier::ITALIC)
}));
assert!(
line.spans
.iter()
.any(|span| span.content.as_ref() == "code" && span.style == inline_code_style())
);
}
#[test]
fn test_render_markdown_leaves_unmatched_inline_delimiters_literal() {
let input = "text **bold";
let lines = render_markdown(input, 80);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), input);
assert!(
!lines[0]
.spans
.iter()
.any(|span| span.style.add_modifier.contains(Modifier::BOLD))
);
}
#[test]
fn test_render_markdown_renders_fenced_code_without_inline_parsing() {
let input = "```rust\nlet value = **raw**;\n```";
let lines = render_markdown(input, 80);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "let value = **raw**;");
assert_eq!(lines[0].spans[0].style, code_block_style());
}
#[test]
fn test_render_markdown_treats_unclosed_fence_as_code() {
let input = "```\n**raw**";
let lines = render_markdown(input, 80);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "**raw**");
assert_eq!(lines[0].spans[0].style, code_block_style());
}
#[test]
fn test_render_markdown_renders_stats_metric_with_fixed_alignment() {
let input = "```stats\nSession ID\tsession-id\n```";
let lines = render_markdown(input, 80);
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0].to_string().find("session-id"),
Some(STATS_LABEL_WIDTH)
);
assert!(lines[0].spans.iter().any(|span| {
span.content.as_ref().contains("Session ID") && span.style == stats_metric_style()
}));
assert!(lines[0].spans.iter().any(|span| {
span.content.as_ref().contains("session-id") && span.style == stats_value_style()
}));
}
#[test]
fn test_render_markdown_renders_stats_section_title_style() {
let input = "```stats\nTokens Usage\n```";
let lines = render_markdown(input, 80);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "Tokens Usage");
assert_eq!(lines[0].spans[0].style, stats_section_style());
}
#[test]
fn test_render_markdown_wraps_bullets_with_continuation_indent() {
let input = "- one two three four";
let lines = render_markdown(input, 8);
assert!(lines.len() >= 2);
assert!(lines[0].to_string().starts_with("- "));
assert!(lines[1].to_string().starts_with(" "));
}
#[test]
fn test_render_markdown_wraps_numbered_list_with_continuation_indent() {
let input = "12. one two three";
let lines = render_markdown(input, 9);
assert!(lines.len() >= 2);
assert!(lines[0].to_string().starts_with("12. "));
assert!(lines[1].to_string().starts_with(" "));
}
#[test]
fn test_render_markdown_wraps_blockquote_with_prefix() {
let input = "> one two three";
let lines = render_markdown(input, 7);
assert!(lines.len() >= 2);
assert!(lines[0].to_string().starts_with("│ "));
assert!(lines[1].to_string().starts_with("│ "));
}
#[test]
fn test_render_markdown_renders_horizontal_rule() {
let input = "---";
let lines = render_markdown(input, 5);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "-----");
assert_eq!(lines[0].spans[0].style, horizontal_rule_style());
}
#[test]
fn test_markdown_render_cache_retains_multiple_entries() {
let cache = MarkdownRenderCache::default();
let first_lines = cache.render("# First", 24);
let second_lines = cache.render("# Second", 24);
let cached_first_lines = cache.render("# First", 24);
assert!(Arc::ptr_eq(&first_lines, &cached_first_lines));
assert_eq!(
second_lines.as_ref(),
render_markdown("# Second", 24).as_slice()
);
assert_eq!(cache.entries.borrow().len(), 2);
}
#[test]
fn test_markdown_render_cache_evicts_least_recently_used_entry() {
let cache = MarkdownRenderCache::default();
for index in 0..MARKDOWN_RENDER_CACHE_ENTRY_LIMIT {
let markdown = format!("# Entry {index}");
cache.render(&markdown, 24);
}
cache.render("# Entry 0", 24);
cache.render("# Overflow", 24);
let cached_hashes = cache
.entries
.borrow()
.iter()
.map(|entry| entry.key.content_hash)
.collect::<Vec<_>>();
assert_eq!(cached_hashes.len(), MARKDOWN_RENDER_CACHE_ENTRY_LIMIT);
assert!(cached_hashes.contains(&MarkdownRenderCache::hash_text("# Entry 0")));
assert!(!cached_hashes.contains(&MarkdownRenderCache::hash_text("# Entry 1")));
assert!(cached_hashes.contains(&MarkdownRenderCache::hash_text("# Overflow")));
}
#[test]
fn test_markdown_render_cache_bump_version_clears_styled_entries() {
let cache = MarkdownRenderCache::default();
let initial_lines = cache.render("# Entry", 24);
cache.bump_version();
let refreshed_lines = cache.render("# Entry", 24);
assert!(!Arc::ptr_eq(&initial_lines, &refreshed_lines));
assert_eq!(cache.entries.borrow().len(), 1);
assert_eq!(cache.version.get(), 1);
}
}