mirror of
https://github.com/openai/codex.git
synced 2026-04-21 21:24:51 +00:00
Compare commits
1 Commits
codex-hack
...
steipete-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c728351b11 |
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
60
codex-rs/core/src/terminal_hyperlinks.rs
Normal file
60
codex-rs/core/src/terminal_hyperlinks.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
122
codex-rs/core/tests/suite/terminal_hyperlinks.rs
Normal file
122
codex-rs/core/tests/suite/terminal_hyperlinks.rs
Normal 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(())
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
281
codex-rs/tui/src/file_references.rs
Normal file
281
codex-rs/tui/src/file_references.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;;.
|
||||
479
codex-rs/tui/src/terminal_hyperlinks.rs
Normal file
479
codex-rs/tui/src/terminal_hyperlinks.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
65
docs/terminal-hyperlinks.md
Normal file
65
docs/terminal-hyperlinks.md
Normal 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
|
||||
```
|
||||
Reference in New Issue
Block a user