Compare commits

...

1 Commits

Author SHA1 Message Date
Peter Steinberger
c728351b11 Add terminal hyperlink support 2026-03-04 21:23:58 +00:00
19 changed files with 1087 additions and 86 deletions

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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)
);
}
}

View File

@@ -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<Item = &'static str> {

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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("<model_switch>"))
.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(())
}

View File

@@ -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| {

View File

@@ -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));
}

View File

@@ -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) {

View File

@@ -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<Regex> = LazyLock::new(|| {
Regex::new(r":(?P<line>\d+)(?::(?P<col>\d+))?(?:[-]\d+(?::\d+)?)?$")
.unwrap_or_else(|error| panic!("invalid location suffix regex: {error}"))
});
static HASH_LOCATION_SUFFIX_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"#L(?P<line>\d+)(?:C(?P<col>\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<usize>,
pub(crate) col: Option<usize>,
}
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<ParsedFileReference> {
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<String> {
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<String> {
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<usize>, Option<usize>, 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::<usize>().ok());
let col = captures
.name("col")
.and_then(|value| value.as_str().parse::<usize>().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::<usize>().ok());
let col = captures
.name("col")
.and_then(|value| value.as_str().parse::<usize>().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<PathBuf> {
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");
}
}

View File

@@ -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;

View File

@@ -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<Regex> =
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<Regex> =
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<Item = Event<'a>>,
@@ -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));

View File

@@ -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]

View File

@@ -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;;.

View File

@@ -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<Line<'static>> {
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<HyperlinkMatch> {
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<Span<'static>>,
) {
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<Regex> = 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);
}
}

View File

@@ -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<DesktopNotificationBackend>,
// When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support)
alt_screen_enabled: bool,
terminal_hyperlink_settings: Option<TerminalHyperlinkSettings>,
}
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();
}

View File

@@ -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
```