use ratatui::text::Line;
use std::borrow::Cow;
use std::ops::Range;
use std::path::Path;
use crate::tui_internal::inline_visualization::InlineVisualizationContext;
use crate::tui_internal::inline_visualization::rewrite_inline_visualizations;
use crate::tui_internal::table_detect;
use crate::tui_internal::terminal_hyperlinks::HyperlinkLine;
pub(crate) fn append_markdown(
markdown_source: &str,
width: Option<usize>,
cwd: Option<&Path>,
lines: &mut Vec<Line<'static>>,
) {
let rendered = crate::tui_internal::markdown_render::render_markdown_text_with_width_and_cwd(
markdown_source,
width,
cwd,
);
crate::tui_internal::render::line_utils::push_owned_lines(&rendered.lines, lines);
}
#[cfg(test)]
pub(crate) fn append_markdown_agent(
markdown_source: &str,
width: Option<usize>,
lines: &mut Vec<Line<'static>>,
) {
let normalized = unwrap_markdown_fences(markdown_source);
let rendered = crate::tui_internal::markdown_render::render_markdown_text_with_width_and_cwd(
&normalized,
width,
None,
);
crate::tui_internal::render::line_utils::push_owned_lines(&rendered.lines, lines);
}
pub(crate) fn render_markdown_agent_with_links_and_cwd(
markdown_source: &str,
width: Option<usize>,
cwd: Option<&Path>,
) -> Vec<HyperlinkLine> {
render_markdown_agent_with_links_cwd_and_visualizations(
markdown_source,
width,
cwd,
None,
)
}
pub(crate) fn render_markdown_agent_with_links_cwd_and_visualizations(
markdown_source: &str,
width: Option<usize>,
cwd: Option<&Path>,
inline_visualization_context: Option<&InlineVisualizationContext>,
) -> Vec<HyperlinkLine> {
let rewritten = rewrite_inline_visualizations(markdown_source, inline_visualization_context);
let normalized = unwrap_markdown_fences(&rewritten.markdown);
let is_hidden_link_destination =
|destination: &str| rewritten.trusted_file_links.contains_key(destination);
let mut lines =
crate::tui_internal::markdown_render::render_markdown_lines_with_width_cwd_and_hidden_link_destinations(
&normalized,
width,
cwd,
&is_hidden_link_destination,
);
for hyperlink in lines.iter_mut().flat_map(|line| &mut line.hyperlinks) {
if let Some(link) = rewritten.trusted_file_links.get(&hyperlink.destination) {
hyperlink.retarget_to_trusted_file(&link.destination);
}
}
lines
}
pub(crate) fn render_streaming_markdown_agent_with_links_and_cwd(
markdown_source: &str,
width: Option<usize>,
cwd: Option<&Path>,
) -> crate::tui_internal::markdown_render::StreamingMarkdownRender {
let normalized = unwrap_markdown_fences(markdown_source);
let mut rendered = crate::tui_internal::markdown_render::render_streaming_markdown_lines_with_width_and_cwd(
&normalized,
width,
cwd,
);
if normalized != markdown_source {
rendered.last_top_level_block_start = rendered
.last_top_level_block_start
.and_then(|boundary| markdown_source.strip_suffix(&normalized[boundary..]))
.map(str::len);
}
rendered
}
fn unwrap_markdown_fences<'a>(markdown_source: &'a str) -> Cow<'a, str> {
if !markdown_source.contains("```") && !markdown_source.contains("~~~") {
return Cow::Borrowed(markdown_source);
}
#[derive(Clone, Copy)]
struct Fence {
marker: u8,
len: usize,
is_blockquoted: bool,
}
fn strip_line_indent(line: &str) -> Option<&str> {
let without_newline = line.strip_suffix('\n').unwrap_or(line);
let mut byte_idx = 0usize;
let mut column = 0usize;
for b in without_newline.as_bytes() {
match b {
b' ' => {
byte_idx += 1;
column += 1;
}
b'\t' => {
byte_idx += 1;
column += 4;
}
_ => break,
}
if column >= 4 {
return None;
}
}
Some(&without_newline[byte_idx..])
}
fn parse_open_fence(line: &str) -> Option<(Fence, bool)> {
let trimmed = strip_line_indent(line)?;
let is_blockquoted = trimmed.trim_start().starts_with('>');
let fence_scan_text = table_detect::strip_blockquote_prefix(trimmed);
let (marker, len) = table_detect::parse_fence_marker(fence_scan_text)?;
let is_markdown = table_detect::is_markdown_fence_info(fence_scan_text, len);
Some((
Fence {
marker: marker as u8,
len,
is_blockquoted,
},
is_markdown,
))
}
fn is_close_fence(line: &str, fence: Fence) -> bool {
let Some(trimmed) = strip_line_indent(line) else {
return false;
};
let fence_scan_text = if fence.is_blockquoted {
if !trimmed.trim_start().starts_with('>') {
return false;
}
table_detect::strip_blockquote_prefix(trimmed)
} else {
trimmed
};
if let Some((marker, len)) = table_detect::parse_fence_marker(fence_scan_text) {
marker as u8 == fence.marker
&& len >= fence.len
&& fence_scan_text[len..].trim().is_empty()
} else {
false
}
}
fn markdown_fence_contains_table(content: &str, is_blockquoted_fence: bool) -> bool {
let mut previous_line: Option<&str> = None;
for line in content.lines() {
let text = if is_blockquoted_fence {
table_detect::strip_blockquote_prefix(line)
} else {
line
};
let trimmed = text.trim();
if trimmed.is_empty() {
previous_line = None;
continue;
}
if let Some(previous) = previous_line
&& table_detect::is_table_header_line(previous)
&& !table_detect::is_table_delimiter_line(previous)
&& table_detect::is_table_delimiter_line(trimmed)
{
return true;
}
previous_line = Some(trimmed);
}
false
}
fn content_from_ranges(source: &str, ranges: &[Range<usize>]) -> String {
let total_len: usize = ranges.iter().map(ExactSizeIterator::len).sum();
let mut content = String::with_capacity(total_len);
for range in ranges {
content.push_str(&source[range.start..range.end]);
}
content
}
struct MarkdownCandidateData {
fence: Fence,
opening_range: Range<usize>,
content_ranges: Vec<Range<usize>>,
}
enum ActiveFence {
Passthrough(Fence),
MarkdownCandidate(Box<MarkdownCandidateData>),
}
let mut out = String::with_capacity(markdown_source.len());
let mut active_fence: Option<ActiveFence> = None;
let mut source_offset = 0usize;
let mut push_source_range = |range: Range<usize>| {
if !range.is_empty() {
out.push_str(&markdown_source[range]);
}
};
for line in markdown_source.split_inclusive('\n') {
let line_start = source_offset;
source_offset += line.len();
let line_range = line_start..source_offset;
if let Some(active) = active_fence.take() {
match active {
ActiveFence::Passthrough(fence) => {
push_source_range(line_range);
if !is_close_fence(line, fence) {
active_fence = Some(ActiveFence::Passthrough(fence));
}
}
ActiveFence::MarkdownCandidate(mut data) => {
if is_close_fence(line, data.fence) {
if markdown_fence_contains_table(
&content_from_ranges(markdown_source, &data.content_ranges),
data.fence.is_blockquoted,
) {
for range in data.content_ranges {
push_source_range(range);
}
} else {
push_source_range(data.opening_range);
for range in data.content_ranges {
push_source_range(range);
}
push_source_range(line_range);
}
} else {
data.content_ranges.push(line_range);
active_fence = Some(ActiveFence::MarkdownCandidate(data));
}
}
}
continue;
}
if let Some((fence, is_markdown)) = parse_open_fence(line) {
if is_markdown {
active_fence = Some(ActiveFence::MarkdownCandidate(Box::new(
MarkdownCandidateData {
fence,
opening_range: line_range,
content_ranges: Vec::new(),
},
)));
} else {
push_source_range(line_range);
active_fence = Some(ActiveFence::Passthrough(fence));
}
continue;
}
push_source_range(line_range);
}
if let Some(active) = active_fence {
match active {
ActiveFence::Passthrough(_) => {}
ActiveFence::MarkdownCandidate(data) => {
push_source_range(data.opening_range);
for range in data.content_ranges {
push_source_range(range);
}
}
}
}
Cow::Owned(out)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::text::Line;
fn lines_to_strings(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect()
}
#[test]
fn citations_render_as_plain_text() {
let src = "Before 【F:/x.rs†L1】\nAfter 【F:/x.rs†L3】\n";
let mut out = Vec::new();
append_markdown(src, None, None, &mut out);
let rendered = lines_to_strings(&out);
assert_eq!(
rendered,
vec![
"Before 【F:/x.rs†L1】".to_string(),
"After 【F:/x.rs†L3】".to_string()
]
);
}
#[test]
fn indented_code_blocks_preserve_leading_whitespace() {
let src = "Before\n\n code 1\n\nAfter\n";
let mut out = Vec::new();
append_markdown(src, None, None, &mut out);
let lines = lines_to_strings(&out);
assert_eq!(lines, vec!["Before", "", " code 1", "", "After"]);
}
#[test]
fn append_markdown_preserves_full_text_line() {
let src = "Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?\n";
let mut out = Vec::new();
append_markdown(src, None, None, &mut out);
assert_eq!(
out.len(),
1,
"expected a single rendered line for plain text"
);
let rendered: String = out
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("");
assert_eq!(
rendered,
"Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?"
);
}
#[test]
fn append_markdown_matches_tui_markdown_for_ordered_item() {
let mut out = Vec::new();
append_markdown(
"1. Tight item\n",
None,
None,
&mut out,
);
let lines = lines_to_strings(&out);
assert_eq!(lines, vec!["1. Tight item".to_string()]);
}
#[test]
fn append_markdown_keeps_ordered_list_line_unsplit_in_context() {
let src = "Loose vs. tight list items:\n1. Tight item\n";
let mut out = Vec::new();
append_markdown(src, None, None, &mut out);
let lines = lines_to_strings(&out);
assert!(
lines.iter().any(|s| s == "1. Tight item"),
"expected '1. Tight item' rendered as a single line; got: {lines:?}"
);
assert!(
!lines
.windows(2)
.any(|w| w[0].trim_end() == "1." && w[1] == "Tight item"),
"did not expect a split into ['1.', 'Tight item']; got: {lines:?}"
);
}
#[test]
fn append_markdown_agent_unwraps_markdown_fences_for_table_rendering() {
let src = "```markdown\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n";
let mut out = Vec::new();
append_markdown_agent(src, None, &mut out);
let rendered = lines_to_strings(&out);
assert!(rendered.iter().any(|line| line.contains('━')));
assert!(rendered.iter().any(|line| line.contains(" 1 2")));
}
#[test]
fn append_markdown_agent_unwraps_markdown_fences_for_no_outer_table_rendering() {
let src = "```md\nCol A | Col B | Col C\n--- | --- | ---\nx | y | z\n10 | 20 | 30\n```\n";
let mut out = Vec::new();
append_markdown_agent(src, None, &mut out);
let rendered = lines_to_strings(&out);
assert!(rendered.iter().any(|line| line.contains('━')));
assert!(
rendered
.iter()
.any(|line| line.contains(" Col A Col B Col C"))
);
assert!(
!rendered
.iter()
.any(|line| line.trim() == "Col A | Col B | Col C")
);
}
#[test]
fn append_markdown_agent_unwraps_markdown_fences_for_two_column_no_outer_table() {
let src = "```md\nA | B\n--- | ---\nleft | right\n```\n";
let mut out = Vec::new();
append_markdown_agent(src, None, &mut out);
let rendered = lines_to_strings(&out);
assert!(rendered.iter().any(|line| line.contains('━')));
assert!(rendered.iter().any(|line| line.contains(" left right")));
assert!(!rendered.iter().any(|line| line.trim() == "A | B"));
}
#[test]
fn append_markdown_agent_unwraps_markdown_fences_for_single_column_table() {
let src = "```md\n| Only |\n|---|\n| value |\n```\n";
let mut out = Vec::new();
append_markdown_agent(src, None, &mut out);
let rendered = lines_to_strings(&out);
assert!(rendered.iter().any(|line| line.contains('━')));
assert!(!rendered.iter().any(|line| line.trim() == "| Only |"));
}
#[test]
fn append_markdown_agent_keeps_non_markdown_fences_as_code() {
let src = "```rust\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n";
let mut out = Vec::new();
append_markdown_agent(src, None, &mut out);
let rendered = lines_to_strings(&out);
assert_eq!(
rendered,
vec![
"| A | B |".to_string(),
"|---|---|".to_string(),
"| 1 | 2 |".to_string(),
]
);
}
#[test]
fn append_markdown_agent_unwraps_blockquoted_markdown_fence_table() {
let src = "> ```markdown\n> | A | B |\n> |---|---|\n> | 1 | 2 |\n> ```\n";
let rendered = unwrap_markdown_fences(src);
assert!(
!rendered.contains("```"),
"expected markdown fence markers to be removed: {rendered:?}"
);
}
#[test]
fn append_markdown_agent_keeps_non_blockquoted_markdown_fence_with_blockquote_table_example() {
let src = "```markdown\n> | A | B |\n> |---|---|\n> | 1 | 2 |\n```\n";
let normalized = unwrap_markdown_fences(src);
assert_eq!(normalized, src);
}
#[test]
fn append_markdown_agent_keeps_markdown_fence_when_content_is_not_table() {
let src = "```markdown\n**bold**\n```\n";
let mut out = Vec::new();
append_markdown_agent(src, None, &mut out);
let rendered = lines_to_strings(&out);
assert_eq!(rendered, vec!["**bold**".to_string()]);
}
#[test]
fn unwrap_markdown_fences_repro_keeps_fence_without_header_delimiter_pair() {
let src = "```markdown\n| A | B |\nnot a delimiter row\n| --- | --- |\n# Heading\n```\n";
let normalized = unwrap_markdown_fences(src);
assert_eq!(normalized, src);
}
#[test]
fn append_markdown_agent_keeps_markdown_fence_with_blank_line_between_header_and_delimiter() {
let src = "```markdown\n| A | B |\n\n|---|---|\n| 1 | 2 |\n```\n";
let rendered = unwrap_markdown_fences(src);
assert_eq!(rendered, src);
}
}