mirror of
https://github.com/openai/codex.git
synced 2026-05-01 09:56:37 +00:00
Compare commits
7 Commits
ice-window
...
codex/tui-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e87ce8023 | ||
|
|
9e62e0b685 | ||
|
|
709d67eb1d | ||
|
|
38261c124e | ||
|
|
39d9bf7e30 | ||
|
|
a67d1cd1a4 | ||
|
|
0fd120461d |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -2512,6 +2512,7 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.2.1",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"vt100",
|
||||
"webbrowser",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user