render local file links from target paths (#13857)

Co-authored-by: Josh McKinney <joshka@openai.com>
This commit is contained in:
pash-openai 2026-03-10 18:00:48 -07:00 committed by Michael Bolin
parent c8446d7cf3
commit da74da6684
9 changed files with 619 additions and 160 deletions

View file

@ -1468,6 +1468,7 @@ impl ChatWidget {
if self.plan_stream_controller.is_none() {
self.plan_stream_controller = Some(PlanStreamController::new(
self.last_rendered_width.get().map(|w| w.saturating_sub(4)),
&self.config.cwd,
));
}
if let Some(controller) = self.plan_stream_controller.as_mut()
@ -1506,7 +1507,7 @@ impl ChatWidget {
// TODO: Replace streamed output with the final plan item text if plan streaming is
// removed or if we need to reconcile mismatches between streamed and final content.
} else if !plan_text.is_empty() {
self.add_to_history(history_cell::new_proposed_plan(plan_text));
self.add_to_history(history_cell::new_proposed_plan(plan_text, &self.config.cwd));
}
if should_restore_after_stream {
self.pending_status_indicator_restore = true;
@ -1539,8 +1540,10 @@ impl ChatWidget {
// At the end of a reasoning block, record transcript-only content.
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
if !self.full_reasoning_buffer.is_empty() {
let cell =
history_cell::new_reasoning_summary_block(self.full_reasoning_buffer.clone());
let cell = history_cell::new_reasoning_summary_block(
self.full_reasoning_buffer.clone(),
&self.config.cwd,
);
self.add_boxed_history(cell);
}
self.reasoning_buffer.clear();
@ -2780,6 +2783,7 @@ impl ChatWidget {
}
self.stream_controller = Some(StreamController::new(
self.last_rendered_width.get().map(|w| w.saturating_sub(2)),
&self.config.cwd,
));
}
if let Some(controller) = self.stream_controller.as_mut()
@ -5156,7 +5160,12 @@ impl ChatWidget {
} else {
// Show explanation when there are no structured findings.
let mut rendered: Vec<ratatui::text::Line<'static>> = vec!["".into()];
append_markdown(&explanation, None, &mut rendered);
append_markdown(
&explanation,
None,
Some(self.config.cwd.as_path()),
&mut rendered,
);
let body_cell = AgentMessageCell::new(rendered, false);
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(body_cell)));

View file

@ -375,14 +375,19 @@ impl HistoryCell for UserHistoryCell {
pub(crate) struct ReasoningSummaryCell {
_header: String,
content: String,
/// Session cwd used to render local file links inside the reasoning body.
cwd: PathBuf,
transcript_only: bool,
}
impl ReasoningSummaryCell {
pub(crate) fn new(header: String, content: String, transcript_only: bool) -> Self {
/// Create a reasoning summary cell that will render local file links relative to the session
/// cwd active when the summary was recorded.
pub(crate) fn new(header: String, content: String, cwd: &Path, transcript_only: bool) -> Self {
Self {
_header: header,
content,
cwd: cwd.to_path_buf(),
transcript_only,
}
}
@ -392,6 +397,7 @@ impl ReasoningSummaryCell {
append_markdown(
&self.content,
Some((width as usize).saturating_sub(2)),
Some(self.cwd.as_path()),
&mut lines,
);
let summary_style = Style::default().dim().italic();
@ -997,11 +1003,15 @@ pub(crate) fn padded_emoji(emoji: &str) -> String {
#[derive(Debug)]
struct TooltipHistoryCell {
tip: String,
cwd: PathBuf,
}
impl TooltipHistoryCell {
fn new(tip: String) -> Self {
Self { tip }
fn new(tip: String, cwd: &Path) -> Self {
Self {
tip,
cwd: cwd.to_path_buf(),
}
}
}
@ -1016,6 +1026,7 @@ impl HistoryCell for TooltipHistoryCell {
append_markdown(
&format!("**Tip:** {}", self.tip),
Some(wrap_width),
Some(self.cwd.as_path()),
&mut lines,
);
@ -1108,7 +1119,7 @@ pub(crate) fn new_session_info(
matches!(config.service_tier, Some(ServiceTier::Fast)),
)
})
.map(TooltipHistoryCell::new)
.map(|tip| TooltipHistoryCell::new(tip, &config.cwd))
{
parts.push(Box::new(tooltips));
}
@ -2046,8 +2057,12 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
PlanUpdateCell { explanation, plan }
}
pub(crate) fn new_proposed_plan(plan_markdown: String) -> ProposedPlanCell {
ProposedPlanCell { plan_markdown }
/// Create a proposed-plan cell that snapshots the session cwd for later markdown rendering.
pub(crate) fn new_proposed_plan(plan_markdown: String, cwd: &Path) -> ProposedPlanCell {
ProposedPlanCell {
plan_markdown,
cwd: cwd.to_path_buf(),
}
}
pub(crate) fn new_proposed_plan_stream(
@ -2063,6 +2078,8 @@ pub(crate) fn new_proposed_plan_stream(
#[derive(Debug)]
pub(crate) struct ProposedPlanCell {
plan_markdown: String,
/// Session cwd used to keep local file-link display aligned with live streamed plan rendering.
cwd: PathBuf,
}
#[derive(Debug)]
@ -2081,7 +2098,12 @@ impl HistoryCell for ProposedPlanCell {
let plan_style = proposed_plan_style();
let wrap_width = width.saturating_sub(4).max(1) as usize;
let mut body: Vec<Line<'static>> = Vec::new();
append_markdown(&self.plan_markdown, Some(wrap_width), &mut body);
append_markdown(
&self.plan_markdown,
Some(wrap_width),
Some(self.cwd.as_path()),
&mut body,
);
if body.is_empty() {
body.push(Line::from("(empty)".dim().italic()));
}
@ -2231,7 +2253,15 @@ pub(crate) fn new_image_generation_call(
PlainHistoryCell { lines }
}
pub(crate) fn new_reasoning_summary_block(full_reasoning_buffer: String) -> Box<dyn HistoryCell> {
/// Create the reasoning history cell emitted at the end of a reasoning block.
///
/// The helper snapshots `cwd` into the returned cell so local file links render the same way they
/// did while the turn was live, even if rendering happens after other app state has advanced.
pub(crate) fn new_reasoning_summary_block(
full_reasoning_buffer: String,
cwd: &Path,
) -> Box<dyn HistoryCell> {
let cwd = cwd.to_path_buf();
let full_reasoning_buffer = full_reasoning_buffer.trim();
if let Some(open) = full_reasoning_buffer.find("**") {
let after_open = &full_reasoning_buffer[(open + 2)..];
@ -2242,9 +2272,12 @@ pub(crate) fn new_reasoning_summary_block(full_reasoning_buffer: String) -> Box<
if after_close_idx < full_reasoning_buffer.len() {
let header_buffer = full_reasoning_buffer[..after_close_idx].to_string();
let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string();
// Preserve the session cwd so local file links render the same way in the
// collapsed reasoning block as they did while streaming live content.
return Box::new(ReasoningSummaryCell::new(
header_buffer,
summary_buffer,
&cwd,
false,
));
}
@ -2253,6 +2286,7 @@ pub(crate) fn new_reasoning_summary_block(full_reasoning_buffer: String) -> Box<
Box::new(ReasoningSummaryCell::new(
"".to_string(),
full_reasoning_buffer.to_string(),
&cwd,
true,
))
}
@ -2468,6 +2502,12 @@ mod tests {
.expect("config")
}
fn test_cwd() -> PathBuf {
// These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or
// Windows-specific root semantics into the fixtures.
std::env::temp_dir()
}
fn render_lines(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
@ -3999,6 +4039,7 @@ mod tests {
fn reasoning_summary_block() {
let cell = new_reasoning_summary_block(
"**High level reasoning**\n\nDetailed reasoning goes here.".to_string(),
&test_cwd(),
);
let rendered_display = render_lines(&cell.display_lines(80));
@ -4014,6 +4055,7 @@ mod tests {
let cell: Box<dyn HistoryCell> = Box::new(ReasoningSummaryCell::new(
"High level reasoning".to_string(),
summary.to_string(),
&test_cwd(),
false,
));
let width: u16 = 24;
@ -4054,7 +4096,8 @@ mod tests {
#[test]
fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() {
let cell = new_reasoning_summary_block("Detailed reasoning goes here.".to_string());
let cell =
new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &test_cwd());
let rendered = render_transcript(cell.as_ref());
assert_eq!(rendered, vec!["• Detailed reasoning goes here."]);
@ -4067,6 +4110,7 @@ mod tests {
config.model_supports_reasoning_summaries = Some(true);
let cell = new_reasoning_summary_block(
"**High level reasoning**\n\nDetailed reasoning goes here.".to_string(),
&test_cwd(),
);
let rendered_display = render_lines(&cell.display_lines(80));
@ -4075,8 +4119,10 @@ mod tests {
#[test]
fn reasoning_summary_block_falls_back_when_header_is_missing() {
let cell =
new_reasoning_summary_block("**High level reasoning without closing".to_string());
let cell = new_reasoning_summary_block(
"**High level reasoning without closing".to_string(),
&test_cwd(),
);
let rendered = render_transcript(cell.as_ref());
assert_eq!(rendered, vec!["• **High level reasoning without closing"]);
@ -4084,14 +4130,17 @@ mod tests {
#[test]
fn reasoning_summary_block_falls_back_when_summary_is_missing() {
let cell =
new_reasoning_summary_block("**High level reasoning without closing**".to_string());
let cell = new_reasoning_summary_block(
"**High level reasoning without closing**".to_string(),
&test_cwd(),
);
let rendered = render_transcript(cell.as_ref());
assert_eq!(rendered, vec!["• High level reasoning without closing"]);
let cell = new_reasoning_summary_block(
"**High level reasoning without closing**\n\n ".to_string(),
&test_cwd(),
);
let rendered = render_transcript(cell.as_ref());
@ -4102,6 +4151,7 @@ mod tests {
fn reasoning_summary_block_splits_header_and_summary_when_present() {
let cell = new_reasoning_summary_block(
"**High level plan**\n\nWe should fix the bug next.".to_string(),
&test_cwd(),
);
let rendered_display = render_lines(&cell.display_lines(80));

View file

@ -1,10 +1,21 @@
use ratatui::text::Line;
use std::path::Path;
/// Render markdown into `lines` while resolving local file-link display relative to `cwd`.
///
/// 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(markdown_source, width);
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);
}
@ -30,7 +41,7 @@ mod tests {
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, &mut out);
append_markdown(src, None, None, &mut out);
let rendered = lines_to_strings(&out);
assert_eq!(
rendered,
@ -46,7 +57,7 @@ mod tests {
// 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, None, &mut out);
append_markdown(src, None, None, &mut out);
let lines = lines_to_strings(&out);
assert_eq!(lines, vec!["Before", "", " code 1", "", "After"]);
}
@ -55,7 +66,7 @@ mod tests {
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, &mut out);
append_markdown(src, None, None, &mut out);
assert_eq!(
out.len(),
1,
@ -76,7 +87,7 @@ mod tests {
#[test]
fn append_markdown_matches_tui_markdown_for_ordered_item() {
let mut out = Vec::new();
append_markdown("1. Tight item\n", None, &mut out);
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()]);
}
@ -85,7 +96,7 @@ mod tests {
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, &mut out);
append_markdown(src, None, None, &mut out);
let lines = lines_to_strings(&out);

View file

@ -1,8 +1,16 @@
//! Markdown rendering for the TUI transcript.
//!
//! This renderer intentionally treats local file links differently from normal web links. For
//! local paths, the displayed text comes from the destination, not the markdown label, so
//! transcripts show the real file target (including normalized location suffixes) and can shorten
//! absolute paths relative to a known working directory.
use crate::render::highlight::highlight_code_to_lines;
use crate::render::line_utils::line_to_static;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
use codex_utils_string::normalize_markdown_hash_location_suffix;
use dirs::home_dir;
use pulldown_cmark::CodeBlockKind;
use pulldown_cmark::CowStr;
use pulldown_cmark::Event;
@ -16,7 +24,10 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use regex_lite::Regex;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
use url::Url;
struct MarkdownStyles {
h1: Style,
@ -79,11 +90,26 @@ pub fn render_markdown_text(input: &str) -> Text<'static> {
render_markdown_text_with_width(input, None)
}
/// Render markdown using the current process working directory for local file-link display.
pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>) -> Text<'static> {
let cwd = std::env::current_dir().ok();
render_markdown_text_with_width_and_cwd(input, width, cwd.as_deref())
}
/// Render markdown with an explicit working directory for local file links.
///
/// The `cwd` parameter controls how absolute local targets are shortened before display. Passing
/// the session cwd keeps full renders, history cells, and streamed deltas visually aligned even
/// when rendering happens away from the process cwd.
pub(crate) fn render_markdown_text_with_width_and_cwd(
input: &str,
width: Option<usize>,
cwd: Option<&Path>,
) -> Text<'static> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, options);
let mut w = Writer::new(parser, width);
let mut w = Writer::new(parser, width, cwd);
w.run();
w.text
}
@ -92,9 +118,11 @@ pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>)
struct LinkState {
destination: String,
show_destination: bool,
hidden_location_suffix: Option<String>,
label_start_span_idx: usize,
label_styled: bool,
/// Pre-rendered display text for local file links.
///
/// When this is present, the markdown label is intentionally suppressed so the rendered
/// transcript always reflects the real target path.
local_target_display: Option<String>,
}
fn should_render_link_destination(dest_url: &str) -> bool {
@ -116,20 +144,6 @@ static HASH_LOCATION_SUFFIX_RE: LazyLock<Regex> =
Err(error) => panic!("invalid hash location regex: {error}"),
});
fn is_local_path_like_link(dest_url: &str) -> bool {
dest_url.starts_with("file://")
|| dest_url.starts_with('/')
|| dest_url.starts_with("~/")
|| dest_url.starts_with("./")
|| dest_url.starts_with("../")
|| dest_url.starts_with("\\\\")
|| matches!(
dest_url.as_bytes(),
[drive, b':', separator, ..]
if drive.is_ascii_alphabetic() && matches!(separator, b'/' | b'\\')
)
}
struct Writer<'a, I>
where
I: Iterator<Item = Event<'a>>,
@ -148,6 +162,9 @@ where
code_block_lang: Option<String>,
code_block_buffer: String,
wrap_width: Option<usize>,
cwd: Option<PathBuf>,
line_ends_with_local_link_target: bool,
pending_local_link_soft_break: bool,
current_line_content: Option<Line<'static>>,
current_initial_indent: Vec<Span<'static>>,
current_subsequent_indent: Vec<Span<'static>>,
@ -159,7 +176,7 @@ impl<'a, I> Writer<'a, I>
where
I: Iterator<Item = Event<'a>>,
{
fn new(iter: I, wrap_width: Option<usize>) -> Self {
fn new(iter: I, wrap_width: Option<usize>, cwd: Option<&Path>) -> Self {
Self {
iter,
text: Text::default(),
@ -175,6 +192,9 @@ where
code_block_lang: None,
code_block_buffer: String::new(),
wrap_width,
cwd: cwd.map(Path::to_path_buf),
line_ends_with_local_link_target: false,
pending_local_link_soft_break: false,
current_line_content: None,
current_initial_indent: Vec::new(),
current_subsequent_indent: Vec::new(),
@ -191,6 +211,7 @@ where
}
fn handle_event(&mut self, event: Event<'a>) {
self.prepare_for_event(&event);
match event {
Event::Start(tag) => self.start_tag(tag),
Event::End(tag) => self.end_tag(tag),
@ -213,6 +234,23 @@ where
}
}
fn prepare_for_event(&mut self, event: &Event<'a>) {
if !self.pending_local_link_soft_break {
return;
}
// Local file links render from the destination at `TagEnd::Link`, so a Markdown soft break
// immediately before a descriptive `: ...` should stay inline instead of splitting the
// list item across two lines.
if matches!(event, Event::Text(text) if text.trim_start().starts_with(':')) {
self.pending_local_link_soft_break = false;
return;
}
self.pending_local_link_soft_break = false;
self.push_line(Line::default());
}
fn start_tag(&mut self, tag: Tag<'a>) {
match tag {
Tag::Paragraph => self.start_paragraph(),
@ -324,6 +362,10 @@ where
}
fn text(&mut self, text: CowStr<'a>) {
if self.suppressing_local_link_label() {
return;
}
self.line_ends_with_local_link_target = false;
if self.pending_marker_line {
self.push_line(Line::default());
}
@ -373,6 +415,10 @@ where
}
fn code(&mut self, code: CowStr<'a>) {
if self.suppressing_local_link_label() {
return;
}
self.line_ends_with_local_link_target = false;
if self.pending_marker_line {
self.push_line(Line::default());
self.pending_marker_line = false;
@ -382,6 +428,10 @@ where
}
fn html(&mut self, html: CowStr<'a>, inline: bool) {
if self.suppressing_local_link_label() {
return;
}
self.line_ends_with_local_link_target = false;
self.pending_marker_line = false;
for (i, line) in html.lines().enumerate() {
if self.needs_newline {
@ -398,10 +448,23 @@ where
}
fn hard_break(&mut self) {
if self.suppressing_local_link_label() {
return;
}
self.line_ends_with_local_link_target = false;
self.push_line(Line::default());
}
fn soft_break(&mut self) {
if self.suppressing_local_link_label() {
return;
}
if self.line_ends_with_local_link_target {
self.pending_local_link_soft_break = true;
self.line_ends_with_local_link_target = false;
return;
}
self.line_ends_with_local_link_target = false;
self.push_line(Line::default());
}
@ -513,36 +576,13 @@ where
fn push_link(&mut self, dest_url: String) {
let show_destination = should_render_link_destination(&dest_url);
let label_styled = !show_destination;
let label_start_span_idx = self
.current_line_content
.as_ref()
.map(|line| line.spans.len())
.unwrap_or(0);
if label_styled {
self.push_inline_style(self.styles.code);
}
self.link = Some(LinkState {
show_destination,
hidden_location_suffix: if is_local_path_like_link(&dest_url) {
dest_url
.rsplit_once('#')
.and_then(|(_, fragment)| {
HASH_LOCATION_SUFFIX_RE
.is_match(fragment)
.then(|| format!("#{fragment}"))
})
.and_then(|suffix| normalize_markdown_hash_location_suffix(&suffix))
.or_else(|| {
COLON_LOCATION_SUFFIX_RE
.find(&dest_url)
.map(|m| m.as_str().to_string())
})
local_target_display: if is_local_path_like_link(&dest_url) {
render_local_link_target(&dest_url, self.cwd.as_deref())
} else {
None
},
label_start_span_idx,
label_styled,
destination: dest_url,
});
}
@ -550,43 +590,34 @@ where
fn pop_link(&mut self) {
if let Some(link) = self.link.take() {
if link.show_destination {
if link.label_styled {
self.pop_inline_style();
}
self.push_span(" (".into());
self.push_span(Span::styled(link.destination, self.styles.link));
self.push_span(")".into());
} else if let Some(location_suffix) = link.hidden_location_suffix.as_deref() {
let label_text = self
.current_line_content
.as_ref()
.and_then(|line| {
line.spans.get(link.label_start_span_idx..).map(|spans| {
spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
})
.unwrap_or_default();
if label_text
.rsplit_once('#')
.is_some_and(|(_, fragment)| HASH_LOCATION_SUFFIX_RE.is_match(fragment))
|| COLON_LOCATION_SUFFIX_RE.find(&label_text).is_some()
{
// The label already carries a location suffix; don't duplicate it.
} else {
self.push_span(Span::styled(location_suffix.to_string(), self.styles.code));
} else if let Some(local_target_display) = link.local_target_display {
if self.pending_marker_line {
self.push_line(Line::default());
}
if link.label_styled {
self.pop_inline_style();
}
} else if link.label_styled {
self.pop_inline_style();
// Local file links are rendered as code-like path text so the transcript shows the
// resolved target instead of arbitrary caller-provided label text.
let style = self
.inline_styles
.last()
.copied()
.unwrap_or_default()
.patch(self.styles.code);
self.push_span(Span::styled(local_target_display, style));
self.line_ends_with_local_link_target = true;
}
}
}
fn suppressing_local_link_label(&self) -> bool {
self.link
.as_ref()
.and_then(|link| link.local_target_display.as_ref())
.is_some()
}
fn flush_current_line(&mut self) {
if let Some(line) = self.current_line_content.take() {
let style = self.current_line_style;
@ -610,6 +641,7 @@ where
self.current_initial_indent.clear();
self.current_subsequent_indent.clear();
self.current_line_in_code_block = false;
self.line_ends_with_local_link_target = false;
}
}
@ -631,6 +663,7 @@ where
self.current_line_style = style;
self.current_line_content = Some(line);
self.current_line_in_code_block = self.in_code_block;
self.line_ends_with_local_link_target = false;
self.pending_marker_line = false;
}
@ -687,6 +720,223 @@ where
}
}
fn is_local_path_like_link(dest_url: &str) -> bool {
dest_url.starts_with("file://")
|| dest_url.starts_with('/')
|| dest_url.starts_with("~/")
|| dest_url.starts_with("./")
|| dest_url.starts_with("../")
|| dest_url.starts_with("\\\\")
|| matches!(
dest_url.as_bytes(),
[drive, b':', separator, ..]
if drive.is_ascii_alphabetic() && matches!(separator, b'/' | b'\\')
)
}
/// Parse a local link target into normalized path text plus an optional location suffix.
///
/// This accepts the path shapes Codex emits today: `file://` URLs, absolute and relative paths,
/// `~/...`, Windows paths, and `#L..C..` or `:line:col` suffixes.
fn render_local_link_target(dest_url: &str, cwd: Option<&Path>) -> Option<String> {
let (path_text, location_suffix) = parse_local_link_target(dest_url)?;
let mut rendered = display_local_link_path(&path_text, cwd);
if let Some(location_suffix) = location_suffix {
rendered.push_str(&location_suffix);
}
Some(rendered)
}
/// Split a local-link destination into `(normalized_path_text, location_suffix)`.
///
/// The returned path text never includes a trailing `#L..` or `:line[:col]` suffix. Path
/// normalization expands `~/...` when possible and rewrites path separators into display-stable
/// forward slashes. The suffix, when present, is returned separately in normalized markdown form.
///
/// Returns `None` only when the destination looks like a `file://` URL but cannot be parsed into a
/// local path. Plain path-like inputs always return `Some(...)` even if they are relative.
fn parse_local_link_target(dest_url: &str) -> Option<(String, Option<String>)> {
if dest_url.starts_with("file://") {
let url = Url::parse(dest_url).ok()?;
let path_text = file_url_to_local_path_text(&url)?;
let location_suffix = url
.fragment()
.and_then(normalize_hash_location_suffix_fragment);
return Some((path_text, location_suffix));
}
let mut path_text = dest_url;
let mut location_suffix = None;
// Prefer `#L..` style fragments when both forms are present so URLs like `path#L10` do not
// get misparsed as a plain path ending in `:10`.
if let Some((candidate_path, fragment)) = dest_url.rsplit_once('#')
&& let Some(normalized) = normalize_hash_location_suffix_fragment(fragment)
{
path_text = candidate_path;
location_suffix = Some(normalized);
}
if location_suffix.is_none()
&& let Some(suffix) = extract_colon_location_suffix(path_text)
{
let path_len = path_text.len().saturating_sub(suffix.len());
path_text = &path_text[..path_len];
location_suffix = Some(suffix);
}
Some((expand_local_link_path(path_text), location_suffix))
}
/// Normalize a hash fragment like `L12` or `L12C3-L14C9` into the display suffix we render.
///
/// Returns `None` for fragments that are not location references. This deliberately ignores other
/// `#...` fragments so non-location hashes stay part of the path text.
fn normalize_hash_location_suffix_fragment(fragment: &str) -> Option<String> {
HASH_LOCATION_SUFFIX_RE
.is_match(fragment)
.then(|| format!("#{fragment}"))
.and_then(|suffix| normalize_markdown_hash_location_suffix(&suffix))
}
/// Extract a trailing `:line`, `:line:col`, or range suffix from a plain path-like string.
///
/// The suffix must occur at the end of the input; embedded colons elsewhere in the path are left
/// alone. This is what keeps Windows drive letters like `C:/...` from being misread as locations.
fn extract_colon_location_suffix(path_text: &str) -> Option<String> {
COLON_LOCATION_SUFFIX_RE
.find(path_text)
.filter(|matched| matched.end() == path_text.len())
.map(|matched| matched.as_str().to_string())
}
/// Expand home-relative paths and normalize separators for display.
///
/// If `~/...` cannot be expanded because the home directory is unavailable, the original text still
/// goes through separator normalization and is returned as-is otherwise.
fn expand_local_link_path(path_text: &str) -> String {
// Expand `~/...` eagerly so home-relative links can participate in the same normalization and
// cwd-relative shortening path as absolute links.
if let Some(rest) = path_text.strip_prefix("~/")
&& let Some(home) = home_dir()
{
return normalize_local_link_path_text(&home.join(rest).to_string_lossy());
}
normalize_local_link_path_text(path_text)
}
/// Convert a `file://` URL into the normalized local-path text used for transcript rendering.
///
/// This prefers `Url::to_file_path()` for standard file URLs. When that rejects Windows-oriented
/// encodings, we reconstruct a display path from the host/path parts so UNC paths and drive-letter
/// URLs still render sensibly.
fn file_url_to_local_path_text(url: &Url) -> Option<String> {
if let Ok(path) = url.to_file_path() {
return Some(normalize_local_link_path_text(&path.to_string_lossy()));
}
// Fall back to string reconstruction for cases `to_file_path()` rejects, especially UNC-style
// hosts and Windows drive paths encoded in URL form.
let mut path_text = url.path().to_string();
if let Some(host) = url.host_str()
&& !host.is_empty()
&& host != "localhost"
{
path_text = format!("//{host}{path_text}");
} else if matches!(
path_text.as_bytes(),
[b'/', drive, b':', b'/', ..] if drive.is_ascii_alphabetic()
) {
path_text.remove(0);
}
Some(normalize_local_link_path_text(&path_text))
}
/// Normalize local-path text into the transcript display form.
///
/// Display normalization is intentionally lexical: it does not touch the filesystem, resolve
/// symlinks, or collapse `.` / `..`. It only converts separators to forward slashes and rewrites
/// UNC-style `\\\\server\\share` inputs into `//server/share` so later prefix checks operate on a
/// stable representation.
fn normalize_local_link_path_text(path_text: &str) -> String {
// Render all local link paths with forward slashes so display and prefix stripping are stable
// across mixed Windows and Unix-style inputs.
if let Some(rest) = path_text.strip_prefix("\\\\") {
format!("//{}", rest.replace('\\', "/").trim_start_matches('/'))
} else {
path_text.replace('\\', "/")
}
}
fn is_absolute_local_link_path(path_text: &str) -> bool {
path_text.starts_with('/')
|| path_text.starts_with("//")
|| matches!(
path_text.as_bytes(),
[drive, b':', b'/', ..] if drive.is_ascii_alphabetic()
)
}
/// Remove trailing separators from a local path without destroying root semantics.
///
/// Roots like `/`, `//`, and `C:/` stay intact so callers can still distinguish "the root itself"
/// from "a path under the root".
fn trim_trailing_local_path_separator(path_text: &str) -> &str {
if path_text == "/" || path_text == "//" {
return path_text;
}
if matches!(path_text.as_bytes(), [drive, b':', b'/'] if drive.is_ascii_alphabetic()) {
return path_text;
}
path_text.trim_end_matches('/')
}
/// Strip `cwd_text` from the start of `path_text` when `path_text` is strictly underneath it.
///
/// Returns the relative remainder without a leading slash. If the path equals the cwd exactly, this
/// returns `None` so callers can keep rendering the full path instead of collapsing it to an empty
/// string.
fn strip_local_path_prefix<'a>(path_text: &'a str, cwd_text: &str) -> Option<&'a str> {
let path_text = trim_trailing_local_path_separator(path_text);
let cwd_text = trim_trailing_local_path_separator(cwd_text);
if path_text == cwd_text {
return None;
}
// Treat filesystem roots specially so `/tmp/x` under `/` becomes `tmp/x` instead of being
// left unchanged by the generic prefix-stripping branch.
if cwd_text == "/" || cwd_text == "//" {
return path_text.strip_prefix('/');
}
path_text
.strip_prefix(cwd_text)
.and_then(|rest| rest.strip_prefix('/'))
}
/// Choose the visible path text for a local link after normalization.
///
/// Relative paths stay relative. Absolute paths are shortened against `cwd` only when they are
/// lexically underneath it; otherwise the absolute path is preserved. This is display logic only,
/// not filesystem canonicalization.
fn display_local_link_path(path_text: &str, cwd: Option<&Path>) -> String {
let path_text = normalize_local_link_path_text(path_text);
if !is_absolute_local_link_path(&path_text) {
return path_text;
}
if let Some(cwd) = cwd {
// Only shorten absolute paths that are under the provided session cwd; otherwise preserve
// the original absolute target for clarity.
let cwd_text = normalize_local_link_path_text(&cwd.to_string_lossy());
if let Some(stripped) = strip_local_path_prefix(&path_text, &cwd_text) {
return stripped.to_string();
}
}
path_text
}
#[cfg(test)]
mod markdown_render_tests {
include!("markdown_render_tests.rs");

View file

@ -3,12 +3,18 @@ use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use std::path::Path;
use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
use crate::markdown_render::render_markdown_text;
use crate::markdown_render::render_markdown_text_with_width_and_cwd;
use insta::assert_snapshot;
fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
render_markdown_text_with_width_and_cwd(input, None, Some(cwd))
}
#[test]
fn empty() {
assert_eq!(render_markdown_text(""), Text::default());
@ -661,8 +667,9 @@ fn load_location_suffix_regexes() {
#[test]
fn file_link_hides_destination() {
let text = render_markdown_text(
let text = render_markdown_text_for_cwd(
"[codex-rs/tui/src/markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs".cyan()]));
assert_eq!(text, expected);
@ -670,97 +677,101 @@ fn file_link_hides_destination() {
#[test]
fn file_link_appends_line_number_when_label_lacks_it() {
let text = render_markdown_text(
let text = render_markdown_text_for_cwd(
"[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter([
"markdown_render.rs".cyan(),
":74".cyan(),
]));
let expected = Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74".cyan()]));
assert_eq!(text, expected);
}
#[test]
fn file_link_uses_label_for_line_number() {
let text = render_markdown_text(
"[markdown_render.rs:74](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)",
fn file_link_keeps_absolute_paths_outside_cwd() {
let text = render_markdown_text_for_cwd(
"[README.md:74](/Users/example/code/codex/README.md:74)",
Path::new("/Users/example/code/codex/codex-rs/tui"),
);
let expected = Text::from(Line::from_iter(["markdown_render.rs:74".cyan()]));
let expected = Text::from(Line::from_iter(["/Users/example/code/codex/README.md:74".cyan()]));
assert_eq!(text, expected);
}
#[test]
fn file_link_appends_hash_anchor_when_label_lacks_it() {
let text = render_markdown_text(
let text = render_markdown_text_for_cwd(
"[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter([
"markdown_render.rs".cyan(),
":74:3".cyan(),
]));
let expected =
Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74:3".cyan()]));
assert_eq!(text, expected);
}
#[test]
fn file_link_uses_label_for_hash_anchor() {
let text = render_markdown_text(
fn file_link_uses_target_path_for_hash_anchor() {
let text = render_markdown_text_for_cwd(
"[markdown_render.rs#L74C3](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter(["markdown_render.rs#L74C3".cyan()]));
let expected =
Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74:3".cyan()]));
assert_eq!(text, expected);
}
#[test]
fn file_link_appends_range_when_label_lacks_it() {
let text = render_markdown_text(
let text = render_markdown_text_for_cwd(
"[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter([
"markdown_render.rs".cyan(),
":74:3-76:9".cyan(),
]));
let expected =
Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan()]));
assert_eq!(text, expected);
}
#[test]
fn file_link_uses_label_for_range() {
let text = render_markdown_text(
fn file_link_uses_target_path_for_range() {
let text = render_markdown_text_for_cwd(
"[markdown_render.rs:74:3-76:9](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter(["markdown_render.rs:74:3-76:9".cyan()]));
let expected =
Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan()]));
assert_eq!(text, expected);
}
#[test]
fn file_link_appends_hash_range_when_label_lacks_it() {
let text = render_markdown_text(
let text = render_markdown_text_for_cwd(
"[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter([
"markdown_render.rs".cyan(),
":74:3-76:9".cyan(),
]));
let expected =
Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan()]));
assert_eq!(text, expected);
}
#[test]
fn multiline_file_link_label_after_styled_prefix_does_not_panic() {
let text = render_markdown_text(
let text = render_markdown_text_for_cwd(
"**bold** plain [foo\nbar](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from_iter([
Line::from_iter(["bold".bold(), " plain ".into(), "foo".cyan()]),
Line::from_iter(["bar".cyan(), ":74:3".cyan()]),
]);
let expected = Text::from(Line::from_iter([
"bold".bold(),
" plain ".into(),
"codex-rs/tui/src/markdown_render.rs:74:3".cyan(),
]));
assert_eq!(text, expected);
}
#[test]
fn file_link_uses_label_for_hash_range() {
let text = render_markdown_text(
fn file_link_uses_target_path_for_hash_range() {
let text = render_markdown_text_for_cwd(
"[markdown_render.rs#L74C3-L76C9](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter(["markdown_render.rs#L74C3-L76C9".cyan()]));
let expected =
Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan()]));
assert_eq!(text, expected);
}
@ -778,8 +789,9 @@ fn url_link_shows_destination() {
#[test]
fn markdown_render_file_link_snapshot() {
let text = render_markdown_text(
let text = render_markdown_text_for_cwd(
"See [markdown_render.rs:74](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74).",
Path::new("/Users/example/code/codex"),
);
let rendered = text
.lines
@ -796,6 +808,82 @@ fn markdown_render_file_link_snapshot() {
assert_snapshot!(rendered);
}
#[test]
fn unordered_list_local_file_link_stays_inline_with_following_text() {
let text = render_markdown_text_with_width_and_cwd(
"- [binary](/Users/example/code/codex/codex-rs/README.md:93): core is the agent/business logic, tui is the terminal UI, exec is the headless automation surface, and cli is the top-level multitool binary.",
Some(72),
Some(Path::new("/Users/example/code/codex")),
);
let rendered = text
.lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>();
assert_eq!(
rendered,
vec![
"- codex-rs/README.md:93: core is the agent/business logic, tui is the",
" terminal UI, exec is the headless automation surface, and cli is the",
" top-level multitool binary.",
]
);
}
#[test]
fn unordered_list_local_file_link_soft_break_before_colon_stays_inline() {
let text = render_markdown_text_with_width_and_cwd(
"- [binary](/Users/example/code/codex/codex-rs/README.md:93)\n : core is the agent/business logic.",
Some(72),
Some(Path::new("/Users/example/code/codex")),
);
let rendered = text
.lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>();
assert_eq!(
rendered,
vec!["- codex-rs/README.md:93: core is the agent/business logic.",]
);
}
#[test]
fn consecutive_unordered_list_local_file_links_do_not_detach_paths() {
let text = render_markdown_text_with_width_and_cwd(
"- [binary](/Users/example/code/codex/codex-rs/README.md:93)\n : cli is the top-level multitool binary.\n- [expectations](/Users/example/code/codex/codex-rs/core/README.md:1)\n : codex-core owns the real runtime behavior.",
Some(72),
Some(Path::new("/Users/example/code/codex")),
);
let rendered = text
.lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>();
assert_eq!(
rendered,
vec![
"- codex-rs/README.md:93: cli is the top-level multitool binary.",
"- codex-rs/core/README.md:1: codex-core owns the real runtime behavior.",
]
);
}
#[test]
fn code_block_known_lang_has_syntax_colors() {
let text = render_markdown_text("```rust\nfn main() {}\n```\n");

View file

@ -1,4 +1,6 @@
use ratatui::text::Line;
use std::path::Path;
use std::path::PathBuf;
use crate::markdown;
@ -8,14 +10,22 @@ pub(crate) struct MarkdownStreamCollector {
buffer: String,
committed_line_count: usize,
width: Option<usize>,
cwd: PathBuf,
}
impl MarkdownStreamCollector {
pub fn new(width: Option<usize>) -> Self {
/// Create a collector that renders markdown using `cwd` for local file-link display.
///
/// The collector snapshots `cwd` into owned state because stream commits can happen long after
/// construction. The same `cwd` should be reused for the entire stream lifecycle; mixing
/// different working directories within one stream would make the same link render with
/// different path prefixes across incremental commits.
pub fn new(width: Option<usize>, cwd: &Path) -> Self {
Self {
buffer: String::new(),
committed_line_count: 0,
width,
cwd: cwd.to_path_buf(),
}
}
@ -41,7 +51,7 @@ impl MarkdownStreamCollector {
return Vec::new();
};
let mut rendered: Vec<Line<'static>> = Vec::new();
markdown::append_markdown(&source, self.width, &mut rendered);
markdown::append_markdown(&source, self.width, Some(self.cwd.as_path()), &mut rendered);
let mut complete_line_count = rendered.len();
if complete_line_count > 0
&& crate::render::line_utils::is_blank_line_spaces_only(
@ -82,7 +92,7 @@ impl MarkdownStreamCollector {
tracing::trace!("markdown finalize (raw source):\n---\n{source}\n---");
let mut rendered: Vec<Line<'static>> = Vec::new();
markdown::append_markdown(&source, self.width, &mut rendered);
markdown::append_markdown(&source, self.width, Some(self.cwd.as_path()), &mut rendered);
let out = if self.committed_line_count >= rendered.len() {
Vec::new()
@ -96,12 +106,19 @@ impl MarkdownStreamCollector {
}
}
#[cfg(test)]
fn test_cwd() -> PathBuf {
// These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or
// Windows-specific root semantics into the fixtures.
std::env::temp_dir()
}
#[cfg(test)]
pub(crate) fn simulate_stream_markdown_for_tests(
deltas: &[&str],
finalize: bool,
) -> Vec<Line<'static>> {
let mut collector = MarkdownStreamCollector::new(None);
let mut collector = MarkdownStreamCollector::new(None, &test_cwd());
let mut out = Vec::new();
for d in deltas {
collector.push_delta(d);
@ -122,7 +139,7 @@ mod tests {
#[tokio::test]
async fn no_commit_until_newline() {
let mut c = super::MarkdownStreamCollector::new(None);
let mut c = super::MarkdownStreamCollector::new(None, &super::test_cwd());
c.push_delta("Hello, world");
let out = c.commit_complete_lines();
assert!(out.is_empty(), "should not commit without newline");
@ -133,7 +150,7 @@ mod tests {
#[tokio::test]
async fn finalize_commits_partial_line() {
let mut c = super::MarkdownStreamCollector::new(None);
let mut c = super::MarkdownStreamCollector::new(None, &super::test_cwd());
c.push_delta("Line without newline");
let out = c.finalize_and_drain();
assert_eq!(out.len(), 1);
@ -253,7 +270,7 @@ mod tests {
async fn heading_starts_on_new_line_when_following_paragraph() {
// Stream a paragraph line, then a heading on the next line.
// Expect two distinct rendered lines: "Hello." and "Heading".
let mut c = super::MarkdownStreamCollector::new(None);
let mut c = super::MarkdownStreamCollector::new(None, &super::test_cwd());
c.push_delta("Hello.\n");
let out1 = c.commit_complete_lines();
let s1: Vec<String> = out1
@ -309,7 +326,7 @@ mod tests {
// Paragraph without trailing newline, then a chunk that starts with the newline
// and the heading text, then a final newline. The collector should first commit
// only the paragraph line, and later commit the heading as its own line.
let mut c = super::MarkdownStreamCollector::new(None);
let mut c = super::MarkdownStreamCollector::new(None, &super::test_cwd());
c.push_delta("Sounds good!");
// No commit yet
assert!(c.commit_complete_lines().is_empty());
@ -354,7 +371,8 @@ mod tests {
// Sanity check raw markdown rendering for a simple line does not produce spurious extras.
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown("Hello.\n", None, &mut rendered);
let test_cwd = super::test_cwd();
crate::markdown::append_markdown("Hello.\n", None, Some(test_cwd.as_path()), &mut rendered);
let rendered_strings: Vec<String> = rendered
.iter()
.map(|l| {
@ -414,7 +432,8 @@ mod tests {
let streamed_str = lines_to_plain_strings(&streamed);
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown(input, None, &mut rendered_all);
let test_cwd = super::test_cwd();
crate::markdown::append_markdown(input, None, Some(test_cwd.as_path()), &mut rendered_all);
let rendered_all_str = lines_to_plain_strings(&rendered_all);
assert_eq!(
@ -520,7 +539,8 @@ mod tests {
let full: String = deltas.iter().copied().collect();
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown(&full, None, &mut rendered_all);
let test_cwd = super::test_cwd();
crate::markdown::append_markdown(&full, None, Some(test_cwd.as_path()), &mut rendered_all);
let rendered_all_strs = lines_to_plain_strings(&rendered_all);
assert_eq!(
@ -608,7 +628,8 @@ mod tests {
// Compute a full render for diagnostics only.
let full: String = deltas.iter().copied().collect();
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown(&full, None, &mut rendered_all);
let test_cwd = super::test_cwd();
crate::markdown::append_markdown(&full, None, Some(test_cwd.as_path()), &mut rendered_all);
// Also assert exact expected plain strings for clarity.
let expected = vec![
@ -635,7 +656,8 @@ mod tests {
let streamed_strs = lines_to_plain_strings(&streamed);
let full: String = deltas.iter().copied().collect();
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown(&full, None, &mut rendered);
let test_cwd = super::test_cwd();
crate::markdown::append_markdown(&full, None, Some(test_cwd.as_path()), &mut rendered);
let rendered_strs = lines_to_plain_strings(&rendered);
assert_eq!(streamed_strs, rendered_strs, "full:\n---\n{full}\n---");
}

View file

@ -3,4 +3,4 @@ source: tui/src/markdown_render_tests.rs
assertion_line: 714
expression: rendered
---
See markdown_render.rs:74.
See codex-rs/tui/src/markdown_render.rs:74.

View file

@ -4,6 +4,7 @@ use crate::render::line_utils::prefix_lines;
use crate::style::proposed_plan_style;
use ratatui::prelude::Stylize;
use ratatui::text::Line;
use std::path::Path;
use std::time::Duration;
use std::time::Instant;
@ -18,9 +19,13 @@ pub(crate) struct StreamController {
}
impl StreamController {
pub(crate) fn new(width: Option<usize>) -> Self {
/// Create a controller whose markdown renderer shortens local file links relative to `cwd`.
///
/// The controller snapshots the path into stream state so later commit ticks and finalization
/// render against the same session cwd that was active when streaming started.
pub(crate) fn new(width: Option<usize>, cwd: &Path) -> Self {
Self {
state: StreamState::new(width),
state: StreamState::new(width, cwd),
finishing_after_drain: false,
header_emitted: false,
}
@ -115,9 +120,14 @@ pub(crate) struct PlanStreamController {
}
impl PlanStreamController {
pub(crate) fn new(width: Option<usize>) -> Self {
/// Create a plan-stream controller whose markdown renderer shortens local file links relative
/// to `cwd`.
///
/// The controller snapshots the path into stream state so later commit ticks and finalization
/// render against the same session cwd that was active when streaming started.
pub(crate) fn new(width: Option<usize>, cwd: &Path) -> Self {
Self {
state: StreamState::new(width),
state: StreamState::new(width, cwd),
header_emitted: false,
top_padding_emitted: false,
}
@ -232,6 +242,13 @@ impl PlanStreamController {
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn test_cwd() -> PathBuf {
// These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or
// Windows-specific root semantics into the fixtures.
std::env::temp_dir()
}
fn lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec<String> {
lines
@ -248,7 +265,7 @@ mod tests {
#[tokio::test]
async fn controller_loose_vs_tight_with_commit_ticks_matches_full() {
let mut ctrl = StreamController::new(None);
let mut ctrl = StreamController::new(None, &test_cwd());
let mut lines = Vec::new();
// Exact deltas from the session log (section: Loose vs. tight list items)
@ -346,7 +363,8 @@ mod tests {
// Full render of the same source
let source: String = deltas.iter().copied().collect();
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown(&source, None, &mut rendered);
let test_cwd = test_cwd();
crate::markdown::append_markdown(&source, None, Some(test_cwd.as_path()), &mut rendered);
let rendered_strs = lines_to_plain_strings(&rendered);
assert_eq!(streamed, rendered_strs);

View file

@ -10,6 +10,7 @@
//! arrival timestamp so policy code can reason about oldest queued age without peeking into text.
use std::collections::VecDeque;
use std::path::Path;
use std::time::Duration;
use std::time::Instant;
@ -33,10 +34,13 @@ pub(crate) struct StreamState {
}
impl StreamState {
/// Creates an empty stream state with an optional target wrap width.
pub(crate) fn new(width: Option<usize>) -> Self {
/// Create stream state whose markdown collector renders local file links relative to `cwd`.
///
/// Controllers are expected to pass the session cwd here once and keep it stable for the
/// lifetime of the active stream.
pub(crate) fn new(width: Option<usize>, cwd: &Path) -> Self {
Self {
collector: MarkdownStreamCollector::new(width),
collector: MarkdownStreamCollector::new(width, cwd),
queued_lines: VecDeque::new(),
has_seen_delta: false,
}
@ -102,10 +106,17 @@ impl StreamState {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
fn test_cwd() -> PathBuf {
// These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or
// Windows-specific root semantics into the fixtures.
std::env::temp_dir()
}
#[test]
fn drain_n_clamps_to_available_lines() {
let mut state = StreamState::new(None);
let mut state = StreamState::new(None, &test_cwd());
state.enqueue(vec![Line::from("one")]);
let drained = state.drain_n(8);