mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
feat: add support for file_opener option in Rust, similiar to #911
This commit is contained in:
3
codex-rs/Cargo.lock
generated
3
codex-rs/Cargo.lock
generated
@@ -629,8 +629,11 @@ dependencies = [
|
||||
"codex-core",
|
||||
"color-eyre",
|
||||
"crossterm",
|
||||
"lazy_static",
|
||||
"mcp-types",
|
||||
"pretty_assertions",
|
||||
"ratatui",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"strum 0.27.1",
|
||||
|
||||
@@ -310,6 +310,19 @@ To disable this behavior, configure `[history]` as follows:
|
||||
persistence = "none" # "save-all" is the default value
|
||||
```
|
||||
|
||||
### file_opener
|
||||
|
||||
Identifies the editor/URI scheme to use for hyperlinking citations in model output. If set, citations to files in the model output will be hyperlinked using the specified URI scheme so they can be ctrl/cmd-clicked from the terminal to open them.
|
||||
|
||||
For example, if the model output includes a reference such as `【F:/home/user/project/main.py†L42-L50】`, then this would be rewritten to link to the URI `vscode://file/home/user/project/main.py:42`.
|
||||
|
||||
Note this is **not** a general editor setting (like `$EDITOR`), as it only accepts a fixed set of values:
|
||||
|
||||
- `vscode` (default)
|
||||
- `vscode-insiders`
|
||||
- `windsurf`
|
||||
- `cursor`
|
||||
|
||||
### project_doc_max_bytes
|
||||
|
||||
Maximum number of bytes to read from an `AGENTS.md` file to include in the instructions sent with the first turn of a session. Defaults to 32 KiB.
|
||||
|
||||
@@ -84,6 +84,10 @@ pub struct Config {
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
pub history: History,
|
||||
|
||||
/// Optional URI-based file opener. If set, citations to files in the model
|
||||
/// output will be hyperlinked using the specified URI scheme.
|
||||
pub file_opener: Option<UriBasedFileOpener>,
|
||||
}
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
@@ -97,7 +101,7 @@ pub struct History {
|
||||
pub max_bytes: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
#[derive(Deserialize, Debug, Copy, Clone, PartialEq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum HistoryPersistence {
|
||||
/// Save all history entries to disk.
|
||||
@@ -107,6 +111,21 @@ pub enum HistoryPersistence {
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Copy, Clone, PartialEq)]
|
||||
pub enum UriBasedFileOpener {
|
||||
#[serde(rename = "vscode")]
|
||||
VsCode,
|
||||
|
||||
#[serde(rename = "vscode-insiders")]
|
||||
VsCodeInsiders,
|
||||
|
||||
#[serde(rename = "windsurf")]
|
||||
Windsurf,
|
||||
|
||||
#[serde(rename = "cursor")]
|
||||
Cursor,
|
||||
}
|
||||
|
||||
/// Base config deserialized from ~/.codex/config.toml.
|
||||
#[derive(Deserialize, Debug, Clone, Default)]
|
||||
pub struct ConfigToml {
|
||||
@@ -158,6 +177,10 @@ pub struct ConfigToml {
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
#[serde(default)]
|
||||
pub history: Option<History>,
|
||||
|
||||
/// Optional URI-based file opener. If set, citations to files in the model
|
||||
/// output will be hyperlinked using the specified URI scheme.
|
||||
pub file_opener: Option<UriBasedFileOpener>,
|
||||
}
|
||||
|
||||
impl ConfigToml {
|
||||
@@ -351,6 +374,9 @@ impl Config {
|
||||
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
|
||||
codex_home,
|
||||
history,
|
||||
// TODO(mbolin): Check if user has VS Code installed before
|
||||
// defaulting to it?
|
||||
file_opener: cfg.file_opener.or(Some(UriBasedFileOpener::VsCode)),
|
||||
};
|
||||
Ok(config)
|
||||
}
|
||||
@@ -686,6 +712,7 @@ disable_response_storage = true
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
codex_home: fixture.codex_home(),
|
||||
history: History::default(),
|
||||
file_opener: Some(UriBasedFileOpener::VsCode),
|
||||
},
|
||||
o3_profile_config
|
||||
);
|
||||
@@ -721,6 +748,7 @@ disable_response_storage = true
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
codex_home: fixture.codex_home(),
|
||||
history: History::default(),
|
||||
file_opener: Some(UriBasedFileOpener::VsCode),
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
||||
@@ -771,6 +799,7 @@ disable_response_storage = true
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
codex_home: fixture.codex_home(),
|
||||
history: History::default(),
|
||||
file_opener: Some(UriBasedFileOpener::VsCode),
|
||||
};
|
||||
|
||||
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
||||
|
||||
@@ -22,11 +22,13 @@ codex-core = { path = "../core" }
|
||||
codex-common = { path = "../common", features = ["cli", "elapsed"] }
|
||||
color-eyre = "0.6.3"
|
||||
crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
|
||||
lazy_static = "1"
|
||||
mcp-types = { path = "../mcp-types" }
|
||||
ratatui = { version = "0.29.0", features = [
|
||||
"unstable-widget-ref",
|
||||
"unstable-rendered-line-info",
|
||||
] }
|
||||
regex = "1"
|
||||
serde_json = "1"
|
||||
shlex = "1.3.0"
|
||||
strum = "0.27.1"
|
||||
@@ -44,4 +46,7 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
tui-input = "0.11.1"
|
||||
tui-markdown = "0.3.3"
|
||||
tui-textarea = "0.7.0"
|
||||
uuid = { version = "1" }
|
||||
uuid = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1"
|
||||
|
||||
@@ -209,11 +209,13 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
self.conversation_history.add_agent_message(message);
|
||||
self.conversation_history
|
||||
.add_agent_message(&self.config, message);
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
|
||||
self.conversation_history.add_agent_reasoning(text);
|
||||
self.conversation_history
|
||||
.add_agent_reasoning(&self.config, text);
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::TaskStarted => {
|
||||
|
||||
23
codex-rs/tui/src/citation_regex.rs
Normal file
23
codex-rs/tui/src/citation_regex.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
#![allow(clippy::expect_used)]
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
// This is defined in its own file so we can limit the scope of
|
||||
// `allow(clippy::expect_used)` because we cannot scope it to the `lazy_static!`
|
||||
// macro.
|
||||
lazy_static::lazy_static! {
|
||||
/// Regular expression that matches Codex-style source file citations such as:
|
||||
///
|
||||
/// ```text
|
||||
/// 【F:src/main.rs†L10-L20】
|
||||
/// ```
|
||||
///
|
||||
/// Capture groups:
|
||||
/// 1. file path (anything except the dagger `†` symbol)
|
||||
/// 2. start line number (digits)
|
||||
/// 3. optional end line (digits or `?`)
|
||||
#[allow(clippy::expect_used)]
|
||||
pub(crate) static ref CITATION_REGEX: Regex = Regex::new(
|
||||
r"【F:([^†]+)†L(\d+)(?:-L(\d+|\?))?】"
|
||||
).expect("failed to compile citation regex");
|
||||
}
|
||||
@@ -183,12 +183,12 @@ impl ConversationHistoryWidget {
|
||||
self.add_to_history(HistoryCell::new_user_prompt(message));
|
||||
}
|
||||
|
||||
pub fn add_agent_message(&mut self, message: String) {
|
||||
self.add_to_history(HistoryCell::new_agent_message(message));
|
||||
pub fn add_agent_message(&mut self, config: &Config, message: String) {
|
||||
self.add_to_history(HistoryCell::new_agent_message(config, message));
|
||||
}
|
||||
|
||||
pub fn add_agent_reasoning(&mut self, text: String) {
|
||||
self.add_to_history(HistoryCell::new_agent_reasoning(text));
|
||||
pub fn add_agent_reasoning(&mut self, config: &Config, text: String) {
|
||||
self.add_to_history(HistoryCell::new_agent_reasoning(config, text));
|
||||
}
|
||||
|
||||
pub fn add_background_event(&mut self, message: String) {
|
||||
|
||||
@@ -155,19 +155,19 @@ impl HistoryCell {
|
||||
HistoryCell::UserPrompt { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_agent_message(message: String) -> Self {
|
||||
pub(crate) fn new_agent_message(config: &Config, message: String) -> Self {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("codex".magenta().bold()));
|
||||
append_markdown(&message, &mut lines);
|
||||
append_markdown(&message, &mut lines, config.file_opener, &config.cwd);
|
||||
lines.push(Line::from(""));
|
||||
|
||||
HistoryCell::AgentMessage { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_agent_reasoning(text: String) -> Self {
|
||||
pub(crate) fn new_agent_reasoning(config: &Config, text: String) -> Self {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("thinking".magenta().italic()));
|
||||
append_markdown(&text, &mut lines);
|
||||
append_markdown(&text, &mut lines, config.file_opener, &config.cwd);
|
||||
lines.push(Line::from(""));
|
||||
|
||||
HistoryCell::AgentReasoning { lines }
|
||||
|
||||
@@ -19,6 +19,7 @@ mod app_event;
|
||||
mod app_event_sender;
|
||||
mod bottom_pane;
|
||||
mod chatwidget;
|
||||
mod citation_regex;
|
||||
mod cli;
|
||||
mod conversation_history_widget;
|
||||
mod exec_command;
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
use codex_core::config::UriBasedFileOpener;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(crate) fn append_markdown(markdown_source: &str, lines: &mut Vec<Line<'static>>) {
|
||||
let markdown = tui_markdown::from_str(markdown_source);
|
||||
use crate::citation_regex::CITATION_REGEX;
|
||||
|
||||
/// Convert the provided markdown to [`ratatui`] [`Line`]s and append them to
|
||||
/// `lines`. Before rendering we rewrite Codex-style file citations of the form
|
||||
/// `【F:path/to/file†L10-L20】` into standard markdown hyperlinks that IDEs such
|
||||
/// as VS Code can interpret when combined with the "URI-based file opener"
|
||||
/// functionality.
|
||||
///
|
||||
/// The specific URI scheme to use is determined by `file_opener` (e.g.
|
||||
/// `vscode`, `vscode-insiders`, `cursor`, `windsurf`). When it is `None` the
|
||||
/// citations are **left unchanged** so they still render as plain text.
|
||||
pub(crate) fn append_markdown(
|
||||
markdown_source: &str,
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
file_opener: Option<UriBasedFileOpener>,
|
||||
cwd: &Path,
|
||||
) {
|
||||
// Perform citation rewrite *before* feeding the string to the markdown
|
||||
// renderer. When `file_opener` is absent we bypass the transformation to
|
||||
// avoid unnecessary allocations.
|
||||
let processed_markdown: std::borrow::Cow<'_, str> = if let Some(scheme) = file_opener {
|
||||
std::borrow::Cow::Owned(rewrite_file_citations(markdown_source, scheme, cwd))
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(markdown_source)
|
||||
};
|
||||
|
||||
let markdown = tui_markdown::from_str(&processed_markdown);
|
||||
|
||||
// `tui_markdown` returns a `ratatui::text::Text` where every `Line` borrows
|
||||
// from the input `message` string. Since the `HistoryCell` stores its lines
|
||||
@@ -28,3 +56,100 @@ pub(crate) fn append_markdown(markdown_source: &str, lines: &mut Vec<Line<'stati
|
||||
lines.push(owned_line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites file citations in `src` into markdown hyperlinks using the
|
||||
/// provided `scheme` (`vscode`, `cursor`, etc.). The resulting URI follows the
|
||||
/// format expected by VS Code-compatible file openers:
|
||||
///
|
||||
/// ```text
|
||||
/// <scheme>://file<ABS_PATH>:<LINE>
|
||||
/// ```
|
||||
fn rewrite_file_citations(src: &str, scheme: UriBasedFileOpener, cwd: &Path) -> String {
|
||||
// Map enum values to the corresponding URI scheme strings.
|
||||
let scheme_str: &str = match scheme {
|
||||
UriBasedFileOpener::VsCode => "vscode",
|
||||
UriBasedFileOpener::VsCodeInsiders => "vscode-insiders",
|
||||
UriBasedFileOpener::Windsurf => "windsurf",
|
||||
UriBasedFileOpener::Cursor => "cursor",
|
||||
};
|
||||
|
||||
CITATION_REGEX
|
||||
.replace_all(src, |caps: ®ex::Captures<'_>| {
|
||||
let file = &caps[1];
|
||||
let start_line = &caps[2];
|
||||
|
||||
// Resolve the path against `cwd` when it is relative.
|
||||
let path = {
|
||||
let p = Path::new(file);
|
||||
if p.is_absolute() {
|
||||
PathBuf::from(p)
|
||||
} else {
|
||||
cwd.join(p)
|
||||
}
|
||||
};
|
||||
|
||||
// VS Code expects forward slashes even on Windows because URIs use
|
||||
// `/` as the path separator.
|
||||
let abs = path.to_string_lossy().replace('\\', "/");
|
||||
|
||||
// Render as a normal markdown link so the downstream renderer emits
|
||||
// the hyperlink escape sequence (when supported by the terminal).
|
||||
format!("[{}]({}://file{}:{})", file, scheme_str, abs, start_line)
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn citation_is_rewritten_with_absolute_path() {
|
||||
let markdown = "See 【F:/src/main.rs†L42-L50】 for details.";
|
||||
let cwd = Path::new("/workspace");
|
||||
let result = rewrite_file_citations(markdown, UriBasedFileOpener::VsCode, cwd);
|
||||
|
||||
assert_eq!(
|
||||
"See [/src/main.rs](vscode://file/src/main.rs:42) for details.",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn citation_is_rewritten_with_relative_path() {
|
||||
let markdown = "Refer to 【F:lib/mod.rs†L5】 here.";
|
||||
let cwd = Path::new("/home/user/project");
|
||||
let result = rewrite_file_citations(markdown, UriBasedFileOpener::Cursor, cwd);
|
||||
|
||||
assert_eq!(
|
||||
"Refer to [lib/mod.rs](cursor://file/home/user/project/lib/mod.rs:5) here.",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn citation_unchanged_without_file_opener() {
|
||||
let markdown = "Look at 【F:file.rs†L1】.";
|
||||
let cwd = Path::new("/");
|
||||
let unchanged = rewrite_file_citations(markdown, UriBasedFileOpener::VsCode, cwd);
|
||||
// The helper itself always rewrites – this test validates behaviour of
|
||||
// append_markdown when `file_opener` is None.
|
||||
let mut out = Vec::new();
|
||||
append_markdown(markdown, &mut out, None, cwd);
|
||||
// Convert lines back to string for comparison.
|
||||
let rendered: String = out
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
assert_eq!(markdown, rendered);
|
||||
// Ensure helper rewrites.
|
||||
assert_ne!(markdown, unchanged);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user