Compare commits

...

7 Commits

Author SHA1 Message Date
pash-openai
8e87ce8023 Merge branch 'main' into codex/tui-file-links-relative-targets 2026-03-06 19:23:43 -08:00
pash
9e62e0b685 core: wait for rollout persistence before resume tests 2026-03-06 18:08:27 -08:00
pash
709d67eb1d app-server: harden app list update ordering test 2026-03-06 17:57:07 -08:00
pash
38261c124e core: relax shell serialization test timeouts 2026-03-06 16:48:13 -08:00
pash
39d9bf7e30 tui: parse unix file urls on windows 2026-03-06 16:12:14 -08:00
pash
a67d1cd1a4 tui: keep out-of-cwd file links absolute 2026-03-06 15:30:00 -08:00
pash
0fd120461d tui: render file links from target paths 2026-03-06 15:30:00 -08:00
13 changed files with 319 additions and 151 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2512,6 +2512,7 @@ dependencies = [
"unicode-segmentation",
"unicode-width 0.2.1",
"url",
"urlencoding",
"uuid",
"vt100",
"webbrowser",

View File

@@ -501,15 +501,21 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
})
.await?;
let maybe_update = timeout(
Duration::from_millis(150),
read_app_list_updated_notification(&mut mcp),
)
.await;
assert!(
maybe_update.is_err(),
"unexpected directory-only app/list update before accessible apps loaded"
);
let expected_accessible = vec![AppInfo {
id: "beta".to_string(),
name: "Beta App".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
let expected = vec![
AppInfo {
@@ -544,8 +550,18 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
},
];
let update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(update.data, expected);
loop {
let update = read_app_list_updated_notification(&mut mcp).await?;
assert!(
update.data.iter().any(|connector| connector.is_accessible),
"unexpected directory-only app/list update before accessible apps loaded: {:#?}",
update.data
);
if update.data == expected {
break;
}
assert_eq!(update.data, expected_accessible);
}
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,

View File

@@ -1,6 +1,13 @@
use std::path::Path;
use std::time::Duration;
use anyhow::Result;
use anyhow::bail;
use codex_core::RolloutRecorder;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
@@ -18,6 +25,40 @@ use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use std::sync::Arc;
const ROLLOUT_PERSIST_TIMEOUT: Duration = Duration::from_secs(5);
const ROLLOUT_POLL_INTERVAL: Duration = Duration::from_millis(25);
async fn wait_for_completed_rollout(rollout_path: &Path) -> Result<()> {
let deadline = tokio::time::Instant::now() + ROLLOUT_PERSIST_TIMEOUT;
loop {
match RolloutRecorder::get_rollout_history(rollout_path).await {
Ok(InitialHistory::Resumed(resumed))
if matches!(
resumed.history.last(),
Some(RolloutItem::EventMsg(EventMsg::TurnComplete(_)))
) =>
{
return Ok(());
}
Ok(_) => {}
Err(err) if tokio::time::Instant::now() >= deadline => {
return Err(err.into());
}
Err(_) => {}
}
if tokio::time::Instant::now() >= deadline {
bail!(
"timed out waiting for completed rollout history at {}",
rollout_path.display()
);
}
tokio::time::sleep(ROLLOUT_POLL_INTERVAL).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -56,6 +97,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> {
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
wait_for_completed_rollout(&rollout_path).await?;
let resumed = builder.resume(&server, home, rollout_path).await?;
let initial_messages = resumed
@@ -122,6 +164,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()>
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
wait_for_completed_rollout(&rollout_path).await?;
let resumed = builder.resume(&server, home, rollout_path).await?;
let initial_messages = resumed
@@ -214,6 +257,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> {
)
.await;
wait_for_completed_rollout(&rollout_path).await?;
let mut resume_builder = test_codex().with_config(|config| {
config.model = Some("gpt-5.2-codex".to_string());
});
@@ -326,6 +370,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu
)
.await;
wait_for_completed_rollout(&rollout_path).await?;
let mut resume_builder = test_codex().with_config(|config| {
config.model = Some("gpt-5.2-codex".to_string());
});

View File

@@ -39,6 +39,8 @@ const FIXTURE_JSON: &str = r#"{
}
"#;
const SERIALIZATION_TEST_TIMEOUT_MS: u64 = 5_000;
fn shell_responses(
call_id: &str,
command: Vec<&str>,
@@ -49,7 +51,7 @@ fn shell_responses(
let command = shlex::try_join(command)?;
let parameters = json!({
"command": command,
"timeout_ms": 2_000,
"timeout_ms": SERIALIZATION_TEST_TIMEOUT_MS,
});
Ok(vec![
sse(vec![
@@ -70,7 +72,7 @@ fn shell_responses(
ShellModelOutput::Shell => {
let parameters = json!({
"command": command,
"timeout_ms": 2_000,
"timeout_ms": SERIALIZATION_TEST_TIMEOUT_MS,
});
Ok(vec![
sse(vec![
@@ -740,7 +742,7 @@ async fn shell_command_output_is_freeform() -> Result<()> {
let call_id = "shell-command";
let args = json!({
"command": "echo shell command",
"timeout_ms": 1_000,
"timeout_ms": SERIALIZATION_TEST_TIMEOUT_MS,
});
let responses = vec![
sse(vec![
@@ -791,7 +793,7 @@ async fn shell_command_output_is_not_truncated_under_10k_bytes() -> Result<()> {
let call_id = "shell-command";
let args = json!({
"command": "perl -e 'print \"1\" x 10000'",
"timeout_ms": 1000,
"timeout_ms": SERIALIZATION_TEST_TIMEOUT_MS,
});
let responses = vec![
sse(vec![
@@ -841,7 +843,7 @@ async fn shell_command_output_is_not_truncated_over_10k_bytes() -> Result<()> {
let call_id = "shell-command";
let args = json!({
"command": "perl -e 'print \"1\" x 10001'",
"timeout_ms": 1000,
"timeout_ms": SERIALIZATION_TEST_TIMEOUT_MS,
});
let responses = vec![
sse(vec![

View File

@@ -101,6 +101,7 @@ two-face = { version = "0.5", default-features = false, features = ["syntect-def
unicode-segmentation = { workspace = true }
unicode-width = { workspace = true }
url = { workspace = true }
urlencoding = { workspace = true }
webbrowser = { workspace = true }
uuid = { workspace = true }

View File

@@ -2666,6 +2666,7 @@ impl ChatWidget {
}
self.stream_controller = Some(StreamController::new(
self.last_rendered_width.get().map(|w| w.saturating_sub(2)),
self.config.cwd.clone(),
));
}
if let Some(controller) = self.stream_controller.as_mut()

View File

@@ -1,10 +1,25 @@
use ratatui::text::Line;
use std::path::Path;
pub(crate) fn append_markdown(
markdown_source: &str,
width: Option<usize>,
lines: &mut Vec<Line<'static>>,
) {
let rendered = crate::markdown_render::render_markdown_text_with_width(markdown_source, width);
append_markdown_with_cwd(markdown_source, width, None, lines);
}
pub(crate) fn append_markdown_with_cwd(
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);
}

View File

@@ -16,7 +16,11 @@ 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;
use urlencoding::decode;
struct MarkdownStyles {
h1: Style,
@@ -80,10 +84,18 @@ pub fn render_markdown_text(input: &str) -> Text<'static> {
}
pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>) -> Text<'static> {
render_markdown_text_with_width_and_cwd(input, width, None)
}
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 +104,7 @@ 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,
local_display_text: Option<String>,
}
fn should_render_link_destination(dest_url: &str) -> bool {
@@ -130,6 +140,64 @@ fn is_local_path_like_link(dest_url: &str) -> bool {
)
}
fn local_link_display_text(dest_url: &str, cwd: Option<&Path>) -> Option<String> {
if !is_local_path_like_link(dest_url) {
return None;
}
let (path, location_suffix) = split_local_link_destination(dest_url)?;
let display_path = cwd.map_or_else(
|| path.display().to_string(),
|cwd| match path.strip_prefix(cwd) {
Ok(stripped) => stripped.display().to_string(),
Err(_) => path.display().to_string(),
},
);
Some(format!("{display_path}{location_suffix}"))
}
fn split_local_link_destination(dest_url: &str) -> Option<(PathBuf, String)> {
if dest_url.starts_with("file://") {
let url = Url::parse(dest_url).ok()?;
let path = url.to_file_path().ok().or_else(|| {
let decoded_path = decode(url.path()).ok()?.into_owned();
if decoded_path.is_empty() {
return None;
}
let file_path = match url.host_str() {
Some(host) if !host.is_empty() && host != "localhost" => {
format!("//{host}{decoded_path}")
}
_ => decoded_path,
};
Some(PathBuf::from(file_path))
})?;
let location_suffix = url
.fragment()
.map(|fragment| format!("#{fragment}"))
.and_then(|suffix| normalize_markdown_hash_location_suffix(&suffix))
.unwrap_or_default();
return Some((path, location_suffix));
}
if let Some((path_part, fragment)) = dest_url.rsplit_once('#')
&& HASH_LOCATION_SUFFIX_RE.is_match(fragment)
{
let location_suffix =
normalize_markdown_hash_location_suffix(&format!("#{fragment}")).unwrap_or_default();
return Some((PathBuf::from(path_part), location_suffix));
}
if let Some(location_match) = COLON_LOCATION_SUFFIX_RE.find(dest_url) {
let path_part = &dest_url[..location_match.start()];
return Some((
PathBuf::from(path_part),
location_match.as_str().to_string(),
));
}
Some((PathBuf::from(dest_url), String::new()))
}
struct Writer<'a, I>
where
I: Iterator<Item = Event<'a>>,
@@ -153,13 +221,14 @@ where
current_subsequent_indent: Vec<Span<'static>>,
current_line_style: Style,
current_line_in_code_block: bool,
cwd: Option<PathBuf>,
}
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(),
@@ -180,9 +249,17 @@ where
current_subsequent_indent: Vec::new(),
current_line_style: Style::default(),
current_line_in_code_block: false,
cwd: cwd.map(Path::to_path_buf),
}
}
fn suppress_local_link_label(&self) -> bool {
self.link
.as_ref()
.and_then(|link| link.local_display_text.as_ref())
.is_some()
}
fn run(&mut self) {
while let Some(ev) = self.iter.next() {
self.handle_event(ev);
@@ -194,10 +271,26 @@ where
match event {
Event::Start(tag) => self.start_tag(tag),
Event::End(tag) => self.end_tag(tag),
Event::Text(text) => self.text(text),
Event::Code(code) => self.code(code),
Event::SoftBreak => self.soft_break(),
Event::HardBreak => self.hard_break(),
Event::Text(text) => {
if !self.suppress_local_link_label() {
self.text(text);
}
}
Event::Code(code) => {
if !self.suppress_local_link_label() {
self.code(code);
}
}
Event::SoftBreak => {
if !self.suppress_local_link_label() {
self.soft_break();
}
}
Event::HardBreak => {
if !self.suppress_local_link_label() {
self.hard_break();
}
}
Event::Rule => {
self.flush_current_line();
if !self.text.lines.is_empty() {
@@ -206,8 +299,16 @@ where
self.push_line(Line::from("———"));
self.needs_newline = true;
}
Event::Html(html) => self.html(html, false),
Event::InlineHtml(html) => self.html(html, true),
Event::Html(html) => {
if !self.suppress_local_link_label() {
self.html(html, false);
}
}
Event::InlineHtml(html) => {
if !self.suppress_local_link_label() {
self.html(html, true);
}
}
Event::FootnoteReference(_) => {}
Event::TaskListMarker(_) => {}
}
@@ -231,9 +332,21 @@ where
}
Tag::List(start) => self.start_list(start),
Tag::Item => self.start_item(),
Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
Tag::Strong => self.push_inline_style(self.styles.strong),
Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
Tag::Emphasis => {
if !self.suppress_local_link_label() {
self.push_inline_style(self.styles.emphasis);
}
}
Tag::Strong => {
if !self.suppress_local_link_label() {
self.push_inline_style(self.styles.strong);
}
}
Tag::Strikethrough => {
if !self.suppress_local_link_label() {
self.push_inline_style(self.styles.strikethrough);
}
}
Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
Tag::HtmlBlock
| Tag::FootnoteDefinition(_)
@@ -257,7 +370,11 @@ where
self.indent_stack.pop();
self.pending_marker_line = false;
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.pop_inline_style(),
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
if !self.suppress_local_link_label() {
self.pop_inline_style();
}
}
TagEnd::Link => self.pop_link(),
TagEnd::HtmlBlock
| TagEnd::FootnoteDefinition
@@ -513,36 +630,9 @@ 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())
})
} else {
None
},
label_start_span_idx,
label_styled,
local_display_text: local_link_display_text(&dest_url, self.cwd.as_deref()),
destination: dest_url,
});
}
@@ -550,39 +640,11 @@ 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));
}
if link.label_styled {
self.pop_inline_style();
}
} else if link.label_styled {
self.pop_inline_style();
} else if let Some(local_display_text) = link.local_display_text {
self.push_span(Span::styled(local_display_text, self.styles.code));
}
}
}

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,106 +667,119 @@ fn load_location_suffix_regexes() {
#[test]
fn file_link_hides_destination() {
let text = render_markdown_text(
"[codex-rs/tui/src/markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs)",
let text = render_markdown_text_for_cwd(
"[ignored label](/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);
}
#[test]
fn file_link_appends_line_number_when_label_lacks_it() {
let text = render_markdown_text(
fn file_link_uses_target_path_relative_to_cwd() {
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(
fn file_link_ignores_label_when_rendering_visible_target() {
let text = render_markdown_text_for_cwd(
"[markdown_render.rs:74](/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: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_appends_hash_anchor_when_label_lacks_it() {
let text = render_markdown_text(
fn file_link_outside_cwd_stays_absolute() {
let text = render_markdown_text_for_cwd(
"[ignored label](/Users/example/other-repo/src/main.rs:12)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter(["/Users/example/other-repo/src/main.rs:12".cyan()]));
assert_eq!(text, expected);
}
#[test]
fn file_link_normalizes_hash_anchor_for_terminal_clickability() {
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_ignores_hash_anchor_label() {
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(
fn file_link_shows_range_from_target() {
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_ignores_range_label() {
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(
fn file_link_normalizes_hash_range() {
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_ignores_hash_range_label() {
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 +797,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

View File

@@ -1,4 +1,5 @@
use ratatui::text::Line;
use std::path::PathBuf;
use crate::markdown;
@@ -8,14 +9,16 @@ pub(crate) struct MarkdownStreamCollector {
buffer: String,
committed_line_count: usize,
width: Option<usize>,
cwd: Option<PathBuf>,
}
impl MarkdownStreamCollector {
pub fn new(width: Option<usize>) -> Self {
pub fn new(width: Option<usize>, cwd: Option<PathBuf>) -> Self {
Self {
buffer: String::new(),
committed_line_count: 0,
width,
cwd,
}
}
@@ -41,7 +44,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_with_cwd(&source, self.width, self.cwd.as_deref(), &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 +85,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_with_cwd(&source, self.width, self.cwd.as_deref(), &mut rendered);
let out = if self.committed_line_count >= rendered.len() {
Vec::new()
@@ -101,7 +104,7 @@ 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, None);
let mut out = Vec::new();
for d in deltas {
collector.push_delta(d);
@@ -122,7 +125,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, None);
c.push_delta("Hello, world");
let out = c.commit_complete_lines();
assert!(out.is_empty(), "should not commit without newline");
@@ -133,7 +136,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, None);
c.push_delta("Line without newline");
let out = c.finalize_and_drain();
assert_eq!(out.len(), 1);
@@ -253,7 +256,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, None);
c.push_delta("Hello.\n");
let out1 = c.commit_complete_lines();
let s1: Vec<String> = out1
@@ -309,7 +312,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, None);
c.push_delta("Sounds good!");
// No commit yet
assert!(c.commit_complete_lines().is_empty());

View File

@@ -1,6 +1,6 @@
---
source: tui/src/markdown_render_tests.rs
assertion_line: 714
assertion_line: 776
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::PathBuf;
use std::time::Duration;
use std::time::Instant;
@@ -18,9 +19,9 @@ pub(crate) struct StreamController {
}
impl StreamController {
pub(crate) fn new(width: Option<usize>) -> Self {
pub(crate) fn new(width: Option<usize>, cwd: PathBuf) -> Self {
Self {
state: StreamState::new(width),
state: StreamState::new(width, Some(cwd)),
finishing_after_drain: false,
header_emitted: false,
}
@@ -117,7 +118,7 @@ pub(crate) struct PlanStreamController {
impl PlanStreamController {
pub(crate) fn new(width: Option<usize>) -> Self {
Self {
state: StreamState::new(width),
state: StreamState::new(width, None),
header_emitted: false,
top_padding_emitted: false,
}
@@ -248,7 +249,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, PathBuf::from("/workspace"));
let mut lines = Vec::new();
// Exact deltas from the session log (section: Loose vs. tight list items)

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::PathBuf;
use std::time::Duration;
use std::time::Instant;
@@ -34,9 +35,9 @@ 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 {
pub(crate) fn new(width: Option<usize>, cwd: Option<PathBuf>) -> Self {
Self {
collector: MarkdownStreamCollector::new(width),
collector: MarkdownStreamCollector::new(width, cwd),
queued_lines: VecDeque::new(),
has_seen_delta: false,
}
@@ -105,7 +106,7 @@ mod tests {
#[test]
fn drain_n_clamps_to_available_lines() {
let mut state = StreamState::new(None);
let mut state = StreamState::new(None, None);
state.enqueue(vec![Line::from("one")]);
let drained = state.drain_n(8);