diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5ba2d7f634..5391277fd9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -47,6 +47,7 @@ use crate::stream_events_utils::last_assistant_message_from_item; use crate::stream_events_utils::raw_assistant_output_text_from_item; use crate::stream_events_utils::record_completed_response_item; use crate::terminal; +use crate::terminal_hyperlinks::augment_model_instructions_for_terminal_hyperlinks; use crate::truncate::TruncationPolicy; use crate::turn_metadata::TurnMetadataState; use crate::util::error_or_panic; @@ -424,6 +425,8 @@ impl Codex { .clone() .or_else(|| conversation_history.get_base_instructions().map(|s| s.text)) .unwrap_or_else(|| model_info.get_model_instructions(config.personality)); + let base_instructions = + augment_model_instructions_for_terminal_hyperlinks(base_instructions, &config); // Respect thread-start tools. When missing (resumed/forked threads), read from the db // first, then fall back to rollout-file tools. diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 63deb5c808..f743b2c0bb 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -3,6 +3,7 @@ use crate::codex::TurnContext; use crate::environment_context::EnvironmentContext; use crate::features::Feature; use crate::shell::Shell; +use crate::terminal_hyperlinks::augment_model_instructions_for_terminal_hyperlinks; use codex_execpolicy::Policy; use codex_protocol::config_types::Personality; use codex_protocol::models::ContentItem; @@ -136,7 +137,10 @@ pub(crate) fn build_model_instructions_update_item( return None; } - let model_instructions = next.model_info.get_model_instructions(next.personality); + let model_instructions = augment_model_instructions_for_terminal_hyperlinks( + next.model_info.get_model_instructions(next.personality), + &next.config, + ); if model_instructions.is_empty() { return None; } diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index dbc4e70685..9cd4d7670e 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -155,6 +155,8 @@ pub enum Feature { FastMode, /// Enable voice transcription in the TUI composer. VoiceTranscription, + /// Make rendered web URLs and local file references clickable in the TUI. + TerminalHyperlinks, /// Enable experimental realtime voice conversation mode in the TUI. RealtimeConversation, /// Prevent idle system sleep while a turn is actively running. @@ -717,6 +719,16 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::TerminalHyperlinks, + key: "terminal_hyperlinks", + stage: Stage::Experimental { + name: "Terminal hyperlinks", + menu_description: "Turn rendered web URLs and local file references into clickable terminal hyperlinks, and steer the model toward shorter workspace-relative file refs.", + announcement: "NEW: Terminal hyperlinks can turn URLs and file refs into real terminal hyperlinks. Enable it in /experimental and restart Codex to try it.", + }, + default_enabled: false, + }, FeatureSpec { id: Feature::RealtimeConversation, key: "realtime_conversation", @@ -887,4 +899,16 @@ mod tests { assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab)); assert_eq!(feature_for_key("collab"), Some(Feature::Collab)); } + + #[test] + fn clickable_text_is_legacy_alias_for_terminal_hyperlinks() { + assert_eq!( + feature_for_key("clickable_text"), + Some(Feature::TerminalHyperlinks) + ); + assert_eq!( + feature_for_key("terminal_hyperlinks"), + Some(Feature::TerminalHyperlinks) + ); + } } diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 45b3dfd5dc..a1fdb548be 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -41,6 +41,10 @@ const ALIASES: &[Alias] = &[ legacy_key: "memory_tool", feature: Feature::MemoryTool, }, + Alias { + legacy_key: "clickable_text", + feature: Feature::TerminalHyperlinks, + }, ]; pub(crate) fn legacy_feature_keys() -> impl Iterator { diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 828bbe214a..94b564dc63 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -14,6 +14,7 @@ mod client; mod client_common; pub mod codex; mod realtime_conversation; +mod terminal_hyperlinks; pub use codex::SteerInputError; mod codex_thread; mod compact_remote; diff --git a/codex-rs/core/src/terminal_hyperlinks.rs b/codex-rs/core/src/terminal_hyperlinks.rs new file mode 100644 index 0000000000..3b280388d8 --- /dev/null +++ b/codex-rs/core/src/terminal_hyperlinks.rs @@ -0,0 +1,60 @@ +use crate::config::Config; +use crate::features::Feature; + +const TERMINAL_HYPERLINKS_PROMPT_SUFFIX: &str = r#" + +## Terminal Hyperlinks + +- Prefer paths relative to the current working directory instead of absolute paths for files inside the workspace. +- Include the start line when it matters, for example `src/app.ts:42` or `src/app.ts#L42`. +- Avoid bare filenames when a cwd-relative path would be clearer. +- If a cwd-relative path is still noisy, shorten it by eliding middle directories, for example `codex-rs/.../terminal_hyperlinks.rs:42`, when the remaining reference stays unambiguous. +"#; + +pub(crate) fn augment_model_instructions_for_terminal_hyperlinks( + mut base_instructions: String, + config: &Config, +) -> String { + if !config.features.enabled(Feature::TerminalHyperlinks) + || base_instructions.contains("## Terminal Hyperlinks") + { + return base_instructions; + } + + base_instructions.push_str(TERMINAL_HYPERLINKS_PROMPT_SUFFIX); + base_instructions +} + +#[cfg(test)] +mod tests { + use super::augment_model_instructions_for_terminal_hyperlinks; + use crate::config::test_config; + use crate::features::Feature; + + #[test] + fn terminal_hyperlinks_suffix_is_only_appended_when_enabled() { + let mut config = test_config(); + let original = "Base instructions".to_string(); + + assert_eq!( + augment_model_instructions_for_terminal_hyperlinks(original.clone(), &config), + original + ); + + let _ = config.features.enable(Feature::TerminalHyperlinks); + let augmented = + augment_model_instructions_for_terminal_hyperlinks(original.clone(), &config); + assert!(augmented.contains("## Terminal Hyperlinks")); + assert!(augmented.starts_with("Base instructions")); + } + + #[test] + fn terminal_hyperlinks_suffix_is_not_duplicated() { + let mut config = test_config(); + let _ = config.features.enable(Feature::TerminalHyperlinks); + let base = "Base instructions\n\n## Terminal Hyperlinks\n".to_string(); + + let augmented = augment_model_instructions_for_terminal_hyperlinks(base.clone(), &config); + assert_eq!(augmented, base); + } +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index e9867caa76..a6bd16da00 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -122,6 +122,7 @@ mod sqlite_state; mod stream_error_allows_next_turn; mod stream_no_completed; mod subagent_notifications; +mod terminal_hyperlinks; mod text_encoding_fix; mod tool_harness; mod tool_parallelism; diff --git a/codex-rs/core/tests/suite/terminal_hyperlinks.rs b/codex-rs/core/tests/suite/terminal_hyperlinks.rs new file mode 100644 index 0000000000..4e86652b7e --- /dev/null +++ b/codex-rs/core/tests/suite/terminal_hyperlinks.rs @@ -0,0 +1,122 @@ +use anyhow::Result; +use codex_core::features::Feature; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse_completed; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; + +const TERMINAL_HYPERLINKS_HEADER: &str = "## Terminal Hyperlinks"; + +async fn submit_user_turn(test: &TestCodex, prompt: &str, model: String) -> Result<()> { + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: prompt.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model, + effort: test.config.model_reasoning_effort, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn terminal_hyperlinks_feature_augments_initial_request_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await; + let mut builder = test_codex() + .with_model("gpt-5.2-codex") + .with_config(|config| { + let _ = config.features.enable(Feature::TerminalHyperlinks); + }); + let test = builder.build(&server).await?; + + submit_user_turn(&test, "hello", test.session_configured.model.clone()).await?; + + let instructions_text = resp_mock.single_request().instructions_text(); + assert!( + instructions_text.contains(TERMINAL_HYPERLINKS_HEADER), + "expected terminal hyperlinks guidance in request instructions, got: {instructions_text:?}" + ); + assert!( + instructions_text.contains("Prefer paths relative to the current working directory"), + "expected relative-path guidance in request instructions, got: {instructions_text:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn terminal_hyperlinks_feature_updates_model_switch_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let resp_mock = mount_sse_sequence( + &server, + vec![sse_completed("resp-1"), sse_completed("resp-2")], + ) + .await; + let mut builder = test_codex() + .with_model("gpt-5.2-codex") + .with_config(|config| { + let _ = config.features.enable(Feature::TerminalHyperlinks); + }); + let test = builder.build(&server).await?; + let next_model = "gpt-5.1-codex-max".to_string(); + + submit_user_turn(&test, "hello", test.session_configured.model.clone()).await?; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: Some(next_model.clone()), + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + submit_user_turn(&test, "switch models", next_model).await?; + + let requests = resp_mock.requests(); + let second_request = requests.last().expect("expected second request"); + let developer_texts = second_request.message_input_texts("developer"); + let model_switch_text = developer_texts + .iter() + .find(|text| text.contains("")) + .expect("expected model switch message in developer input"); + + assert!( + model_switch_text.contains(TERMINAL_HYPERLINKS_HEADER), + "expected terminal hyperlinks guidance in model switch message, got: {model_switch_text:?}" + ); + + Ok(()) +} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fe8aca54fd..a47ab19dec 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1925,6 +1925,11 @@ impl App { } // Allow widgets to process any pending timers before rendering. self.chat_widget.pre_draw_tick(); + tui.set_terminal_hyperlink_settings( + crate::terminal_hyperlinks::TerminalHyperlinkSettings::from_config( + &self.config, + ), + ); tui.draw( self.chat_widget.desired_height(tui.terminal.size()?.width), |frame| { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8c4f4ef57b..38ec5963ed 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -258,6 +258,7 @@ use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; +use crate::terminal_hyperlinks::TerminalHyperlinkSettings; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod interrupts; @@ -8371,6 +8372,11 @@ impl Drop for ChatWidget { impl Renderable for ChatWidget { fn render(&self, area: Rect, buf: &mut Buffer) { self.as_renderable().render(area, buf); + crate::terminal_hyperlinks::linkify_buffer_area( + buf, + area, + &TerminalHyperlinkSettings::from_config(&self.config), + ); self.last_rendered_width.set(Some(area.width as usize)); } diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 7d0ab018b8..83ef1b6260 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -76,7 +76,7 @@ const LIGHT_256_GUTTER_FG_IDX: u8 = 236; use crate::color::is_light; use crate::color::perceptual_distance; -use crate::exec_command::relativize_to_home; +use crate::file_references; use crate::render::Insets; use crate::render::highlight::DiffScopeBackgroundRgbs; use crate::render::highlight::diff_scope_background_rgbs; @@ -92,7 +92,6 @@ use crate::terminal_palette::default_bg; use crate::terminal_palette::indexed_color; use crate::terminal_palette::rgb_color; use crate::terminal_palette::stdout_color_level; -use codex_core::git_info::get_git_repo_root; use codex_core::terminal::TerminalName; use codex_core::terminal::terminal_info; use codex_protocol::protocol::FileChange; @@ -737,26 +736,7 @@ fn render_change( /// possible, keeping output stable in jj/no-`.git` workspaces (e.g. image /// tool calls should show `example.png` instead of an absolute path). pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { - if path.is_relative() { - return path.display().to_string(); - } - - if let Ok(stripped) = path.strip_prefix(cwd) { - return stripped.display().to_string(); - } - - let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) { - (Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo, - _ => false, - }; - let chosen = if path_in_same_repo { - pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf()) - } else { - relativize_to_home(path) - .map(|p| PathBuf::from_iter([Path::new("~"), p.as_path()])) - .unwrap_or_else(|| path.to_path_buf()) - }; - chosen.display().to_string() + file_references::display_path_for(path, cwd) } pub(crate) fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) { diff --git a/codex-rs/tui/src/file_references.rs b/codex-rs/tui/src/file_references.rs new file mode 100644 index 0000000000..e4a7abe9e6 --- /dev/null +++ b/codex-rs/tui/src/file_references.rs @@ -0,0 +1,281 @@ +use crate::exec_command::relativize_to_home; +use codex_core::git_info::get_git_repo_root; +use codex_utils_string::normalize_markdown_hash_location_suffix; +use regex_lite::Regex; +use std::ffi::OsStr; +use std::path::Path; +use std::path::PathBuf; +use std::sync::LazyLock; + +static COLON_LOCATION_SUFFIX_RE: LazyLock = LazyLock::new(|| { + Regex::new(r":(?P\d+)(?::(?P\d+))?(?:[-–]\d+(?::\d+)?)?$") + .unwrap_or_else(|error| panic!("invalid location suffix regex: {error}")) +}); + +static HASH_LOCATION_SUFFIX_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"#L(?P\d+)(?:C(?P\d+))?(?:-L\d+(?:C\d+)?)?$") + .unwrap_or_else(|error| panic!("invalid hash location regex: {error}")) +}); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ParsedFileReference { + pub(crate) resolved_path: PathBuf, + pub(crate) display_path: String, + pub(crate) location_suffix: String, + pub(crate) line: Option, + pub(crate) col: Option, +} + +impl ParsedFileReference { + pub(crate) fn display_text(&self) -> String { + format!("{}{}", self.display_path, self.location_suffix) + } +} + +pub(crate) fn parse_local_file_reference_token( + token: &str, + cwd: &Path, +) -> Option { + let (path_text, line, col, location_suffix) = strip_location_suffix(token); + let resolved_path = resolve_existing_local_path(path_text, cwd)?; + if !looks_like_file_reference(path_text, &resolved_path, cwd) { + return None; + } + + Some(ParsedFileReference { + display_path: display_path_for(&resolved_path, cwd), + resolved_path, + location_suffix, + line, + col, + }) +} + +pub(crate) fn extract_local_path_location_suffix(dest_url: &str) -> Option { + if !is_local_path_like_link(dest_url) { + return None; + } + + normalized_location_suffix(dest_url) +} + +pub(crate) fn text_has_location_suffix(text: &str) -> bool { + normalized_location_suffix(text).is_some() +} + +pub(crate) 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'\\') + ) +} + +pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { + let rendered = if path.is_relative() { + path.display().to_string() + } else if let Ok(stripped) = path.strip_prefix(cwd) { + stripped.display().to_string() + } else { + let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) { + (Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo, + _ => false, + }; + let chosen = if path_in_same_repo { + pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf()) + } else { + relativize_to_home(path) + .map(|relative| PathBuf::from_iter([Path::new("~"), relative.as_path()])) + .unwrap_or_else(|| path.to_path_buf()) + }; + chosen.display().to_string() + }; + + collapse_middle_directories(rendered) +} + +fn normalized_location_suffix(text: &str) -> Option { + if let Some(captures) = HASH_LOCATION_SUFFIX_RE.captures(text) + && let Some(full) = captures.get(0) + { + return normalize_markdown_hash_location_suffix(full.as_str()); + } + + COLON_LOCATION_SUFFIX_RE + .captures(text) + .and_then(|captures| captures.get(0).map(|suffix| suffix.as_str().to_string())) +} + +fn strip_location_suffix(token: &str) -> (&str, Option, Option, String) { + if let Some(captures) = HASH_LOCATION_SUFFIX_RE.captures(token) + && let Some(full) = captures.get(0) + { + let line = captures + .name("line") + .and_then(|value| value.as_str().parse::().ok()); + let col = captures + .name("col") + .and_then(|value| value.as_str().parse::().ok()); + let suffix = normalize_markdown_hash_location_suffix(full.as_str()).unwrap_or_default(); + return (&token[..full.start()], line, col, suffix); + } + + if let Some(captures) = COLON_LOCATION_SUFFIX_RE.captures(token) + && let Some(full) = captures.get(0) + { + let line = captures + .name("line") + .and_then(|value| value.as_str().parse::().ok()); + let col = captures + .name("col") + .and_then(|value| value.as_str().parse::().ok()); + return (&token[..full.start()], line, col, full.as_str().to_string()); + } + + (token, None, None, String::new()) +} + +fn looks_like_file_reference(token: &str, resolved_path: &Path, cwd: &Path) -> bool { + token.starts_with("~/") + || token.starts_with("./") + || token.starts_with("../") + || token.starts_with('/') + || token.starts_with("\\\\") + || token.contains('/') + || token.contains('\\') + || matches!( + token.as_bytes(), + [drive, b':', separator, ..] + if drive.is_ascii_alphabetic() && matches!(separator, b'/' | b'\\') + ) + || is_cwd_bare_filename_reference(token, resolved_path, cwd) +} + +fn is_cwd_bare_filename_reference(token: &str, resolved_path: &Path, cwd: &Path) -> bool { + if token.is_empty() + || token.contains(['/', '\\']) + || token.starts_with("~/") + || token.starts_with("./") + || token.starts_with("../") + || token.contains(':') + || token.chars().any(char::is_whitespace) + || !token + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) + { + return false; + } + + resolved_path.parent() == Some(cwd) && resolved_path.file_name() == Some(OsStr::new(token)) +} + +fn resolve_local_path(token: &str, cwd: &Path) -> PathBuf { + if let Some(path) = token.strip_prefix("~/") { + return dirs::home_dir() + .map(|home| home.join(path)) + .unwrap_or_else(|| PathBuf::from(token)); + } + + let path = PathBuf::from(token); + if path.is_absolute() { + return path; + } + + cwd.join(path) +} + +fn resolve_existing_local_path(token: &str, cwd: &Path) -> Option { + let path = resolve_local_path(token, cwd); + path.exists().then_some(path) +} + +fn collapse_middle_directories(path: String) -> String { + let separator = if path.contains('\\') { '\\' } else { '/' }; + let starts_with_separator = path.starts_with(separator); + let mut parts: Vec<&str> = path + .split(separator) + .filter(|part| !part.is_empty()) + .collect(); + if parts.len() <= 3 || path.len() <= 36 { + return path; + } + + let prefix = if starts_with_separator { + separator.to_string() + } else { + String::new() + }; + + let keep_last = if path.len() > 52 { 1 } else { 2 }; + if parts.len() <= keep_last + 1 { + return path; + } + + let tail_start = parts.len() - keep_last; + let tail = parts.split_off(tail_start); + let mut collapsed = format!("{prefix}{}", parts[0]); + collapsed.push(separator); + collapsed.push_str("..."); + for part in tail { + collapsed.push(separator); + collapsed.push_str(part); + } + + if collapsed.len() < path.len() { + collapsed + } else { + path + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_local_file_reference_supports_bare_filenames() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("Cargo.toml"); + std::fs::write(&path, "[package]\nname = \"demo\"\n").expect("write file"); + + let reference = + parse_local_file_reference_token("Cargo.toml:7", dir.path()).expect("file ref"); + + assert_eq!(reference.resolved_path, path); + assert_eq!(reference.display_text(), "Cargo.toml:7"); + assert_eq!(reference.line, Some(7)); + assert_eq!(reference.col, None); + } + + #[test] + fn extract_local_path_location_suffix_normalizes_hash_locations() { + let suffix = extract_local_path_location_suffix("file:///tmp/src/lib.rs#L74C3-L76C9"); + assert_eq!(suffix.as_deref(), Some(":74:3-76:9")); + } + + #[test] + fn display_path_collapses_long_workspace_paths() { + let cwd = Path::new("/workspace"); + let path = cwd.join("codex-rs/tui/src/bottom_pane/chat_composer.rs"); + + let rendered = display_path_for(&path, cwd); + + assert_eq!(rendered, "codex-rs/.../bottom_pane/chat_composer.rs"); + } + + #[test] + fn display_path_keeps_short_workspace_paths() { + let cwd = Path::new("/workspace"); + let path = cwd.join("tui/example.png"); + + let rendered = display_path_for(&path, cwd); + + assert_eq!(rendered, "tui/example.png"); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b9a1a5c47c..91c6935446 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -79,6 +79,7 @@ mod diff_render; mod exec_cell; mod exec_command; mod external_editor; +mod file_references; mod file_search; mod frames; mod get_git_diff; @@ -109,6 +110,7 @@ mod status; mod status_indicator_widget; mod streaming; mod style; +mod terminal_hyperlinks; mod terminal_palette; mod text_formatting; mod theme_picker; diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 2bbe19b6f7..e31d83ce96 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -1,8 +1,10 @@ +use crate::file_references::extract_local_path_location_suffix; +use crate::file_references::is_local_path_like_link; +use crate::file_references::text_has_location_suffix; 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 pulldown_cmark::CodeBlockKind; use pulldown_cmark::CowStr; use pulldown_cmark::Event; @@ -15,8 +17,6 @@ use ratatui::style::Style; use ratatui::text::Line; use ratatui::text::Span; use ratatui::text::Text; -use regex_lite::Regex; -use std::sync::LazyLock; struct MarkdownStyles { h1: Style, @@ -101,35 +101,6 @@ fn should_render_link_destination(dest_url: &str) -> bool { !is_local_path_like_link(dest_url) } -static COLON_LOCATION_SUFFIX_RE: LazyLock = - LazyLock::new( - || match Regex::new(r":\d+(?::\d+)?(?:[-–]\d+(?::\d+)?)?$") { - Ok(regex) => regex, - Err(error) => panic!("invalid location suffix regex: {error}"), - }, - ); - -// Covered by load_location_suffix_regexes. -static HASH_LOCATION_SUFFIX_RE: LazyLock = - LazyLock::new(|| match Regex::new(r"^L\d+(?:C\d+)?(?:-L\d+(?:C\d+)?)?$") { - Ok(regex) => 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>, @@ -524,23 +495,7 @@ where } 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 - }, + hidden_location_suffix: extract_local_path_location_suffix(&dest_url), label_start_span_idx, label_styled, destination: dest_url, @@ -569,11 +524,7 @@ where }) }) .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() - { + if text_has_location_suffix(&label_text) { // The label already carries a location suffix; don't duplicate it. } else { self.push_span(Span::styled(location_suffix.to_string(), self.styles.code)); diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index 9981246093..04302c0f69 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -1,11 +1,10 @@ +use crate::file_references::extract_local_path_location_suffix; use pretty_assertions::assert_eq; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::text::Text; -use crate::markdown_render::COLON_LOCATION_SUFFIX_RE; -use crate::markdown_render::HASH_LOCATION_SUFFIX_RE; use crate::markdown_render::render_markdown_text; use insta::assert_snapshot; @@ -654,9 +653,9 @@ fn link() { } #[test] -fn load_location_suffix_regexes() { - let _colon = &*COLON_LOCATION_SUFFIX_RE; - let _hash = &*HASH_LOCATION_SUFFIX_RE; +fn local_path_location_suffixes_are_normalized() { + let suffix = extract_local_path_location_suffix("file:///tmp/src/lib.rs#L74C3"); + assert_eq!(suffix.as_deref(), Some(":74:3")); } #[test] diff --git a/codex-rs/tui/src/snapshots/codex_tui__terminal_hyperlinks__tests__terminal_hyperlinks_linkify_line.snap b/codex-rs/tui/src/snapshots/codex_tui__terminal_hyperlinks__tests__terminal_hyperlinks_linkify_line.snap new file mode 100644 index 0000000000..fbe8dc337e --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__terminal_hyperlinks__tests__terminal_hyperlinks_linkify_line.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/terminal_hyperlinks.rs +expression: normalized +--- +Open ]8;;https://example.com/docshttps://example.com/docs]8;; and ]8;;vscode://file/workspace/src/lib.rs:1src/lib.rs:1]8;;. diff --git a/codex-rs/tui/src/terminal_hyperlinks.rs b/codex-rs/tui/src/terminal_hyperlinks.rs new file mode 100644 index 0000000000..22e851def4 --- /dev/null +++ b/codex-rs/tui/src/terminal_hyperlinks.rs @@ -0,0 +1,479 @@ +use codex_core::config::Config; +use codex_core::config::types::UriBasedFileOpener; +use codex_core::features::Feature; +use codex_core::terminal::TerminalName; +use codex_core::terminal::terminal_info; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use std::fmt::Write as _; +use std::path::PathBuf; +use url::Url; + +use crate::file_references::parse_local_file_reference_token; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct TerminalHyperlinkSettings { + pub(crate) enabled: bool, + pub(crate) cwd: PathBuf, + pub(crate) file_opener: UriBasedFileOpener, + pub(crate) terminal_name: TerminalName, +} + +impl TerminalHyperlinkSettings { + pub(crate) fn from_config(config: &Config) -> Self { + Self { + enabled: config.features.enabled(Feature::TerminalHyperlinks), + cwd: config.cwd.clone(), + file_opener: config.file_opener, + terminal_name: terminal_info().name, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct HyperlinkMatch { + start: usize, + end: usize, + display_text: String, + target: String, +} + +pub(crate) fn linkify_lines( + lines: &[Line<'static>], + settings: &TerminalHyperlinkSettings, +) -> Vec> { + lines + .iter() + .map(|line| linkify_line(line, settings)) + .collect() +} + +pub(crate) fn linkify_line( + line: &Line<'static>, + settings: &TerminalHyperlinkSettings, +) -> Line<'static> { + if !settings.enabled { + return line.clone(); + } + + let text: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + let matches = detect_hyperlinks(&text, settings); + if matches.is_empty() { + return line.clone(); + } + + let mut spans = Vec::new(); + let mut cursor = 0usize; + for hyperlink in &matches { + push_original_range(line, cursor, hyperlink.start, &mut spans); + spans.push(Span::styled( + osc8_wrap(&hyperlink.display_text, &hyperlink.target), + style_at_offset(line, hyperlink.start), + )); + cursor = hyperlink.end; + } + push_original_range(line, cursor, text.len(), &mut spans); + + Line::from(spans).style(line.style) +} + +pub(crate) fn linkify_buffer_area( + buf: &mut Buffer, + area: Rect, + settings: &TerminalHyperlinkSettings, +) { + if !settings.enabled { + return; + } + + for y in area.top()..area.bottom() { + let mut row_text = String::new(); + let mut cells = Vec::new(); + for x in area.left()..area.right() { + let symbol = buf[(x, y)].symbol().to_string(); + let start = row_text.len(); + row_text.push_str(&symbol); + let end = row_text.len(); + cells.push((x, start, end)); + } + + for hyperlink in detect_hyperlinks(&row_text, settings) { + for (x, start, end) in &cells { + if *start >= hyperlink.end || *end <= hyperlink.start { + continue; + } + let cell = &mut buf[(*x, y)]; + let symbol = cell.symbol().to_string(); + if symbol.trim().is_empty() || symbol.contains("\x1B]8;;") { + continue; + } + cell.set_symbol(&osc8_wrap(&symbol, &hyperlink.target)); + } + } + } +} + +fn detect_hyperlinks(text: &str, settings: &TerminalHyperlinkSettings) -> Vec { + let mut matches = Vec::new(); + let mut index = 0usize; + while index < text.len() { + let Some(ch) = text[index..].chars().next() else { + break; + }; + if ch.is_whitespace() { + index += ch.len_utf8(); + continue; + } + + let token_start = index; + index += ch.len_utf8(); + while index < text.len() { + let Some(next) = text[index..].chars().next() else { + break; + }; + if next.is_whitespace() { + break; + } + index += next.len_utf8(); + } + + let raw = &text[token_start..index]; + let (trim_leading, trim_trailing) = trimmed_token_offsets(raw); + if trim_leading + trim_trailing >= raw.len() { + continue; + } + + let start = token_start + trim_leading; + let end = index - trim_trailing; + let token = &text[start..end]; + + let Some((target, display_text)) = + detect_url_target(token).or_else(|| detect_file_target(token, settings)) + else { + continue; + }; + + if matches + .last() + .is_some_and(|previous: &HyperlinkMatch| previous.end > start) + { + continue; + } + + matches.push(HyperlinkMatch { + start, + end, + display_text, + target, + }); + } + + matches +} + +fn detect_url_target(token: &str) -> Option<(String, String)> { + let parsed = Url::parse(token).ok()?; + matches!(parsed.scheme(), "http" | "https").then(|| (parsed.into(), token.to_string())) +} + +fn detect_file_target( + token: &str, + settings: &TerminalHyperlinkSettings, +) -> Option<(String, String)> { + let reference = parse_local_file_reference_token(token, &settings.cwd)?; + if settings.terminal_name == TerminalName::VsCode { + return None; + } + + let file_url = Url::from_file_path(&reference.resolved_path).ok()?; + match settings.file_opener.get_scheme() { + Some(scheme) => { + let suffix = file_url.as_str().strip_prefix("file://")?; + let mut target = format!("{scheme}://file{suffix}"); + if let Some(line) = reference.line { + let _ = write!(target, ":{line}"); + if let Some(col) = reference.col { + let _ = write!(target, ":{col}"); + } + } + Some((target, reference.display_text())) + } + None => Some((file_url.into(), reference.display_text())), + } +} + +fn trimmed_token_offsets(token: &str) -> (usize, usize) { + let leading = token + .chars() + .take_while(|ch| matches!(ch, '(' | '[' | '{' | '<' | '"' | '\'' | '`')) + .map(char::len_utf8) + .sum(); + let trailing = token + .chars() + .rev() + .take_while(|ch| { + matches!( + ch, + ')' | ']' | '}' | '>' | '"' | '\'' | '`' | '.' | ',' | ';' | '!' | '?' + ) + }) + .map(char::len_utf8) + .sum(); + (leading, trailing) +} + +fn osc8_wrap(text: &str, target: &str) -> String { + let safe_target: String = target + .chars() + .filter(|&ch| ch != '\x1B' && ch != '\x07') + .collect(); + if safe_target.is_empty() { + return text.to_string(); + } + format!("\x1B]8;;{safe_target}\x07{text}\x1B]8;;\x07") +} + +fn push_original_range( + line: &Line<'static>, + start: usize, + end: usize, + spans: &mut Vec>, +) { + if start >= end { + return; + } + + let mut global_offset = 0usize; + for span in &line.spans { + let span_text = span.content.as_ref(); + let span_end = global_offset + span_text.len(); + let segment_start = start.max(global_offset); + let segment_end = end.min(span_end); + if segment_start < segment_end { + spans.push(Span::styled( + span_text[(segment_start - global_offset)..(segment_end - global_offset)] + .to_string(), + span.style, + )); + } + global_offset = span_end; + if global_offset >= end { + break; + } + } +} + +fn style_at_offset(line: &Line<'static>, offset: usize) -> Style { + let mut global_offset = 0usize; + for span in &line.spans { + let span_end = global_offset + span.content.len(); + if offset < span_end { + return span.style; + } + global_offset = span_end; + } + + line.spans + .last() + .map(|span| span.style) + .unwrap_or(line.style) +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::style::Stylize; + use regex_lite::Regex; + use std::sync::LazyLock; + + static OSC8_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"\x1B\]8;;[^\x07]*\x07|\x1B\]8;;\x07") + .unwrap_or_else(|error| panic!("invalid osc8 regex: {error}")) + }); + + fn settings(cwd: PathBuf) -> TerminalHyperlinkSettings { + TerminalHyperlinkSettings { + enabled: true, + cwd, + file_opener: UriBasedFileOpener::VsCode, + terminal_name: TerminalName::Unknown, + } + } + + fn strip_osc8(text: &str) -> String { + OSC8_RE.replace_all(text, "").to_string() + } + + #[test] + fn linkify_line_wraps_http_url() { + let settings = settings(PathBuf::from("/tmp")); + let line = Line::from(vec!["See ".into(), "https://example.com/docs".cyan()]); + + let linked = linkify_line(&line, &settings); + let text: String = linked + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!(text.contains("\x1B]8;;https://example.com/docs\x07")); + } + + #[test] + fn linkify_line_wraps_local_file_reference() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("src/lib.rs"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + std::fs::write(&path, "fn main() {}\n").expect("write file"); + + let line = Line::from(vec!["src/lib.rs:1".cyan()]); + let linked = linkify_line(&line, &settings(dir.path().to_path_buf())); + let text: String = linked + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!(text.contains("\x1B]8;;vscode://file")); + assert!(text.contains("src/lib.rs:1")); + } + + #[test] + fn linkify_buffer_area_wraps_detected_token() { + let settings = settings(PathBuf::from("/tmp")); + let area = Rect::new(0, 0, 32, 1); + let mut buf = Buffer::empty(area); + buf.set_string( + 0, + 0, + "https://example.com/docs", + ratatui::style::Style::default(), + ); + + linkify_buffer_area(&mut buf, area, &settings); + + let rendered = buf[(0, 0)].symbol().to_string(); + assert!(rendered.contains("\x1B]8;;https://example.com/docs\x07")); + } + + #[test] + fn trims_surrounding_punctuation() { + let settings = settings(PathBuf::from("/tmp")); + let matches = detect_hyperlinks("(https://example.com/docs).", &settings); + assert_eq!(matches.len(), 1); + assert_eq!(&matches[0].target, "https://example.com/docs"); + } + + #[test] + fn linkify_line_wraps_bare_filename_reference_from_cwd() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("Cargo.toml"); + std::fs::write(&path, "[package]\nname = \"demo\"\n").expect("write file"); + + let line = Line::from(vec!["Cargo.toml:1".cyan()]); + let linked = linkify_line(&line, &settings(dir.path().to_path_buf())); + let text: String = linked + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!(text.contains("\x1B]8;;vscode://file")); + assert!(text.contains("Cargo.toml:1")); + } + + #[test] + fn linkify_line_shortens_long_workspace_paths() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir + .path() + .join("src/components/chat/composer/terminal_hyperlinks.rs"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + std::fs::write(&path, "fn main() {}\n").expect("write file"); + + let token = "src/components/chat/composer/terminal_hyperlinks.rs:1"; + let line = Line::from(vec![token.cyan()]); + let linked = linkify_line(&line, &settings(dir.path().to_path_buf())); + let text: String = linked + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + let visible = strip_osc8(&text); + assert_eq!(visible, "src/.../composer/terminal_hyperlinks.rs:1"); + } + + #[test] + fn linkify_line_uses_standard_file_uri_without_editor_opener() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("Cargo.toml"); + std::fs::write(&path, "[package]\nname = \"demo\"\n").expect("write file"); + + let mut settings = settings(dir.path().to_path_buf()); + settings.file_opener = UriBasedFileOpener::None; + + let line = Line::from(vec!["Cargo.toml:1".cyan()]); + let linked = linkify_line(&line, &settings); + let text: String = linked + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!(text.contains("\x1B]8;;file://")); + assert!(text.contains("Cargo.toml:1")); + } + + #[test] + fn linkify_line_leaves_local_file_references_plain_in_vscode_terminal() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("src/lib.rs"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + std::fs::write(&path, "fn main() {}\n").expect("write file"); + + let mut settings = settings(dir.path().to_path_buf()); + settings.terminal_name = TerminalName::VsCode; + + let line = Line::from(vec!["src/lib.rs:1".cyan()]); + let linked = linkify_line(&line, &settings); + let text: String = linked + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert_eq!(text, "src/lib.rs:1"); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn linkify_line_snapshot_captures_terminal_hyperlinks_output() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("src/lib.rs"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + std::fs::write(&path, "fn main() {}\n").expect("write file"); + + let line = Line::from(vec![ + "Open ".into(), + "https://example.com/docs".cyan(), + " and ".into(), + "src/lib.rs:1".cyan(), + ".".into(), + ]); + + let linked = linkify_line(&line, &settings(dir.path().to_path_buf())); + let text: String = linked + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + let normalized = text.replace(dir.path().to_string_lossy().as_ref(), "/workspace"); + + assert_snapshot!("terminal_hyperlinks_linkify_line", normalized); + } +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 9d521d1aa6..0840b09621 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -41,6 +41,7 @@ use crate::custom_terminal; use crate::custom_terminal::Terminal as CustomTerminal; use crate::notifications::DesktopNotificationBackend; use crate::notifications::detect_backend; +use crate::terminal_hyperlinks::TerminalHyperlinkSettings; use crate::tui::event_stream::EventBroker; use crate::tui::event_stream::TuiEventStream; #[cfg(unix)] @@ -255,6 +256,7 @@ pub struct Tui { notification_backend: Option, // When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support) alt_screen_enabled: bool, + terminal_hyperlink_settings: Option, } impl Tui { @@ -283,6 +285,7 @@ impl Tui { enhanced_keys_supported, notification_backend: Some(detect_backend(NotificationMethod::default())), alt_screen_enabled: true, + terminal_hyperlink_settings: None, } } @@ -295,6 +298,10 @@ impl Tui { self.notification_backend = Some(detect_backend(method)); } + pub fn set_terminal_hyperlink_settings(&mut self, settings: TerminalHyperlinkSettings) { + self.terminal_hyperlink_settings = Some(settings); + } + pub fn frame_requester(&self) -> FrameRequester { self.frame_requester.clone() } @@ -496,10 +503,12 @@ impl Tui { } if !self.pending_history_lines.is_empty() { - crate::insert_history::insert_history_lines( - terminal, - self.pending_history_lines.clone(), - )?; + let lines = if let Some(settings) = &self.terminal_hyperlink_settings { + crate::terminal_hyperlinks::linkify_lines(&self.pending_history_lines, settings) + } else { + self.pending_history_lines.clone() + }; + crate::insert_history::insert_history_lines(terminal, lines)?; self.pending_history_lines.clear(); } diff --git a/docs/terminal-hyperlinks.md b/docs/terminal-hyperlinks.md new file mode 100644 index 0000000000..42bddbdfdb --- /dev/null +++ b/docs/terminal-hyperlinks.md @@ -0,0 +1,65 @@ +# Terminal Hyperlinks + +Manual smoke tests for the `terminal_hyperlinks` feature. + +## Build + +```sh +cd /Users/steipete/openai/codex2 +just fmt +cd /Users/steipete/openai/codex2/codex-rs +cargo build -p codex-cli +``` + +## VS Code Integrated Terminal + +Codex detects VS Code via `TERM_PROGRAM=vscode` and leaves local file references as plain text so +the integrated terminal's native file-link detection can handle them. + +```sh +cd /Users/steipete/openai/codex2 +just codex --enable terminal_hyperlinks +``` + +In the session, ask: + +```text +Reply with exactly these references on separate lines: +Cargo.toml:1 +codex-rs/tui/src/terminal_hyperlinks.rs:1 +codex-rs/core/src/context_manager/updates.rs:1 +https://example.com/docs +``` + +Expected results: + +- `Cargo.toml:1` opens the workspace file in VS Code. +- `codex-rs/tui/src/terminal_hyperlinks.rs:1` opens that file in VS Code. +- The web URL opens in the browser. +- No absolute workspace paths should be necessary. + +## iTerm2 / Ghostty / Other Native Terminals + +Outside VS Code, Codex emits OSC 8 hyperlinks for URLs and local files. + +```sh +cd /Users/steipete/openai/codex2 +just codex --enable terminal_hyperlinks +``` + +Use the same prompt as above, then verify: + +- Web URLs are clickable as OSC 8 hyperlinks. +- Local files are clickable as OSC 8 hyperlinks. +- Bare filenames such as `Cargo.toml:1` open the cwd-relative file. +- Longer workspace paths may render with collapsed middle directories such as + `codex-rs/.../bottom_pane/chat_composer.rs:1` while still opening the full resolved file. + +## Focused Test Commands + +```sh +cd /Users/steipete/openai/codex2/codex-rs +cargo test -p codex-core terminal_hyperlinks +cargo test -p codex-tui terminal_hyperlinks +cargo test -p codex-tui file_references +```