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, UnicodeWidthStr};
use crate::style::TextRenderSettings;
use crate::text_util::wrap_styled_line;
use crate::{mermaid, style};
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>]> {
self.render_with_settings(text, width, TextRenderSettings::default())
}
pub fn render_with_settings(
&self,
text: &str,
width: usize,
settings: TextRenderSettings,
) -> Arc<[Line<'static>]> {
self.render_with_settings_and_renderer(
text,
width,
settings,
render_markdown_active_settings,
)
}
pub fn render_with_settings_and_renderer(
&self,
text: &str,
width: usize,
settings: TextRenderSettings,
renderer: impl FnOnce(&str, usize) -> Vec<Line<'static>>,
) -> Arc<[Line<'static>]> {
style::with_render_settings(settings, || {
self.render_active_settings(text, width, renderer)
})
}
pub fn bump_version(&self) {
self.version.set(self.version.get().wrapping_add(1));
self.entries.borrow_mut().clear();
}
pub fn version(&self) -> u64 {
self.version.get()
}
fn render_active_settings(
&self,
text: &str,
width: usize,
renderer: impl FnOnce(&str, usize) -> Vec<Line<'static>>,
) -> 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(renderer(text, width));
self.store_entry(MarkdownRenderCacheEntry {
key,
lines: Arc::clone(&lines),
});
lines
}
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,
}
struct MarkdownTable {
alignments: Vec<TableAlignment>,
headers: Vec<String>,
rows: Vec<Vec<String>>,
}
#[derive(Clone, Copy)]
enum TableAlignment {
Center,
Left,
Right,
}
pub fn render_markdown(text: &str, width: usize) -> Vec<Line<'static>> {
render_markdown_active_settings(text, width)
}
pub fn render_markdown_with_settings(
text: &str,
width: usize,
settings: TextRenderSettings,
) -> Vec<Line<'static>> {
style::with_render_settings(settings, || render_markdown_active_settings(text, width))
}
pub fn markdown_block_preservation_mask(text: &str) -> Vec<bool> {
let raw_lines = text.split('\n').collect::<Vec<_>>();
let mut block_state = BlockState::Paragraph;
let mut preservation_mask = vec![false; raw_lines.len()];
let mut line_index = 0;
while line_index < raw_lines.len() {
let raw_line = raw_lines[line_index];
if is_fence_delimiter(raw_line) {
preservation_mask[line_index] = true;
let _ = update_fence_state(raw_line, &mut block_state);
line_index += 1;
continue;
}
if !matches!(block_state, BlockState::Paragraph) {
preservation_mask[line_index] = true;
line_index += 1;
continue;
}
if let Some((_, next_line_index)) = parse_markdown_table(&raw_lines, line_index) {
preservation_mask[line_index..next_line_index].fill(true);
line_index = next_line_index;
continue;
}
if is_horizontal_rule(raw_line) {
preservation_mask[line_index] = true;
}
line_index += 1;
}
preservation_mask
}
fn render_markdown_active_settings(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;
let raw_lines = text.split('\n').collect::<Vec<_>>();
let mut line_index = 0;
while line_index < raw_lines.len() {
line_index = render_markdown_input_line(
&raw_lines,
line_index,
width,
&mut block_state,
&mut is_user_prompt_block,
&mut active_prompt_block_kind,
&mut rendered_lines,
);
}
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 render_markdown_input_line(
raw_lines: &[&str],
line_index: usize,
width: usize,
block_state: &mut BlockState,
is_user_prompt_block: &mut bool,
active_prompt_block_kind: &mut PromptBlockKind,
rendered_lines: &mut Vec<Line<'static>>,
) -> usize {
let raw_line = raw_lines[line_index];
if handle_prompt_block_line(
raw_line,
width,
block_state,
is_user_prompt_block,
active_prompt_block_kind,
rendered_lines,
) {
return line_index + 1;
}
if let Some(next_line_index) =
render_mermaid_block_line(raw_lines, line_index, width, *block_state, rendered_lines)
{
return next_line_index;
}
if update_fence_state(raw_line, block_state) {
return line_index + 1;
}
if let Some(next_line_index) =
render_markdown_table_line(raw_lines, line_index, width, *block_state, rendered_lines)
{
return next_line_index;
}
render_markdown_block_line(raw_line, width, *block_state, rendered_lines);
line_index + 1
}
fn render_mermaid_block_line(
raw_lines: &[&str],
line_index: usize,
width: usize,
block_state: BlockState,
rendered_lines: &mut Vec<Line<'static>>,
) -> Option<usize> {
let raw_line = raw_lines[line_index];
if !matches!(block_state, BlockState::Paragraph) || !is_mermaid_fence(raw_line) {
return None;
}
let (diagram_lines, next_line_index) = render_mermaid_block(raw_lines, line_index, width)?;
rendered_lines.extend(diagram_lines);
Some(next_line_index)
}
fn update_fence_state(raw_line: &str, block_state: &mut BlockState) -> bool {
if !is_fence_delimiter(raw_line) {
return false;
}
*block_state = match block_state {
BlockState::Paragraph => opening_fence_block_state(raw_line),
BlockState::FencedCode | BlockState::FencedStats => BlockState::Paragraph,
};
true
}
fn render_markdown_table_line(
raw_lines: &[&str],
line_index: usize,
width: usize,
block_state: BlockState,
rendered_lines: &mut Vec<Line<'static>>,
) -> Option<usize> {
if !matches!(block_state, BlockState::Paragraph) {
return None;
}
let (table, next_line_index) = parse_markdown_table(raw_lines, line_index)?;
rendered_lines.extend(render_markdown_table(&table, width));
Some(next_line_index)
}
fn render_markdown_block_line(
raw_line: &str,
width: usize,
block_state: BlockState,
rendered_lines: &mut Vec<Line<'static>>,
) {
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)),
}
}
fn handle_prompt_block_line(
raw_line: &str,
width: usize,
block_state: &mut BlockState,
is_user_prompt_block: &mut bool,
active_prompt_block_kind: &mut PromptBlockKind,
rendered_lines: &mut Vec<Line<'static>>,
) -> bool {
let starts_user_prompt_block = raw_line.starts_with(USER_PROMPT_PREFIX);
let Some(prompt_line) = user_prompt_block_line(raw_line, is_user_prompt_block) else {
return false;
};
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(""));
}
true
}
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_spans_with_word_boundaries(
vec![Span::styled(raw_line.to_string(), 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 is_mermaid_fence(raw_line: &str) -> bool {
let Some(suffix) = raw_line.trim().strip_prefix("```mermaid") else {
return false;
};
suffix
.chars()
.next()
.is_none_or(|character| character.is_ascii_whitespace() || character == '{')
}
fn render_mermaid_block(
raw_lines: &[&str],
start_index: usize,
width: usize,
) -> Option<(Vec<Line<'static>>, usize)> {
let mut source = String::new();
let mut next_line_index = start_index + 1;
let mut source_byte_count = 0_usize;
let mut source_line_count = 0_usize;
loop {
let raw_line = raw_lines.get(next_line_index)?;
next_line_index += 1;
if is_fence_delimiter(raw_line) {
break;
}
source_line_count += 1;
if source_line_count > mermaid::MAX_SOURCE_LINE_COUNT {
return None;
}
let newline_byte_count = usize::from(source_line_count > 1);
source_byte_count = source_byte_count
.checked_add(newline_byte_count)?
.checked_add(raw_line.len())?;
if source_byte_count > mermaid::MAX_SOURCE_BYTE_COUNT {
return None;
}
if source_line_count > 1 {
source.push('\n');
}
source.push_str(raw_line);
}
let diagram = mermaid::render_mermaid(&source)?;
if diagram.width > width {
return None;
}
Some((diagram.lines, next_line_index))
}
fn parse_markdown_table(raw_lines: &[&str], start_index: usize) -> Option<(MarkdownTable, usize)> {
let header_cells = parse_table_row(raw_lines.get(start_index)?)?;
let alignments = parse_table_separator(raw_lines.get(start_index + 1)?, header_cells.len())?;
let mut next_line_index = start_index + 2;
let mut rows = Vec::new();
while let Some(raw_line) = raw_lines.get(next_line_index) {
if raw_line.trim().is_empty() {
break;
}
let Some(row) = parse_table_row(raw_line) else {
break;
};
if parse_table_separator(raw_line, header_cells.len()).is_some() {
break;
}
rows.push(normalize_table_row(row, header_cells.len()));
next_line_index += 1;
}
Some((
MarkdownTable {
alignments,
headers: header_cells,
rows,
},
next_line_index,
))
}
fn render_markdown_table(table: &MarkdownTable, width: usize) -> Vec<Line<'static>> {
let column_widths = table_column_widths(table, width);
let mut lines = Vec::new();
lines.push(table_border_line('┌', '┬', '┐', &column_widths));
lines.extend(render_table_row(
&table.headers,
&table.alignments,
&column_widths,
table_header_style(),
));
lines.push(table_border_line('├', '┼', '┤', &column_widths));
for row in &table.rows {
lines.extend(render_table_row(
row,
&table.alignments,
&column_widths,
table_cell_style(),
));
}
lines.push(table_border_line('└', '┴', '┘', &column_widths));
lines
}
fn parse_table_row(raw_line: &str) -> Option<Vec<String>> {
let trimmed = raw_line.trim();
if !trimmed.contains('|') {
return None;
}
let mut cells = trimmed.split('|').collect::<Vec<_>>();
if trimmed.starts_with('|') {
cells.remove(0);
}
if trimmed.ends_with('|') {
cells.pop();
}
if cells.len() < 2 {
return None;
}
Some(
cells
.into_iter()
.map(|cell| cell.trim().to_string())
.collect(),
)
}
fn parse_table_separator(
raw_line: &str,
expected_cell_count: usize,
) -> Option<Vec<TableAlignment>> {
let separator_cells = parse_table_row(raw_line)?;
if separator_cells.len() != expected_cell_count {
return None;
}
separator_cells
.iter()
.map(|cell| parse_table_alignment(cell))
.collect()
}
fn parse_table_alignment(cell: &str) -> Option<TableAlignment> {
let trimmed = cell.trim();
let dash_count = trimmed
.chars()
.filter(|character| *character == '-')
.count();
if dash_count < 3
|| !trimmed
.chars()
.all(|character| character == '-' || character == ':')
{
return None;
}
match (trimmed.starts_with(':'), trimmed.ends_with(':')) {
(true, true) => Some(TableAlignment::Center),
(false, true) => Some(TableAlignment::Right),
_ => Some(TableAlignment::Left),
}
}
fn normalize_table_row(mut row: Vec<String>, column_count: usize) -> Vec<String> {
row.truncate(column_count);
while row.len() < column_count {
row.push(String::new());
}
row
}
fn table_column_widths(table: &MarkdownTable, width: usize) -> Vec<usize> {
let mut column_widths = table
.headers
.iter()
.enumerate()
.map(|(column_index, header)| {
let body_width = table
.rows
.iter()
.filter_map(|row| row.get(column_index))
.map(|cell| UnicodeWidthStr::width(cell.as_str()))
.max()
.unwrap_or(0);
UnicodeWidthStr::width(header.as_str())
.max(body_width)
.max(3)
})
.collect::<Vec<_>>();
shrink_table_columns_to_width(&mut column_widths, width);
column_widths
}
fn shrink_table_columns_to_width(column_widths: &mut [usize], width: usize) {
if width == 0 {
return;
}
while table_rendered_width(column_widths) > width {
let Some((largest_index, largest_width)) = column_widths
.iter()
.copied()
.enumerate()
.filter(|(_, column_width)| *column_width > 3)
.max_by_key(|(_, column_width)| *column_width)
else {
break;
};
column_widths[largest_index] = largest_width.saturating_sub(1);
}
}
fn table_rendered_width(column_widths: &[usize]) -> usize {
1 + column_widths
.iter()
.map(|column_width| column_width + 3)
.sum::<usize>()
}
fn table_border_line(
left_corner: char,
join: char,
right_corner: char,
column_widths: &[usize],
) -> Line<'static> {
let mut border = String::new();
border.push(left_corner);
for (column_index, column_width) in column_widths.iter().enumerate() {
border.push_str(&"─".repeat(column_width + 2));
if column_index + 1 == column_widths.len() {
border.push(right_corner);
} else {
border.push(join);
}
}
Line::from(vec![Span::styled(border, table_border_style())])
}
fn render_table_row(
cells: &[String],
alignments: &[TableAlignment],
column_widths: &[usize],
cell_style: Style,
) -> Vec<Line<'static>> {
let wrapped_cells = cells
.iter()
.zip(column_widths)
.map(|(cell, column_width)| wrap_table_cell(cell, *column_width, cell_style))
.collect::<Vec<_>>();
let row_height = wrapped_cells.iter().map(Vec::len).max().unwrap_or(1).max(1);
let mut lines = Vec::with_capacity(row_height);
for row_index in 0..row_height {
let mut spans = vec![Span::styled("│".to_string(), table_border_style())];
for (column_index, column_width) in column_widths.iter().enumerate() {
let cell_line = wrapped_cells
.get(column_index)
.and_then(|cell_lines| cell_lines.get(row_index));
let alignment = alignments
.get(column_index)
.copied()
.unwrap_or(TableAlignment::Left);
append_table_cell_spans(&mut spans, cell_line, *column_width, alignment, cell_style);
spans.push(Span::styled("│".to_string(), table_border_style()));
}
lines.push(Line::from(spans));
}
lines
}
fn wrap_table_cell(cell: &str, width: usize, base_style: Style) -> Vec<Line<'static>> {
if cell.is_empty() {
return vec![Line::from("")];
}
wrap_styled_line(parse_inline_spans(cell, base_style), width)
}
fn append_table_cell_spans(
spans: &mut Vec<Span<'static>>,
cell_line: Option<&Line<'static>>,
column_width: usize,
alignment: TableAlignment,
cell_style: Style,
) {
let cell_width = cell_line.map_or(0, Line::width);
let available_padding = column_width.saturating_sub(cell_width);
let (left_padding, right_padding) = table_cell_padding(available_padding, alignment);
spans.push(Span::styled(" ".repeat(left_padding + 1), cell_style));
if let Some(cell_line) = cell_line {
spans.extend(cell_line.spans.iter().cloned());
}
spans.push(Span::styled(" ".repeat(right_padding + 1), cell_style));
}
fn table_cell_padding(available_padding: usize, alignment: TableAlignment) -> (usize, usize) {
match alignment {
TableAlignment::Center => {
let left_padding = available_padding / 2;
(left_padding, available_padding - left_padding)
}
TableAlignment::Left => (0, available_padding),
TableAlignment::Right => (available_padding, 0),
}
}
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 table_header_style() -> Style {
Style::default()
.fg(style::palette::text())
.bg(style::palette::surface_elevated())
.add_modifier(Modifier::BOLD)
}
fn table_cell_style() -> Style {
Style::default().fg(style::palette::text())
}
fn table_border_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::fmt::Write;
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_keeps_inline_style_punctuation_adjacent() {
let input = "Use (`session_messages_from_rows`), then [`Image #1`].";
let lines = render_markdown(input, 80);
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0].to_string(),
"Use (session_messages_from_rows), then [Image #1]."
);
}
#[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_wraps_fenced_code_on_word_boundaries() {
let input = "```text\nformatted blocks in user messages without words breaking\n```";
let lines = render_markdown(input, 32);
let rendered_lines = lines.iter().map(ToString::to_string).collect::<Vec<_>>();
assert_eq!(rendered_lines[0], "formatted blocks in user ");
assert_eq!(rendered_lines[1], "messages without words breaking");
assert!(!rendered_lines.iter().any(|line| line.ends_with("message")));
assert!(!rendered_lines.iter().any(|line| line.starts_with("s ")));
assert!(lines.iter().all(|line| {
line.spans
.first()
.is_some_and(|span| span.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_mermaid_block_as_diagram() {
let input = "```mermaid {theme=default}\ngraph TD\n A[Start] --> B[Finish]\n```";
let lines = render_markdown(input, 80);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Start"));
assert!(text.contains("Finish"));
assert!(text.contains("┌"));
assert!(text.contains("▼"));
assert!(!text.contains("graph TD"));
assert!(!text.contains("```"));
}
#[test]
fn test_render_markdown_keeps_code_fallback_for_mermaid_prefix_language() {
let input = "```mermaid-diagram\ngraph TD\n A[Start] --> B[Finish]\n```";
let lines = render_markdown(input, 80);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("graph TD"));
assert!(text.contains("A[Start] --> B[Finish]"));
assert!(!text.contains("▼"));
assert_eq!(lines[0].spans[0].style, code_block_style());
}
#[test]
fn test_render_markdown_accepts_mermaid_fence_with_tab_separator() {
let input = "```mermaid\t{theme=default}\ngraph TD\n A[Start] --> B[Finish]\n```";
let lines = render_markdown(input, 80);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Start"));
assert!(text.contains("Finish"));
assert!(text.contains("▼"));
assert!(!text.contains("graph TD"));
assert!(!text.contains("```"));
}
#[test]
fn test_render_markdown_mermaid_uses_injected_palette() {
let input = "```mermaid\ngraph TD\n A[Start] --> B[Finish]\n```";
let settings = TextRenderSettings {
cache_version: 7,
palette: crate::TextPalette {
text: Color::Red,
..crate::TextPalette::default()
},
};
let lines = render_markdown_with_settings(input, 80, settings);
let start_span = lines
.iter()
.flat_map(|line| line.spans.iter())
.find(|span| span.content.contains("Start"))
.expect("start label should render");
assert_eq!(start_span.style.fg, Some(Color::Red));
}
#[test]
fn test_render_markdown_renders_feedback_mermaid_block_as_diagram() {
let input = concat!(
"```mermaid\n",
"flowchart LR\n",
" A[\"App\"] -- \"commands:<br/>prompt · interrupt · permission answer\" --> \
H[\"ag-harness\"]\n",
" H -- \"typed events:<br/>deltas · tool calls · diffs · usage\" --> A\n",
"```",
);
let lines = render_markdown(input, 80);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("App"));
assert!(text.contains("ag-harness"));
assert!(text.contains("commands:"));
assert!(text.contains("typed events:"));
assert!(text.contains("◀"));
assert!(!text.contains("flowchart LR"));
assert!(!text.contains("<br/>"));
}
#[test]
fn test_render_markdown_keeps_code_fallback_for_unsupported_mermaid() {
let input = "```mermaid\nclassDiagram\n A <|-- B\n```";
let lines = render_markdown(input, 80);
assert_eq!(lines[0].to_string(), "classDiagram");
assert_eq!(lines[0].spans[0].style, code_block_style());
}
#[test]
fn test_render_markdown_renders_sequence_mermaid_block_as_diagram() {
let input = concat!(
"```mermaid\n",
"sequenceDiagram\n",
" participant User\n",
" participant Agentty\n",
" User->>Agentty: Start session\n",
"```\n",
);
let lines = render_markdown(input, 120);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("User"));
assert!(text.contains("Agentty"));
assert!(text.contains("Start session"));
assert!(!text.contains("sequenceDiagram"));
}
#[test]
fn test_render_markdown_keeps_code_fallback_for_unclosed_mermaid_fence() {
let input = "```mermaid\ngraph TD\n A[Start] --> B[Finish]";
let lines = render_markdown(input, 80);
assert_eq!(lines[0].to_string(), "graph TD");
assert_eq!(lines[0].spans[0].style, code_block_style());
}
#[test]
fn test_render_markdown_keeps_code_fallback_for_diagram_wider_than_width() {
let input = "```mermaid\ngraph LR\n A[Start] --> B[Finish]\n```";
let lines = render_markdown(input, 10);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("graph LR"));
assert!(!text.contains("┌"));
}
#[test]
fn test_render_markdown_keeps_code_fallback_for_line_limited_mermaid_source() {
let mut input = String::from("```mermaid\ngraph TD");
for node_index in 0..mermaid::MAX_SOURCE_LINE_COUNT {
write!(&mut input, "\n N{node_index}").expect("writing to String should succeed");
}
input.push_str("\n```");
let lines = render_markdown(&input, 80);
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("graph TD"));
assert!(!text.contains("┌"));
}
#[test]
fn test_render_markdown_keeps_code_fallback_for_byte_limited_mermaid_source() {
let label = "x".repeat(mermaid::MAX_SOURCE_BYTE_COUNT);
let input = format!("```mermaid\ngraph TD\n A[{label}]\n```");
let lines = render_markdown(&input, 80);
assert_eq!(lines[0].to_string(), "graph TD");
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_render_markdown_renders_pipe_table() {
let input = "| Name | Status |\n| --- | ---: |\n| Build | passing |\n| Docs | queued |";
let lines = render_markdown(input, 80);
let rendered_lines = lines.iter().map(ToString::to_string).collect::<Vec<_>>();
let rendered_text = rendered_lines.join("\n");
assert!(rendered_text.contains("Name"));
assert!(rendered_text.contains("Status"));
assert!(rendered_text.contains("Build"));
assert!(rendered_text.contains("passing"));
assert!(!rendered_text.contains("| --- | ---: |"));
assert!(rendered_lines.iter().any(|line| line.starts_with("┌")));
assert!(rendered_lines.iter().any(|line| line.starts_with("├")));
assert!(lines[1].spans.iter().any(|span| {
span.content.as_ref().contains("Name") && span.style == table_header_style()
}));
}
#[test]
fn test_markdown_block_preservation_mask_uses_shared_block_classifiers() {
let input = concat!(
" plain\n",
" | Name | Status |\n",
" | --- | ---: |\n",
" | Build | passing |\n",
"\n",
" ---\n",
" ```text\n",
" fenced\n",
" ```",
);
let preservation_mask = markdown_block_preservation_mask(input);
assert_eq!(
preservation_mask,
vec![false, true, true, true, false, true, true, true, true]
);
}
#[test]
fn test_render_markdown_wraps_table_cells_to_available_width() {
let input = "| Column | Notes |\n| --- | --- |\n| One | alpha beta gamma delta |";
let lines = render_markdown(input, 20);
let rendered_text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered_text.contains("alpha"));
assert!(rendered_text.contains("beta"));
assert!(rendered_text.contains("gamma"));
assert!(lines.iter().all(|line| line.width() <= 20));
}
#[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_reuses_custom_renderer_result() {
let cache = MarkdownRenderCache::default();
let settings = TextRenderSettings::default();
let render_count = Cell::new(0);
let first_lines =
cache.render_with_settings_and_renderer("custom", 24, settings, |_text, _width| {
render_count.set(render_count.get() + 1);
vec![Line::from("custom render")]
});
let cached_lines =
cache.render_with_settings_and_renderer("custom", 24, settings, |_text, _width| {
render_count.set(render_count.get() + 1);
vec![Line::from("unexpected render")]
});
assert!(Arc::ptr_eq(&first_lines, &cached_lines));
assert_eq!(first_lines[0].to_string(), "custom render");
assert_eq!(render_count.get(), 1);
}
#[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_uses_injected_palette() {
let cache = MarkdownRenderCache::default();
let settings = TextRenderSettings {
cache_version: 7,
palette: crate::TextPalette {
accent: Color::Red,
..crate::TextPalette::default()
},
};
let lines = cache.render_with_settings("# Entry", 24, settings);
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Red));
}
#[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);
}
}