mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
## What changed - Make `/copy` select the latest completed response directly in the owned transcript. Navigate user messages, code blocks, and blockquotes with arrow keys or `j`/`k`, use `g`/`G` to jump to the ends, and press Enter to copy or Escape to close. - Highlight selected blocks and keep surrounding context visible during navigation and resizing. Preserve source payloads for code and quotes, including rendered Mermaid diagrams and fenced Markdown tables. - Pause queued submissions until copy mode closes, and report clipboard results in the transcript footer. ## Testing Add tests for copy routing, keyboard navigation, highlighting and resize behavior, source whitespace and line endings, clipboard completion, and queued-input resumption. GitOrigin-RevId: bd4f58ec17568f95d0e91014e5ddba33ca8bd57c
770 lines
28 KiB
Rust
770 lines
28 KiB
Rust
//! Markdown-to-ratatui rendering entry points.
|
|
//!
|
|
//! This module provides the public API surface that the rest of the TUI uses
|
|
//! to turn markdown source into `Vec<Line<'static>>`. Two variants exist:
|
|
//!
|
|
//! - [`append_markdown`] -- general-purpose, used for plan blocks and history
|
|
//! cells that already hold pre-processed markdown (no fence unwrapping).
|
|
//! - [`append_markdown_agent`] -- for agent responses. Runs
|
|
//! [`normalize_markdown_for_rendering`] first so that `` ```md ``/`` ```markdown ``
|
|
//! fences containing tables are stripped and `pulldown-cmark` sees raw
|
|
//! table syntax instead of fenced code, when `tui.rendering.tables` is enabled.
|
|
//!
|
|
//! ## Why fence unwrapping exists
|
|
//!
|
|
//! LLM agents frequently wrap tables in `` ```markdown `` fences, treating
|
|
//! them as code. Without unwrapping, `pulldown-cmark` parses those lines
|
|
//! as a fenced code block and renders them as monospace code rather than a
|
|
//! structured table. The unwrapper is intentionally conservative: it
|
|
//! buffers the entire fence body before deciding, only unwraps fences whose
|
|
//! info string is `md` or `markdown` AND whose body contains a
|
|
//! header+delimiter pair, and degrades gracefully on unclosed fences.
|
|
use pulldown_cmark::CodeBlockKind;
|
|
use pulldown_cmark::Event;
|
|
use pulldown_cmark::Tag;
|
|
use pulldown_cmark::TagEnd;
|
|
use ratatui::text::Line;
|
|
use std::borrow::Cow;
|
|
use std::ops::Range;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use crate::inline_visualization::InlineVisualizationContext;
|
|
use crate::inline_visualization::rewrite_inline_visualizations;
|
|
use crate::markdown_render::ListSpacing;
|
|
use crate::table_detect;
|
|
use crate::terminal_hyperlinks::HyperlinkLine;
|
|
|
|
/// Render markdown source to styled ratatui lines and append them to `lines`.
|
|
///
|
|
/// Callers that already know the session working directory should pass it here so streamed and
|
|
/// non-streamed rendering show the same relative path text even if the process cwd differs.
|
|
pub(crate) fn append_markdown(
|
|
markdown_source: &str,
|
|
width: Option<usize>,
|
|
cwd: Option<&Path>,
|
|
lines: &mut Vec<Line<'static>>,
|
|
) {
|
|
let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd(
|
|
markdown_source,
|
|
width,
|
|
cwd,
|
|
);
|
|
crate::render::line_utils::push_owned_lines(&rendered.lines, lines);
|
|
}
|
|
|
|
/// Render an agent message to styled ratatui lines.
|
|
///
|
|
/// Before rendering, the source is passed through [`unwrap_markdown_fences`] so that tables
|
|
/// wrapped in `` ```md `` fences are rendered as native tables rather than code blocks.
|
|
/// Non-markdown fences (e.g. `rust`, `sh`) are left
|
|
/// intact.
|
|
#[cfg(test)]
|
|
pub(crate) fn append_markdown_agent(
|
|
markdown_source: &str,
|
|
width: Option<usize>,
|
|
lines: &mut Vec<Line<'static>>,
|
|
) {
|
|
let normalized = normalize_markdown_for_rendering(markdown_source);
|
|
let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd(
|
|
&normalized,
|
|
width,
|
|
/*cwd*/ None,
|
|
);
|
|
crate::render::line_utils::push_owned_lines(&rendered.lines, lines);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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_list_spacing(
|
|
markdown_source,
|
|
width,
|
|
cwd,
|
|
/*inline_visualization_context*/ None,
|
|
ListSpacing::AfterMultiline,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn render_markdown_agent_with_list_spacing(
|
|
markdown_source: &str,
|
|
width: Option<usize>,
|
|
cwd: Option<&Path>,
|
|
inline_visualization_context: Option<&InlineVisualizationContext>,
|
|
list_spacing: ListSpacing,
|
|
) -> Vec<HyperlinkLine> {
|
|
let rewritten = rewrite_inline_visualizations(markdown_source, inline_visualization_context);
|
|
let mut code_sources = Vec::new();
|
|
let normalized = if crate::markdown_render::preferences::current().tables {
|
|
unwrap_markdown_fences_with_sources(&rewritten.markdown, &mut code_sources)
|
|
} else {
|
|
Cow::Borrowed(rewritten.markdown.as_ref())
|
|
};
|
|
let is_hidden_link_destination = |destination: &str| {
|
|
rewritten.trusted_file_links.contains_key(destination)
|
|
|| crate::markdown_render::hide_web_link_destination(destination)
|
|
};
|
|
let mut lines = crate::markdown_render::render_with_copy_sources(
|
|
&normalized,
|
|
width,
|
|
cwd,
|
|
&is_hidden_link_destination,
|
|
list_spacing,
|
|
code_sources,
|
|
)
|
|
.lines;
|
|
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
|
|
}
|
|
|
|
/// Render an agent message and collect the block metadata needed for incremental rendering.
|
|
///
|
|
/// Block offsets are mapped back to `markdown_source` after Markdown table fences are unwrapped.
|
|
/// If a normalized boundary cannot be expressed as a raw-source suffix, it is discarded so the
|
|
/// transformed block remains mutable.
|
|
pub(crate) fn render_streaming_markdown_agent_with_links_and_cwd(
|
|
markdown_source: &str,
|
|
width: Option<usize>,
|
|
cwd: Option<&Path>,
|
|
list_spacing: ListSpacing,
|
|
) -> crate::markdown_render::StreamingMarkdownRender {
|
|
let normalized = normalize_markdown_for_rendering(markdown_source);
|
|
let mut rendered = crate::markdown_render::render_streaming_markdown_lines_with_width_and_cwd(
|
|
&normalized,
|
|
width,
|
|
cwd,
|
|
&crate::markdown_render::hide_web_link_destination,
|
|
list_spacing,
|
|
);
|
|
if normalized != markdown_source {
|
|
// Fence unwrapping removes opening/closing lines. A normalized tail that is still a raw
|
|
// suffix necessarily begins after those removed lines, so its boundary can safely be
|
|
// mapped back to the raw source; otherwise leave the transformed block mutable.
|
|
rendered.pending_math_start = rendered.pending_math_start.map(|boundary| {
|
|
markdown_source
|
|
.strip_suffix(&normalized[boundary..])
|
|
.map_or(0, str::len)
|
|
});
|
|
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.mutable_fence_start = rendered.mutable_fence_start.map(|boundary| {
|
|
markdown_source
|
|
.strip_suffix(&normalized[boundary..])
|
|
.map_or(0, str::len)
|
|
});
|
|
}
|
|
rendered
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) enum CopyTarget {
|
|
Code {
|
|
language: Option<String>,
|
|
content: Arc<str>,
|
|
},
|
|
Quote(Arc<str>),
|
|
}
|
|
|
|
pub(crate) fn extract_copy_targets(markdown_source: &str) -> Vec<CopyTarget> {
|
|
let mut targets = Vec::new();
|
|
let mut quote: Option<(Range<usize>, usize, bool)> = None;
|
|
let mut quote_depth = 0usize;
|
|
let mut in_code_block = false;
|
|
let mut code = None;
|
|
|
|
for (event, range) in pulldown_cmark::Parser::new(markdown_source).into_offset_iter() {
|
|
match event {
|
|
Event::Start(Tag::BlockQuote) => {
|
|
if quote_depth == 0 {
|
|
quote = Some((range, targets.len(), false));
|
|
}
|
|
quote_depth += 1;
|
|
}
|
|
Event::End(TagEnd::BlockQuote) => {
|
|
quote_depth = quote_depth.saturating_sub(1);
|
|
if quote_depth == 0 {
|
|
quote = None;
|
|
}
|
|
}
|
|
Event::Start(Tag::CodeBlock(kind)) => {
|
|
in_code_block = true;
|
|
if let CodeBlockKind::Fenced(info) = kind {
|
|
let language = info
|
|
.split([',', ' ', '\t'])
|
|
.next()
|
|
.filter(|language| !language.is_empty())
|
|
.map(str::to_owned);
|
|
code = Some((language, String::new()));
|
|
}
|
|
}
|
|
Event::Text(text) | Event::Code(text) => {
|
|
if in_code_block {
|
|
if let Some((_, content)) = &mut code {
|
|
if text.starts_with('\n')
|
|
&& range.start > 0
|
|
&& markdown_source.as_bytes()[range.start - 1] == b'\r'
|
|
{
|
|
content.push('\r');
|
|
}
|
|
content.push_str(&text);
|
|
}
|
|
} else if let Some((span, position, emitted)) = &mut quote
|
|
&& !*emitted
|
|
&& !crate::git_action_directives::strip_line_directives(&text)
|
|
.0
|
|
.trim()
|
|
.is_empty()
|
|
{
|
|
let content = markdown_source[span.clone()]
|
|
.split_inclusive('\n')
|
|
.map(|line| {
|
|
line.trim_start_matches(' ')
|
|
.strip_prefix('>')
|
|
.map_or(line, |line| line.strip_prefix(' ').unwrap_or(line))
|
|
})
|
|
.collect::<String>();
|
|
targets.insert(*position, CopyTarget::Quote(content.into()));
|
|
*emitted = true;
|
|
}
|
|
}
|
|
Event::End(TagEnd::CodeBlock) => {
|
|
in_code_block = false;
|
|
if let Some((language, content)) = code.take() {
|
|
targets.push(CopyTarget::Code {
|
|
language,
|
|
content: content.into(),
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
targets
|
|
}
|
|
|
|
/// Apply table-fence normalization only when terminal table layouts are enabled.
|
|
pub(crate) fn normalize_markdown_for_rendering(markdown_source: &str) -> Cow<'_, str> {
|
|
if crate::markdown_render::preferences::current().tables {
|
|
unwrap_markdown_fences(markdown_source)
|
|
} else {
|
|
Cow::Borrowed(markdown_source)
|
|
}
|
|
}
|
|
|
|
/// Strip `` ```md ``/`` ```markdown `` fences that contain tables, emitting their content as bare
|
|
/// markdown so `pulldown-cmark` parses the tables natively.
|
|
///
|
|
/// Fences whose info string is not `md` or `markdown` are passed through unchanged. Markdown
|
|
/// fences that do *not* contain a table (detected by checking for a header row + delimiter row)
|
|
/// are also passed through so that non-table markdown inside a fence still renders as a code
|
|
/// block.
|
|
///
|
|
/// The fence unwrapping is intentionally conservative: it buffers the entire fence body before
|
|
/// deciding, and an unclosed fence at end-of-input is re-emitted with its opening line so partial
|
|
/// streams degrade to code display.
|
|
pub(crate) fn unwrap_markdown_fences(markdown_source: &str) -> Cow<'_, str> {
|
|
unwrap_markdown_fences_with_sources(markdown_source, &mut Vec::new())
|
|
}
|
|
|
|
fn unwrap_markdown_fences_with_sources<'a>(
|
|
markdown_source: &'a str,
|
|
sources: &mut Vec<crate::markdown_copy::SourceBlock>,
|
|
) -> Cow<'a, str> {
|
|
// Zero-copy fast path: most messages contain no fences at all.
|
|
if !markdown_source.contains("```") && !markdown_source.contains("~~~") {
|
|
return Cow::Borrowed(markdown_source);
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct Fence {
|
|
marker: u8,
|
|
len: usize,
|
|
is_blockquoted: bool,
|
|
}
|
|
|
|
// Strip a trailing newline and up to 3 leading spaces, returning the
|
|
// trimmed slice. Returns `None` when the line has 4+ leading spaces
|
|
// (which makes it an indented code line per CommonMark).
|
|
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..])
|
|
}
|
|
|
|
// Parse an opening fence line, returning the fence metadata and whether
|
|
// the fence info string indicates markdown content.
|
|
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>>,
|
|
}
|
|
|
|
// Box the large variant to keep ActiveFence small (~pointer-sized).
|
|
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 push_source_range = |out: &mut String, 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(&mut out, 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,
|
|
) {
|
|
let start = out.len();
|
|
for range in data.content_ranges {
|
|
push_source_range(&mut out, range);
|
|
}
|
|
let fenced = &markdown_source[data.opening_range.start..line_range.end];
|
|
if let Some(CopyTarget::Code { content, .. }) =
|
|
extract_copy_targets(fenced)
|
|
.into_iter()
|
|
.find(|target| matches!(target, CopyTarget::Code { .. }))
|
|
{
|
|
sources.push(crate::markdown_copy::SourceBlock {
|
|
range: start..out.len(),
|
|
content,
|
|
});
|
|
}
|
|
} else {
|
|
push_source_range(&mut out, data.opening_range);
|
|
for range in data.content_ranges {
|
|
push_source_range(&mut out, range);
|
|
}
|
|
push_source_range(&mut out, 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(&mut out, line_range);
|
|
active_fence = Some(ActiveFence::Passthrough(fence));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
push_source_range(&mut out, line_range);
|
|
}
|
|
|
|
if let Some(active) = active_fence {
|
|
match active {
|
|
ActiveFence::Passthrough(_) => {}
|
|
ActiveFence::MarkdownCandidate(data) => {
|
|
push_source_range(&mut out, data.opening_range);
|
|
for range in data.content_ranges {
|
|
push_source_range(&mut out, range);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Cow::Owned(out)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
use ratatui::text::Line;
|
|
|
|
#[test]
|
|
fn copy_targets_preserve_fenced_code_language_and_exact_content() {
|
|
let markdown =
|
|
"```python title=example\nprint('hi')\n```\n\n ignored()\n\n~~~\nplain()\n~~~";
|
|
assert_eq!(
|
|
extract_copy_targets(markdown),
|
|
vec![
|
|
CopyTarget::Code {
|
|
language: Some("python".to_string()),
|
|
content: Arc::from("print('hi')\n"),
|
|
},
|
|
CopyTarget::Code {
|
|
language: None,
|
|
content: Arc::from("plain()\n"),
|
|
},
|
|
]
|
|
);
|
|
assert_eq!(
|
|
extract_copy_targets("```powershell\r\nGet-Item .\r\nWrite-Output ok\r\n```\r\n"),
|
|
vec![CopyTarget::Code {
|
|
language: Some("powershell".to_string()),
|
|
content: Arc::from("Get-Item .\r\nWrite-Output ok\r\n"),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_targets_preserve_nested_quote_markdown_and_source_order() {
|
|
let markdown = "> outer **bold**\n> > inner *quote*\n> ```sh\n> nested()\n> ```\n";
|
|
assert_eq!(
|
|
extract_copy_targets(markdown),
|
|
vec![
|
|
CopyTarget::Quote(Arc::from(
|
|
"outer **bold**\n> inner *quote*\n```sh\nnested()\n```\n"
|
|
)),
|
|
CopyTarget::Code {
|
|
language: Some("sh".to_string()),
|
|
content: Arc::from("nested()\n"),
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_targets_exclude_blockquotes_without_prose() {
|
|
let markdown = concat!(
|
|
"> > ::git-stage{cwd=\"/repo\"}\n\n",
|
|
"> ::git-stage{cwd=\"/repo\"}\n> ```sh\n> code()\n> ```\n\n",
|
|
"> quoted prose\n"
|
|
);
|
|
assert_eq!(
|
|
extract_copy_targets(markdown),
|
|
vec![
|
|
CopyTarget::Code {
|
|
language: Some("sh".to_string()),
|
|
content: Arc::from("code()\n"),
|
|
},
|
|
CopyTarget::Quote(Arc::from("quoted prose\n")),
|
|
]
|
|
);
|
|
}
|
|
|
|
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, /*width*/ None, /*cwd*/ None, &mut out);
|
|
let rendered = lines_to_strings(&out);
|
|
assert_eq!(
|
|
rendered,
|
|
vec![
|
|
"Before 【F:/x.rs†L1】".to_string(),
|
|
"After 【F:/x.rs†L3】".to_string()
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn indented_code_blocks_preserve_leading_whitespace() {
|
|
// Basic sanity: indented code with surrounding blank lines should produce the indented line.
|
|
let src = "Before\n\n code 1\n\nAfter\n";
|
|
let mut out = Vec::new();
|
|
append_markdown(src, /*width*/ None, /*cwd*/ 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, /*width*/ None, /*cwd*/ 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",
|
|
/*width*/ None,
|
|
/*cwd*/ 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, /*width*/ None, /*cwd*/ None, &mut out);
|
|
|
|
let lines = lines_to_strings(&out);
|
|
|
|
// Expect to find the ordered list line rendered as a single line,
|
|
// not split into a marker-only line followed by the text.
|
|
assert!(
|
|
lines.iter().any(|s| s == "1. Tight item"),
|
|
"expected '1. Tight item' rendered as a single line; got: {lines:?}"
|
|
);
|
|
assert!(
|
|
!lines
|
|
.windows(2)
|
|
.any(|w| w[0].trim_end() == "1." && w[1] == "Tight item"),
|
|
"did not expect a split into ['1.', 'Tight item']; got: {lines:?}"
|
|
);
|
|
}
|
|
|
|
#[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, /*width*/ 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, /*width*/ 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, /*width*/ 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, /*width*/ 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, /*width*/ 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, /*width*/ 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);
|
|
}
|
|
}
|