mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add /ide context support to the TUI (#20294)
## Why Users have asked for a `/ide` command in the TUI so Codex can use the active IDE session for live context such as the current file, open tabs, and selected ranges. We already support a similar feature in the Codex desktop app, so bringing it to the TUI makes sense. One subtle compatibility constraint is that the injected prompt wrapper and transcript stripping should match the desktop app and IDE extension. By using the same `## My request for Codex:` delimiter and hiding the injected context from transcript rendering the same way, threads created in the TUI render correctly in desktop and IDE surfaces, and threads created there replay correctly in the TUI, even when IDE context was included. Addresses https://github.com/openai/codex/issues/13834. ## What changed ### Summary This PR consists of four four pieces: 1. An IPC client that uses a socket (Mac/Linux) or named pipe (Windows) to talk to the IDE Extension 2. Logic that establishes the IPC connection and requests IDE context (open files, selection) on demand 3. Logic that injects this context into the user prompt (using the same technique as the desktop app) and hides the added context when rendering the prompt in the TUI transcript 4. A new slash command for enabling/disabling this mode and text within the footer to indicate when it's enabled ### Details - Added `/ide [on|off|status]` to the TUI, with bare `/ide` toggling IDE context on or off. - Added a Rust IDE context client that connects to the local Codex IDE IPC route as a client and requests context from the IDE extension flow. - Injected IDE context using the same prompt delimiter and transcript-stripping convention as the desktop app and IDE extension so shared threads render consistently across surfaces. - Added an `IDE context` status-line indicator while the feature is active and cleared it when enabling or fetching context fails. - Added handling for multiple selection ranges, oversized selections, interleaved IPC messages, and transient reconnect timing after quick toggles. ## Verification Did extensive manual testing in addition to running automated unit and regression tests. To test: - Launch VS Code (or Cursor) with the IDE extension. - Open one or more files in the IDE and select a range of text within one of them. - Start the TUI. - Ask the agent which files you have open in your IDE, and it should say that it does not know. - Enable `/ide` mode; note that `IDE context` appears in the lower right. - Ask the agent what files you have open in your IDE and what text is selected.
This commit is contained in:
committed by
GitHub
Unverified
parent
41e171fcf2
commit
6784db51c0
@@ -131,7 +131,12 @@ libc = { workspace = true }
|
||||
which = { workspace = true }
|
||||
windows-sys = { version = "0.52", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Console",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
winsplit = "0.1"
|
||||
|
||||
|
||||
@@ -121,7 +121,6 @@
|
||||
//! overall state machine, since it affects which transitions are even possible from a given UI
|
||||
//! state.
|
||||
//!
|
||||
use crate::bottom_pane::footer::goal_status_indicator_line;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::has_ctrl_or_alt;
|
||||
@@ -167,7 +166,6 @@ use super::footer::footer_hint_items_width;
|
||||
use super::footer::footer_line_width;
|
||||
use super::footer::inset_footer_hint_area;
|
||||
use super::footer::max_left_width_for_right;
|
||||
use super::footer::mode_indicator_line as collaboration_mode_indicator_line;
|
||||
use super::footer::passive_footer_status_line;
|
||||
use super::footer::render_context_right;
|
||||
use super::footer::render_footer_from_props;
|
||||
@@ -176,6 +174,7 @@ use super::footer::render_footer_line;
|
||||
use super::footer::reset_mode_after_activity;
|
||||
use super::footer::side_conversation_context_line;
|
||||
use super::footer::single_line_footer_layout;
|
||||
use super::footer::status_line_right_indicator_line;
|
||||
use super::footer::toggle_shortcut_mode;
|
||||
use super::footer::uses_passive_footer_status_layout;
|
||||
use super::paste_burst::CharDecision;
|
||||
@@ -385,6 +384,7 @@ pub(crate) struct ChatComposer {
|
||||
config: ChatComposerConfig,
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
goal_status_indicator: Option<GoalStatusIndicator>,
|
||||
ide_context_active: bool,
|
||||
connectors_enabled: bool,
|
||||
plugins_command_enabled: bool,
|
||||
fast_command_enabled: bool,
|
||||
@@ -565,6 +565,7 @@ impl ChatComposer {
|
||||
config,
|
||||
collaboration_mode_indicator: None,
|
||||
goal_status_indicator: None,
|
||||
ide_context_active: false,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
fast_command_enabled: false,
|
||||
@@ -724,6 +725,10 @@ impl ChatComposer {
|
||||
self.goal_status_indicator = indicator;
|
||||
}
|
||||
|
||||
pub fn set_ide_context_active(&mut self, active: bool) {
|
||||
self.ide_context_active = active;
|
||||
}
|
||||
|
||||
pub fn set_personality_command_enabled(&mut self, enabled: bool) {
|
||||
self.personality_command_enabled = enabled;
|
||||
}
|
||||
@@ -1083,14 +1088,16 @@ impl ChatComposer {
|
||||
if let Some(vim_mode) = self.vim_mode_indicator_span() {
|
||||
spans.push(vim_mode);
|
||||
}
|
||||
if let Some(collab) =
|
||||
collaboration_mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint)
|
||||
.or_else(|| goal_status_indicator_line(self.goal_status_indicator.as_ref()))
|
||||
{
|
||||
if let Some(indicators) = status_line_right_indicator_line(
|
||||
self.collaboration_mode_indicator,
|
||||
self.goal_status_indicator.as_ref(),
|
||||
self.ide_context_active,
|
||||
show_cycle_hint,
|
||||
) {
|
||||
if !spans.is_empty() {
|
||||
spans.push(" | ".dim());
|
||||
}
|
||||
spans.extend(collab.spans);
|
||||
spans.extend(indicators.spans);
|
||||
}
|
||||
if spans.is_empty() {
|
||||
None
|
||||
|
||||
@@ -566,6 +566,34 @@ pub(crate) fn goal_status_indicator_line(
|
||||
Some(Line::from(vec![Span::from(label).magenta()]))
|
||||
}
|
||||
|
||||
pub(crate) fn status_line_right_indicator_line(
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
goal_status_indicator: Option<&GoalStatusIndicator>,
|
||||
ide_context_active: bool,
|
||||
show_cycle_hint: bool,
|
||||
) -> Option<Line<'static>> {
|
||||
let primary_indicator = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint)
|
||||
.or_else(|| goal_status_indicator_line(goal_status_indicator));
|
||||
let ide_context_indicator = ide_context_active.then(|| Line::from(vec!["IDE context".cyan()]));
|
||||
let mut line: Option<Line<'static>> = None;
|
||||
|
||||
for indicator in [primary_indicator, ide_context_indicator]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if let Some(line) = line.as_mut() {
|
||||
line.push_span(" · ".dim());
|
||||
for span in indicator.spans {
|
||||
line.push_span(span);
|
||||
}
|
||||
} else {
|
||||
line = Some(indicator);
|
||||
}
|
||||
}
|
||||
|
||||
line
|
||||
}
|
||||
|
||||
pub(crate) fn side_conversation_context_line(label: &str) -> Line<'static> {
|
||||
if let Some(rest) = label.strip_prefix("Side ") {
|
||||
Line::from(vec!["Side".magenta().bold(), format!(" {rest}").magenta()])
|
||||
@@ -1261,6 +1289,7 @@ mod tests {
|
||||
height: u16,
|
||||
props: &FooterProps,
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
ide_context_active: bool,
|
||||
context_line: Line<'static>,
|
||||
) {
|
||||
terminal
|
||||
@@ -1321,9 +1350,16 @@ mod tests {
|
||||
)
|
||||
};
|
||||
let right_line = if status_line_active {
|
||||
let full = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint);
|
||||
let compact = mode_indicator_line(
|
||||
let full = status_line_right_indicator_line(
|
||||
collaboration_mode_indicator,
|
||||
/*goal_status_indicator*/ None,
|
||||
ide_context_active,
|
||||
show_cycle_hint,
|
||||
);
|
||||
let compact = status_line_right_indicator_line(
|
||||
collaboration_mode_indicator,
|
||||
/*goal_status_indicator*/ None,
|
||||
ide_context_active,
|
||||
/*show_cycle_hint*/ false,
|
||||
);
|
||||
let full_width = full.as_ref().map(|line| line.width() as u16).unwrap_or(0);
|
||||
@@ -1448,6 +1484,7 @@ mod tests {
|
||||
height,
|
||||
props,
|
||||
collaboration_mode_indicator,
|
||||
/*ide_context_active*/ false,
|
||||
context_line,
|
||||
);
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
@@ -1466,11 +1503,32 @@ mod tests {
|
||||
height,
|
||||
props,
|
||||
collaboration_mode_indicator,
|
||||
/*ide_context_active*/ false,
|
||||
context_line,
|
||||
);
|
||||
terminal.backend().vt100().screen().contents()
|
||||
}
|
||||
|
||||
fn snapshot_footer_with_indicators(
|
||||
name: &str,
|
||||
width: u16,
|
||||
props: &FooterProps,
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
ide_context_active: bool,
|
||||
) {
|
||||
let height = footer_height(props).max(1);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
draw_footer_frame(
|
||||
&mut terminal,
|
||||
height,
|
||||
props,
|
||||
collaboration_mode_indicator,
|
||||
ide_context_active,
|
||||
context_window_line(/*percent*/ None, /*used_tokens*/ None),
|
||||
);
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_snapshots() {
|
||||
snapshot_footer(
|
||||
@@ -1769,6 +1827,14 @@ mod tests {
|
||||
context_window_line(Some(50), /*used_tokens*/ None),
|
||||
);
|
||||
|
||||
snapshot_footer_with_indicators(
|
||||
"footer_status_line_enabled_mode_and_ide_context_right",
|
||||
/*width*/ 120,
|
||||
&props,
|
||||
Some(CollaborationModeIndicator::Plan),
|
||||
/*ide_context_active*/ true,
|
||||
);
|
||||
|
||||
let props = FooterProps {
|
||||
mode: FooterMode::ComposerEmpty,
|
||||
esc_backtrack_hint: false,
|
||||
|
||||
@@ -380,6 +380,11 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_ide_context_active(&mut self, active: bool) {
|
||||
self.composer.set_ide_context_active(active);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_personality_command_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_personality_command_enabled(enabled);
|
||||
self.request_redraw();
|
||||
|
||||
@@ -165,6 +165,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
commands,
|
||||
vec![
|
||||
SlashCommand::Ide,
|
||||
SlashCommand::Copy,
|
||||
SlashCommand::Diff,
|
||||
SlashCommand::Mention,
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Plan mode (shift+tab to cycle) · IDE context "
|
||||
@@ -320,6 +320,8 @@ use self::goal_status::GoalStatusState;
|
||||
#[cfg(test)]
|
||||
use self::goal_status::goal_status_indicator_from_app_goal;
|
||||
mod goal_menu;
|
||||
mod ide_context;
|
||||
use self::ide_context::IdeContextState;
|
||||
mod interrupts;
|
||||
use self::interrupts::InterruptManager;
|
||||
mod keymap_picker;
|
||||
@@ -838,6 +840,7 @@ pub(crate) struct ChatWidget {
|
||||
connectors_partial_snapshot: Option<ConnectorsSnapshot>,
|
||||
connectors_prefetch_in_flight: bool,
|
||||
connectors_force_refetch_pending: bool,
|
||||
ide_context: IdeContextState,
|
||||
plugins_cache: PluginsCacheState,
|
||||
plugins_fetch_state: PluginListFetchState,
|
||||
plugin_install_apps_needing_auth: Vec<AppSummary>,
|
||||
@@ -1138,6 +1141,7 @@ pub(crate) struct ThreadInputState {
|
||||
composer: Option<ThreadComposerState>,
|
||||
pending_steers: VecDeque<UserMessage>,
|
||||
pending_steer_history_records: VecDeque<UserMessageHistoryRecord>,
|
||||
pending_steer_compare_keys: VecDeque<PendingSteerCompareKey>,
|
||||
rejected_steers_queue: VecDeque<UserMessage>,
|
||||
rejected_steer_history_records: VecDeque<UserMessageHistoryRecord>,
|
||||
queued_user_messages: VecDeque<QueuedUserMessage>,
|
||||
@@ -1451,16 +1455,16 @@ fn user_message_display_for_history(
|
||||
history_record: &UserMessageHistoryRecord,
|
||||
) -> UserMessageDisplay {
|
||||
let message = user_message_for_restore(message, history_record);
|
||||
UserMessageDisplay {
|
||||
message: message.text,
|
||||
remote_image_urls: message.remote_image_urls,
|
||||
local_images: message
|
||||
ChatWidget::user_message_display_from_parts(
|
||||
message.text,
|
||||
message.text_elements,
|
||||
message
|
||||
.local_images
|
||||
.into_iter()
|
||||
.map(|image| image.path)
|
||||
.collect(),
|
||||
text_elements: message.text_elements,
|
||||
}
|
||||
message.remote_image_urls,
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_user_messages_with_history_record(
|
||||
@@ -3235,6 +3239,11 @@ impl ChatWidget {
|
||||
.iter()
|
||||
.map(|pending| pending.history_record.clone())
|
||||
.collect(),
|
||||
pending_steer_compare_keys: self
|
||||
.pending_steers
|
||||
.iter()
|
||||
.map(|pending| pending.compare_key.clone())
|
||||
.collect(),
|
||||
rejected_steers_queue: self.rejected_steers_queue.clone(),
|
||||
rejected_steer_history_records: self.rejected_steer_history_records.clone(),
|
||||
queued_user_messages: self.queued_user_messages.clone(),
|
||||
@@ -3288,16 +3297,19 @@ impl ChatWidget {
|
||||
input_state.pending_steers.len(),
|
||||
UserMessageHistoryRecord::UserMessageText,
|
||||
);
|
||||
let mut pending_steer_compare_keys = input_state.pending_steer_compare_keys;
|
||||
self.pending_steers = input_state
|
||||
.pending_steers
|
||||
.into_iter()
|
||||
.zip(pending_steer_history_records)
|
||||
.map(|(user_message, history_record)| PendingSteer {
|
||||
compare_key: PendingSteerCompareKey {
|
||||
message: user_message.text.clone(),
|
||||
image_count: user_message.local_images.len()
|
||||
+ user_message.remote_image_urls.len(),
|
||||
},
|
||||
compare_key: pending_steer_compare_keys.pop_front().unwrap_or_else(|| {
|
||||
PendingSteerCompareKey {
|
||||
message: user_message.text.clone(),
|
||||
image_count: user_message.local_images.len()
|
||||
+ user_message.remote_image_urls.len(),
|
||||
}
|
||||
}),
|
||||
history_record,
|
||||
user_message,
|
||||
})
|
||||
@@ -4883,6 +4895,7 @@ impl ChatWidget {
|
||||
connectors_partial_snapshot: None,
|
||||
connectors_prefetch_in_flight: false,
|
||||
connectors_force_refetch_pending: false,
|
||||
ide_context: IdeContextState::default(),
|
||||
plugins_cache: PluginsCacheState::default(),
|
||||
plugins_fetch_state: PluginListFetchState::default(),
|
||||
plugin_install_apps_needing_auth: Vec::new(),
|
||||
@@ -5749,6 +5762,9 @@ impl ChatWidget {
|
||||
));
|
||||
return (false, None);
|
||||
}
|
||||
|
||||
self.maybe_apply_ide_context(&mut items);
|
||||
|
||||
let collaboration_mode = if self.collaboration_modes_enabled() {
|
||||
self.active_collaboration_mask
|
||||
.as_ref()
|
||||
@@ -5831,7 +5847,7 @@ impl ChatWidget {
|
||||
|
||||
// Show replayable user content in conversation history.
|
||||
let display_user_message = render_in_history.then(|| {
|
||||
user_message_for_restore(
|
||||
user_message_display_for_history(
|
||||
UserMessage {
|
||||
text,
|
||||
local_images,
|
||||
@@ -5842,49 +5858,8 @@ impl ChatWidget {
|
||||
&history_record,
|
||||
)
|
||||
});
|
||||
if let Some(display_user_message) = display_user_message {
|
||||
let UserMessage {
|
||||
text,
|
||||
local_images,
|
||||
remote_image_urls,
|
||||
text_elements,
|
||||
mention_bindings: _,
|
||||
} = display_user_message;
|
||||
if !text.is_empty() {
|
||||
let local_image_paths = local_images
|
||||
.into_iter()
|
||||
.map(|img| img.path)
|
||||
.collect::<Vec<_>>();
|
||||
self.last_rendered_user_message_display =
|
||||
Some(Self::user_message_display_from_parts(
|
||||
text.clone(),
|
||||
text_elements.clone(),
|
||||
local_image_paths.clone(),
|
||||
remote_image_urls.clone(),
|
||||
));
|
||||
self.add_to_history(history_cell::new_user_prompt(
|
||||
text,
|
||||
text_elements,
|
||||
local_image_paths,
|
||||
remote_image_urls,
|
||||
));
|
||||
self.record_visible_user_turn_for_copy();
|
||||
} else if !remote_image_urls.is_empty() {
|
||||
self.last_rendered_user_message_display =
|
||||
Some(Self::user_message_display_from_parts(
|
||||
String::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
remote_image_urls.clone(),
|
||||
));
|
||||
self.add_to_history(history_cell::new_user_prompt(
|
||||
String::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
remote_image_urls,
|
||||
));
|
||||
self.record_visible_user_turn_for_copy();
|
||||
}
|
||||
if let Some(display) = display_user_message {
|
||||
self.on_user_message_display(display);
|
||||
}
|
||||
|
||||
self.needs_final_message_separator = false;
|
||||
@@ -6622,6 +6597,7 @@ impl ChatWidget {
|
||||
self.last_rendered_user_message_display = Some(display.clone());
|
||||
if !display.message.trim().is_empty()
|
||||
|| !display.text_elements.is_empty()
|
||||
|| !display.local_images.is_empty()
|
||||
|| !display.remote_image_urls.is_empty()
|
||||
{
|
||||
self.record_visible_user_turn_for_copy();
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Chat-widget wiring for the `/ide` command and IDE context prompt injection.
|
||||
|
||||
use codex_app_server_protocol::UserInput;
|
||||
|
||||
use super::ChatWidget;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct IdeContextState {
|
||||
enabled: bool,
|
||||
prompt_fetch_warned: bool,
|
||||
}
|
||||
|
||||
impl IdeContextState {
|
||||
pub(super) fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
fn enable(&mut self) {
|
||||
self.enabled = true;
|
||||
self.prompt_fetch_warned = false;
|
||||
}
|
||||
|
||||
fn disable(&mut self) {
|
||||
self.enabled = false;
|
||||
self.prompt_fetch_warned = false;
|
||||
}
|
||||
|
||||
fn mark_available(&mut self) {
|
||||
self.prompt_fetch_warned = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn handle_ide_command(&mut self) {
|
||||
if self.ide_context.is_enabled() {
|
||||
self.ide_context.disable();
|
||||
self.sync_ide_context_status_indicator();
|
||||
self.add_info_message("IDE context is off.".to_string(), /*hint*/ None);
|
||||
} else {
|
||||
self.ide_context.enable();
|
||||
self.add_ide_context_status_message();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_ide_command_args(&mut self, args: &str) {
|
||||
match args.to_ascii_lowercase().as_str() {
|
||||
"" => self.handle_ide_command(),
|
||||
"on" => {
|
||||
self.ide_context.enable();
|
||||
self.add_ide_context_status_message();
|
||||
}
|
||||
"off" => {
|
||||
self.ide_context.disable();
|
||||
self.sync_ide_context_status_indicator();
|
||||
self.add_info_message("IDE context is off.".to_string(), /*hint*/ None);
|
||||
}
|
||||
"status" => {
|
||||
self.add_ide_context_status_message();
|
||||
}
|
||||
_ => {
|
||||
self.add_error_message("Usage: /ide [on|off|status]".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches fresh IDE context for the outgoing user turn and folds it into the prompt.
|
||||
pub(super) fn maybe_apply_ide_context(&mut self, items: &mut Vec<UserInput>) {
|
||||
if !self.ide_context.is_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
match crate::ide_context::fetch_ide_context(&self.config.cwd) {
|
||||
Ok(context) => {
|
||||
self.ide_context.mark_available();
|
||||
self.sync_ide_context_status_indicator();
|
||||
crate::ide_context::apply_ide_context_to_user_input(&context, items);
|
||||
}
|
||||
Err(err) => {
|
||||
self.sync_ide_context_status_indicator();
|
||||
if !self.ide_context.prompt_fetch_warned {
|
||||
self.ide_context.prompt_fetch_warned = true;
|
||||
self.add_info_message(
|
||||
"IDE context was skipped for this message.".to_string(),
|
||||
Some(err.prompt_skip_hint()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_ide_context_status_message(&mut self) {
|
||||
if !self.ide_context.is_enabled() {
|
||||
self.sync_ide_context_status_indicator();
|
||||
self.add_info_message("IDE context is off.".to_string(), /*hint*/ None);
|
||||
return;
|
||||
}
|
||||
|
||||
match crate::ide_context::fetch_ide_context(&self.config.cwd) {
|
||||
Ok(context) => {
|
||||
self.ide_context.mark_available();
|
||||
self.sync_ide_context_status_indicator();
|
||||
if crate::ide_context::has_prompt_context(&context) {
|
||||
self.add_info_message(
|
||||
"IDE context is on.".to_string(),
|
||||
Some(
|
||||
"Future messages will include your current IDE selection and open tabs."
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
self.add_info_message(
|
||||
"IDE context is on.".to_string(),
|
||||
Some("Connected to your IDE.".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.ide_context.disable();
|
||||
self.sync_ide_context_status_indicator();
|
||||
self.add_info_message(
|
||||
"IDE context could not be enabled.".to_string(),
|
||||
Some(err.user_facing_hint()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sync_ide_context_status_indicator(&mut self) {
|
||||
self.bottom_pane
|
||||
.set_ide_context_active(self.ide_context.is_enabled());
|
||||
}
|
||||
}
|
||||
@@ -363,6 +363,9 @@ impl ChatWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
SlashCommand::Ide => {
|
||||
self.handle_ide_command();
|
||||
}
|
||||
SlashCommand::DebugConfig => {
|
||||
self.add_debug_config_output();
|
||||
}
|
||||
@@ -572,6 +575,9 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
}
|
||||
SlashCommand::Ide => {
|
||||
self.handle_ide_command_args(trimmed);
|
||||
}
|
||||
SlashCommand::Mcp => match trimmed.to_ascii_lowercase().as_str() {
|
||||
"verbose" => self.add_mcp_output(McpServerStatusDetail::Full),
|
||||
_ => self.add_error_message("Usage: /mcp [verbose]".to_string()),
|
||||
@@ -835,6 +841,7 @@ impl ChatWidget {
|
||||
}
|
||||
match cmd {
|
||||
SlashCommand::Fast
|
||||
| SlashCommand::Ide
|
||||
| SlashCommand::Status
|
||||
| SlashCommand::DebugConfig
|
||||
| SlashCommand::Ps
|
||||
|
||||
@@ -931,6 +931,7 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
|
||||
composer: None,
|
||||
pending_steers: VecDeque::new(),
|
||||
pending_steer_history_records: VecDeque::new(),
|
||||
pending_steer_compare_keys: VecDeque::new(),
|
||||
rejected_steers_queue: VecDeque::new(),
|
||||
rejected_steer_history_records: VecDeque::new(),
|
||||
queued_user_messages: VecDeque::new(),
|
||||
@@ -1180,6 +1181,68 @@ fn user_message_display_from_inputs_matches_flattened_user_message_shape() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_message_display_from_inputs_hides_prompt_context() {
|
||||
let raw_message = "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\nAsk $figma";
|
||||
let mention_start = raw_message.find("$figma").expect("mention in raw message");
|
||||
let rendered = ChatWidget::user_message_display_from_inputs(&[UserInput::Text {
|
||||
text: raw_message.to_string(),
|
||||
text_elements: vec![
|
||||
TextElement::new(
|
||||
(mention_start..mention_start + "$figma".len()).into(),
|
||||
Some("$figma".to_string()),
|
||||
)
|
||||
.into(),
|
||||
],
|
||||
}]);
|
||||
|
||||
assert_eq!(
|
||||
rendered,
|
||||
ChatWidget::user_message_display_from_parts(
|
||||
"Ask $figma".to_string(),
|
||||
vec![TextElement::new((4..10).into(), Some("$figma".to_string()))],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_user_message_with_hidden_prompt_context_renders_local_images() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let local_image = PathBuf::from("/tmp/context-image.png");
|
||||
let raw_message =
|
||||
"# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\n";
|
||||
|
||||
complete_user_message_for_inputs(
|
||||
&mut chat,
|
||||
"user-1",
|
||||
vec![
|
||||
UserInput::Text {
|
||||
text: raw_message.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
UserInput::LocalImage {
|
||||
path: local_image.clone(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
let mut user_cell = None;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = event
|
||||
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
|
||||
{
|
||||
user_cell = Some((cell.message.clone(), cell.local_image_paths.clone()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let (message, local_images) = user_cell.expect("expected user history cell");
|
||||
assert_eq!(message, "");
|
||||
assert_eq!(local_images, vec![local_image]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupt_restores_queued_messages_into_composer() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -249,6 +249,7 @@ pub(super) async fn make_chatwidget_manual(
|
||||
newly_installed_marketplace_tab_id: None,
|
||||
connectors_prefetch_in_flight: false,
|
||||
connectors_force_refetch_pending: false,
|
||||
ide_context: super::super::ide_context::IdeContextState::default(),
|
||||
plugins_cache: PluginsCacheState::default(),
|
||||
plugins_fetch_state: PluginListFetchState::default(),
|
||||
interrupts: InterruptManager::new(),
|
||||
|
||||
@@ -400,7 +400,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_user_message_with_only_local_images_does_not_render_history_cell() {
|
||||
async fn replayed_user_message_with_only_local_images_renders_history_cell() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
let local_images = [PathBuf::from("/tmp/replay-local-only.png")];
|
||||
@@ -438,17 +438,20 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce
|
||||
ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
|
||||
let mut found_user_history_cell = false;
|
||||
let mut user_cell = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = ev
|
||||
&& cell.as_any().downcast_ref::<UserHistoryCell>().is_some()
|
||||
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
|
||||
{
|
||||
found_user_history_cell = true;
|
||||
user_cell = Some((cell.message.clone(), cell.local_image_paths.clone()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(!found_user_history_cell);
|
||||
let (stored_message, stored_local_images) =
|
||||
user_cell.expect("expected a replayed local-image-only user history cell");
|
||||
assert!(stored_message.is_empty());
|
||||
assert_eq!(stored_local_images, local_images);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -333,6 +333,12 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let mut pending_steers = VecDeque::new();
|
||||
pending_steers.push_back(UserMessage::from("pending steer"));
|
||||
let expected_compare_key = PendingSteerCompareKey {
|
||||
message: "hidden IDE context\npending steer".to_string(),
|
||||
image_count: 0,
|
||||
};
|
||||
let mut pending_steer_compare_keys = VecDeque::new();
|
||||
pending_steer_compare_keys.push_back(expected_compare_key.clone());
|
||||
let mut rejected_steers_queue = VecDeque::new();
|
||||
rejected_steers_queue.push_back(UserMessage::from("already rejected"));
|
||||
let mut queued_user_messages = VecDeque::new();
|
||||
@@ -342,6 +348,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
|
||||
composer: None,
|
||||
pending_steers,
|
||||
pending_steer_history_records: VecDeque::new(),
|
||||
pending_steer_compare_keys,
|
||||
rejected_steers_queue,
|
||||
rejected_steer_history_records: VecDeque::new(),
|
||||
queued_user_messages,
|
||||
@@ -362,6 +369,10 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
|
||||
chat.pending_steers.front().unwrap().user_message.text,
|
||||
"pending steer"
|
||||
);
|
||||
assert_eq!(
|
||||
chat.pending_steers.front().unwrap().compare_key,
|
||||
expected_compare_key
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
|
||||
use super::ChatWidget;
|
||||
@@ -33,8 +34,30 @@ impl ChatWidget {
|
||||
local_images: Vec<PathBuf>,
|
||||
remote_image_urls: Vec<String>,
|
||||
) -> UserMessageDisplay {
|
||||
let (message, prompt_request_offset) =
|
||||
crate::ide_context::extract_prompt_request_with_offset(&message);
|
||||
let prompt_request_end = prompt_request_offset + message.len();
|
||||
// Prompt context uses the same delimiter and stripping behavior as the desktop app and IDE
|
||||
// extension. The raw user message goes to the agent, but every surface renders only the
|
||||
// request after that delimiter, so keep elements inside the visible request and shift their
|
||||
// byte ranges to match.
|
||||
let text_elements = text_elements
|
||||
.into_iter()
|
||||
.filter_map(|element| {
|
||||
let range = element.byte_range;
|
||||
if range.start < prompt_request_offset || range.end > prompt_request_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(element.map_range(|range| ByteRange {
|
||||
start: range.start - prompt_request_offset,
|
||||
end: range.end - prompt_request_offset,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
UserMessageDisplay {
|
||||
message,
|
||||
message: message.to_string(),
|
||||
remote_image_urls,
|
||||
local_images,
|
||||
text_elements,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! IDE context data model and public helpers for TUI `/ide` support.
|
||||
|
||||
mod ipc;
|
||||
mod prompt;
|
||||
#[cfg(windows)]
|
||||
mod windows_pipe;
|
||||
|
||||
pub(crate) use ipc::fetch_ide_context;
|
||||
pub(crate) use prompt::apply_ide_context_to_user_input;
|
||||
pub(crate) use prompt::extract_prompt_request_with_offset;
|
||||
pub(crate) use prompt::has_prompt_context;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct IdeContext {
|
||||
active_file: Option<ActiveFile>,
|
||||
#[serde(default)]
|
||||
open_tabs: Vec<FileDescriptor>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ActiveFile {
|
||||
#[serde(flatten)]
|
||||
descriptor: FileDescriptor,
|
||||
selection: Range,
|
||||
#[serde(default)]
|
||||
active_selection_content: String,
|
||||
#[serde(default)]
|
||||
selections: Vec<Range>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FileDescriptor {
|
||||
label: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
struct Range {
|
||||
start: Position,
|
||||
end: Position,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
struct Position {
|
||||
line: u32,
|
||||
character: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn deserializes_existing_ide_context_shape() {
|
||||
let value = json!({
|
||||
"activeFile": {
|
||||
"label": "lib.rs",
|
||||
"path": "src/lib.rs",
|
||||
"fsPath": "/repo/src/lib.rs",
|
||||
"selection": {
|
||||
"start": { "line": 1, "character": 2 },
|
||||
"end": { "line": 3, "character": 4 }
|
||||
},
|
||||
"activeSelectionContent": "selected",
|
||||
"selections": []
|
||||
},
|
||||
"openTabs": [
|
||||
{
|
||||
"label": "main.rs",
|
||||
"path": "src/main.rs",
|
||||
"fsPath": "/repo/src/main.rs",
|
||||
"startLine": 2,
|
||||
"endLine": 10
|
||||
}
|
||||
],
|
||||
"processEnv": {
|
||||
"path": "/usr/bin"
|
||||
}
|
||||
});
|
||||
|
||||
let context: IdeContext = serde_json::from_value(value).expect("deserialize ide context");
|
||||
assert_eq!(
|
||||
context,
|
||||
IdeContext {
|
||||
active_file: Some(ActiveFile {
|
||||
descriptor: FileDescriptor {
|
||||
label: "lib.rs".to_string(),
|
||||
path: "src/lib.rs".to_string(),
|
||||
},
|
||||
selection: Range {
|
||||
start: Position {
|
||||
line: 1,
|
||||
character: 2,
|
||||
},
|
||||
end: Position {
|
||||
line: 3,
|
||||
character: 4,
|
||||
},
|
||||
},
|
||||
active_selection_content: "selected".to_string(),
|
||||
selections: Vec::new(),
|
||||
}),
|
||||
open_tabs: vec![FileDescriptor {
|
||||
label: "main.rs".to_string(),
|
||||
path: "src/main.rs".to_string(),
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
||||
//! Prompt rendering for IDE context injected into TUI user turns.
|
||||
|
||||
use codex_app_server_protocol::ByteRange;
|
||||
use codex_app_server_protocol::TextElement;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
|
||||
use super::IdeContext;
|
||||
|
||||
const MAX_ACTIVE_SELECTION_CHARS: usize = 40_000;
|
||||
const MAX_OPEN_TABS: usize = 100;
|
||||
const MAX_OPEN_TABS_CHARS: usize = 20_000;
|
||||
// Match the desktop app and IDE extension delimiter exactly. IDE context is serialized into the
|
||||
// raw prompt before this marker, then transcript rendering strips back to the request after the last
|
||||
// marker. Keeping the same marker and stripping semantics lets threads created with IDE context in
|
||||
// one surface replay cleanly in the others.
|
||||
const PROMPT_REQUEST_BEGIN: &str = "## My request for Codex:";
|
||||
|
||||
pub(crate) fn apply_ide_context_to_user_input(
|
||||
context: &IdeContext,
|
||||
items: &mut Vec<UserInput>,
|
||||
) -> bool {
|
||||
let Some(context_text) = render_prompt_context(context) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let prefix = format!("{context_text}\n{PROMPT_REQUEST_BEGIN}\n");
|
||||
if let Some(text_index) = items
|
||||
.iter()
|
||||
.position(|item| matches!(item, UserInput::Text { .. }))
|
||||
{
|
||||
// Prefix the existing text item in place so image and text items keep
|
||||
// the same relative order they had in the user's original submission.
|
||||
let item = std::mem::replace(
|
||||
&mut items[text_index],
|
||||
UserInput::Text {
|
||||
text: String::new(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
);
|
||||
let UserInput::Text {
|
||||
text,
|
||||
text_elements,
|
||||
} = item
|
||||
else {
|
||||
unreachable!("position matched a text item");
|
||||
};
|
||||
items[text_index] = prefixed_text_input(prefix, text, text_elements);
|
||||
} else {
|
||||
items.insert(
|
||||
0,
|
||||
UserInput::Text {
|
||||
text: prefix,
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn has_prompt_context(context: &IdeContext) -> bool {
|
||||
render_prompt_context(context).is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn extract_prompt_request_with_offset(message: &str) -> (&str, usize) {
|
||||
let Some((before_request, request)) = message.rsplit_once(PROMPT_REQUEST_BEGIN) else {
|
||||
return (message, 0);
|
||||
};
|
||||
|
||||
let request_start = before_request.len() + PROMPT_REQUEST_BEGIN.len();
|
||||
let trimmed_request = request.trim();
|
||||
let leading_trimmed_len = request.len() - request.trim_start().len();
|
||||
(trimmed_request, request_start + leading_trimmed_len)
|
||||
}
|
||||
|
||||
fn prefixed_text_input(prefix: String, text: String, text_elements: Vec<TextElement>) -> UserInput {
|
||||
let prefix_len = prefix.len();
|
||||
UserInput::Text {
|
||||
text: format!("{prefix}{text}"),
|
||||
text_elements: text_elements
|
||||
.into_iter()
|
||||
.map(|element| {
|
||||
let range = element.byte_range.clone();
|
||||
TextElement::new(
|
||||
ByteRange {
|
||||
start: range.start + prefix_len,
|
||||
end: range.end + prefix_len,
|
||||
},
|
||||
element.placeholder().map(str::to_string),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_prompt_context(context: &IdeContext) -> Option<String> {
|
||||
let mut ide_context_section = String::new();
|
||||
|
||||
if let Some(active_file) = &context.active_file {
|
||||
ide_context_section.push_str(&format!(
|
||||
"\n## Active file: {}\n",
|
||||
active_file.descriptor.path
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(active_file) = &context.active_file {
|
||||
let selected_ranges = if active_file.selections.is_empty() {
|
||||
std::slice::from_ref(&active_file.selection)
|
||||
} else {
|
||||
active_file.selections.as_slice()
|
||||
}
|
||||
.iter()
|
||||
.filter(|range| range.start != range.end)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !selected_ranges.is_empty()
|
||||
&& (active_file.active_selection_content.is_empty() || selected_ranges.len() > 1)
|
||||
{
|
||||
if selected_ranges.len() == 1 {
|
||||
ide_context_section.push_str("\n## Active selection range:\n");
|
||||
} else {
|
||||
ide_context_section.push_str("\n## Active selection ranges:\n");
|
||||
}
|
||||
for range in selected_ranges {
|
||||
// Render ranges as 1-based positions for the prompt.
|
||||
let start_line = range.start.line + 1;
|
||||
let start_column = range.start.character + 1;
|
||||
let end_line = range.end.line + 1;
|
||||
let end_column = range.end.character + 1;
|
||||
ide_context_section.push_str(&format!(
|
||||
"- {}: line {start_line}, column {start_column} to line {end_line}, column {end_column}\n",
|
||||
active_file.descriptor.path
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(active_file) = &context.active_file
|
||||
&& !active_file.active_selection_content.is_empty()
|
||||
{
|
||||
ide_context_section.push_str("\n## Active selection of the file:\n");
|
||||
let selection = active_file.active_selection_content.as_str();
|
||||
if let Some((truncate_at, _)) = selection.char_indices().nth(MAX_ACTIVE_SELECTION_CHARS) {
|
||||
ide_context_section.push_str(&selection[..truncate_at]);
|
||||
ide_context_section.push_str(&format!(
|
||||
"\n[Selection truncated to {MAX_ACTIVE_SELECTION_CHARS} characters.]\n"
|
||||
));
|
||||
} else {
|
||||
ide_context_section.push_str(selection);
|
||||
}
|
||||
}
|
||||
|
||||
if !context.open_tabs.is_empty() {
|
||||
ide_context_section.push_str("\n## Open tabs:\n");
|
||||
let mut rendered_tabs = 0;
|
||||
let mut rendered_tab_chars = 0;
|
||||
for tab in &context.open_tabs {
|
||||
if rendered_tabs >= MAX_OPEN_TABS {
|
||||
break;
|
||||
}
|
||||
|
||||
let tab_line = format!("- {}: {}\n", tab.label, tab.path);
|
||||
if rendered_tab_chars + tab_line.len() > MAX_OPEN_TABS_CHARS {
|
||||
break;
|
||||
}
|
||||
|
||||
ide_context_section.push_str(&tab_line);
|
||||
rendered_tabs += 1;
|
||||
rendered_tab_chars += tab_line.len();
|
||||
}
|
||||
|
||||
let omitted_tabs = context.open_tabs.len() - rendered_tabs;
|
||||
if omitted_tabs > 0 {
|
||||
ide_context_section.push_str(&format!("[{omitted_tabs} open tabs omitted.]\n"));
|
||||
}
|
||||
}
|
||||
|
||||
if ide_context_section.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"# Context from my IDE setup:\n{ide_context_section}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::ActiveFile;
|
||||
use super::super::FileDescriptor;
|
||||
use super::super::IdeContext;
|
||||
use super::super::Position;
|
||||
use super::super::Range;
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn descriptor(label: &str, path: &str) -> FileDescriptor {
|
||||
FileDescriptor {
|
||||
label: label.to_string(),
|
||||
path: path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_prompt_context_matches_app_format() {
|
||||
let context = IdeContext {
|
||||
active_file: Some(ActiveFile {
|
||||
descriptor: descriptor("lib.rs", "src/lib.rs"),
|
||||
selection: Range {
|
||||
start: Position {
|
||||
line: 4,
|
||||
character: 0,
|
||||
},
|
||||
end: Position {
|
||||
line: 6,
|
||||
character: 1,
|
||||
},
|
||||
},
|
||||
active_selection_content: "fn selected() {}".to_string(),
|
||||
selections: Vec::new(),
|
||||
}),
|
||||
open_tabs: vec![
|
||||
descriptor("lib.rs", "src/lib.rs"),
|
||||
descriptor("main.rs", "src/main.rs"),
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
render_prompt_context(&context),
|
||||
Some(
|
||||
"# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## Active selection of the file:\nfn selected() {}\n## Open tabs:\n- lib.rs: src/lib.rs\n- main.rs: src/main.rs\n"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_prompt_context_omits_empty_context() {
|
||||
let context = IdeContext {
|
||||
active_file: None,
|
||||
open_tabs: Vec::new(),
|
||||
};
|
||||
|
||||
assert_eq!(render_prompt_context(&context), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_ide_context_uses_desktop_prompt_request_delimiter() {
|
||||
let context = IdeContext {
|
||||
active_file: Some(ActiveFile {
|
||||
descriptor: descriptor("lib.rs", "src/lib.rs"),
|
||||
selection: Range {
|
||||
start: Position {
|
||||
line: 0,
|
||||
character: 0,
|
||||
},
|
||||
end: Position {
|
||||
line: 0,
|
||||
character: 0,
|
||||
},
|
||||
},
|
||||
active_selection_content: String::new(),
|
||||
selections: Vec::new(),
|
||||
}),
|
||||
open_tabs: Vec::new(),
|
||||
};
|
||||
let text = "Ask $figma".to_string();
|
||||
let mut items = vec![
|
||||
UserInput::LocalImage {
|
||||
path: PathBuf::from("/tmp/screenshot.png"),
|
||||
},
|
||||
UserInput::Text {
|
||||
text,
|
||||
text_elements: vec![TextElement::new(
|
||||
ByteRange { start: 4, end: 10 },
|
||||
Some("$figma".to_string()),
|
||||
)],
|
||||
},
|
||||
];
|
||||
|
||||
assert!(apply_ide_context_to_user_input(&context, &mut items));
|
||||
|
||||
let expected_prefix = "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\n";
|
||||
let prefix_len = expected_prefix.len();
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![
|
||||
UserInput::LocalImage {
|
||||
path: PathBuf::from("/tmp/screenshot.png"),
|
||||
},
|
||||
UserInput::Text {
|
||||
text: format!("{expected_prefix}Ask $figma"),
|
||||
text_elements: vec![TextElement::new(
|
||||
ByteRange {
|
||||
start: prefix_len + 4,
|
||||
end: prefix_len + 10,
|
||||
},
|
||||
Some("$figma".to_string()),
|
||||
)],
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_prompt_request_returns_text_after_last_delimiter() {
|
||||
let message =
|
||||
"# Context\n## My request for Codex:\nFirst\n## My request for Codex:\n Second\n";
|
||||
|
||||
assert_eq!(
|
||||
extract_prompt_request_with_offset(message),
|
||||
("Second", message.find("Second").expect("request offset"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_prompt_context_includes_selection_ranges_without_content() {
|
||||
let first_range = Range {
|
||||
start: Position {
|
||||
line: 1,
|
||||
character: 2,
|
||||
},
|
||||
end: Position {
|
||||
line: 1,
|
||||
character: 5,
|
||||
},
|
||||
};
|
||||
let second_range = Range {
|
||||
start: Position {
|
||||
line: 3,
|
||||
character: 0,
|
||||
},
|
||||
end: Position {
|
||||
line: 4,
|
||||
character: 1,
|
||||
},
|
||||
};
|
||||
let context = IdeContext {
|
||||
active_file: Some(ActiveFile {
|
||||
descriptor: descriptor("lib.rs", "src/lib.rs"),
|
||||
selection: first_range.clone(),
|
||||
active_selection_content: String::new(),
|
||||
selections: vec![first_range, second_range],
|
||||
}),
|
||||
open_tabs: Vec::new(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
render_prompt_context(&context),
|
||||
Some(
|
||||
"# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## Active selection ranges:\n- src/lib.rs: line 2, column 3 to line 2, column 6\n- src/lib.rs: line 4, column 1 to line 5, column 2\n"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_prompt_context_truncates_large_selection() {
|
||||
let context = IdeContext {
|
||||
active_file: Some(ActiveFile {
|
||||
descriptor: descriptor("large.txt", "large.txt"),
|
||||
selection: Range {
|
||||
start: Position {
|
||||
line: 0,
|
||||
character: 0,
|
||||
},
|
||||
end: Position {
|
||||
line: 0,
|
||||
character: 1,
|
||||
},
|
||||
},
|
||||
active_selection_content: format!("{}tail", "a".repeat(MAX_ACTIVE_SELECTION_CHARS)),
|
||||
selections: Vec::new(),
|
||||
}),
|
||||
open_tabs: Vec::new(),
|
||||
};
|
||||
|
||||
let rendered = render_prompt_context(&context).expect("rendered IDE context");
|
||||
assert!(rendered.contains(&format!(
|
||||
"[Selection truncated to {MAX_ACTIVE_SELECTION_CHARS} characters.]"
|
||||
)));
|
||||
assert!(!rendered.contains("tail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_prompt_context_omits_excess_open_tabs() {
|
||||
let open_tabs = (0..MAX_OPEN_TABS + 2)
|
||||
.map(|index| descriptor(&format!("file-{index}.rs"), &format!("src/file-{index}.rs")))
|
||||
.collect::<Vec<_>>();
|
||||
let context = IdeContext {
|
||||
active_file: None,
|
||||
open_tabs,
|
||||
};
|
||||
|
||||
let rendered = render_prompt_context(&context).expect("rendered IDE context");
|
||||
assert!(rendered.contains("- file-99.rs: src/file-99.rs\n"));
|
||||
assert!(!rendered.contains("- file-100.rs: src/file-100.rs\n"));
|
||||
assert!(rendered.contains("[2 open tabs omitted.]\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Windows named-pipe transport for the IDE context IPC client.
|
||||
|
||||
use std::io;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::path::PathBuf;
|
||||
use std::ptr;
|
||||
use std::time::Instant;
|
||||
|
||||
use windows_sys::Win32::Foundation::BOOL;
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::Foundation::ERROR_IO_PENDING;
|
||||
use windows_sys::Win32::Foundation::ERROR_NOT_FOUND;
|
||||
use windows_sys::Win32::Foundation::GENERIC_READ;
|
||||
use windows_sys::Win32::Foundation::GENERIC_WRITE;
|
||||
use windows_sys::Win32::Foundation::HANDLE;
|
||||
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
|
||||
use windows_sys::Win32::Foundation::WAIT_FAILED;
|
||||
use windows_sys::Win32::Foundation::WAIT_OBJECT_0;
|
||||
use windows_sys::Win32::Foundation::WAIT_TIMEOUT;
|
||||
use windows_sys::Win32::Security::EqualSid;
|
||||
use windows_sys::Win32::Security::GetTokenInformation;
|
||||
use windows_sys::Win32::Security::TOKEN_QUERY;
|
||||
use windows_sys::Win32::Security::TOKEN_USER;
|
||||
use windows_sys::Win32::Security::TokenUser;
|
||||
use windows_sys::Win32::Storage::FileSystem::CreateFileW;
|
||||
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL;
|
||||
use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OVERLAPPED;
|
||||
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ;
|
||||
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE;
|
||||
use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING;
|
||||
use windows_sys::Win32::Storage::FileSystem::ReadFile;
|
||||
use windows_sys::Win32::Storage::FileSystem::WriteFile;
|
||||
use windows_sys::Win32::System::IO::CancelIoEx;
|
||||
use windows_sys::Win32::System::IO::GetOverlappedResult;
|
||||
use windows_sys::Win32::System::IO::OVERLAPPED;
|
||||
use windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId;
|
||||
use windows_sys::Win32::System::Threading::CreateEventW;
|
||||
use windows_sys::Win32::System::Threading::GetCurrentProcess;
|
||||
use windows_sys::Win32::System::Threading::OpenProcess;
|
||||
use windows_sys::Win32::System::Threading::OpenProcessToken;
|
||||
use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION;
|
||||
use windows_sys::Win32::System::Threading::WaitForSingleObject;
|
||||
|
||||
const TRUE: BOOL = 1;
|
||||
const FALSE: BOOL = 0;
|
||||
const NULL_HANDLE: HANDLE = 0;
|
||||
|
||||
pub(super) struct WindowsPipeStream {
|
||||
handle: OwnedHandle,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
impl WindowsPipeStream {
|
||||
pub(super) fn connect(pipe_path: PathBuf, deadline: Instant) -> io::Result<Self> {
|
||||
let wide_path = pipe_path
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide_path.as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
ptr::null(),
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED,
|
||||
NULL_HANDLE,
|
||||
)
|
||||
};
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let handle = OwnedHandle(handle);
|
||||
validate_pipe_server_owner(handle.raw())?;
|
||||
|
||||
Ok(Self { handle, deadline })
|
||||
}
|
||||
|
||||
pub(super) fn set_deadline(&mut self, deadline: Instant) {
|
||||
self.deadline = deadline;
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for WindowsPipeStream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if buf.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let bytes_to_read = u32::try_from(buf.len()).unwrap_or(u32::MAX);
|
||||
let mut operation = OverlappedOperation::new()?;
|
||||
let result = unsafe {
|
||||
ReadFile(
|
||||
self.handle.raw(),
|
||||
buf.as_mut_ptr(),
|
||||
bytes_to_read,
|
||||
ptr::null_mut(),
|
||||
operation.as_mut_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
operation.complete(self.handle.raw(), result, self.deadline)
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for WindowsPipeStream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
if buf.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let bytes_to_write = u32::try_from(buf.len()).unwrap_or(u32::MAX);
|
||||
let mut operation = OverlappedOperation::new()?;
|
||||
let result = unsafe {
|
||||
WriteFile(
|
||||
self.handle.raw(),
|
||||
buf.as_ptr(),
|
||||
bytes_to_write,
|
||||
ptr::null_mut(),
|
||||
operation.as_mut_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
operation.complete(self.handle.raw(), result, self.deadline)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct OverlappedOperation {
|
||||
event: OwnedHandle,
|
||||
overlapped: OVERLAPPED,
|
||||
}
|
||||
|
||||
impl OverlappedOperation {
|
||||
fn new() -> io::Result<Self> {
|
||||
let event = unsafe { CreateEventW(ptr::null(), TRUE, FALSE, ptr::null()) };
|
||||
if event == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let mut overlapped = unsafe { std::mem::zeroed::<OVERLAPPED>() };
|
||||
overlapped.hEvent = event;
|
||||
Ok(Self {
|
||||
event: OwnedHandle(event),
|
||||
overlapped,
|
||||
})
|
||||
}
|
||||
|
||||
fn as_mut_ptr(&mut self) -> *mut OVERLAPPED {
|
||||
&mut self.overlapped
|
||||
}
|
||||
|
||||
fn complete(
|
||||
&mut self,
|
||||
handle: HANDLE,
|
||||
initial_result: BOOL,
|
||||
deadline: Instant,
|
||||
) -> io::Result<usize> {
|
||||
if initial_result == 0 {
|
||||
let error = io::Error::last_os_error();
|
||||
if error.raw_os_error() != Some(ERROR_IO_PENDING as i32) {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
// Use a zero wait after the deadline so pending overlapped I/O still flows through
|
||||
// cancel_and_timeout instead of returning while the OS operation owns this OVERLAPPED.
|
||||
match unsafe { WaitForSingleObject(self.event.raw(), remaining_timeout_ms(deadline)) } {
|
||||
WAIT_OBJECT_0 => {}
|
||||
WAIT_TIMEOUT => return Err(self.cancel_and_timeout(handle)),
|
||||
WAIT_FAILED => return Err(io::Error::last_os_error()),
|
||||
other => {
|
||||
return Err(io::Error::other(format!(
|
||||
"unexpected WaitForSingleObject result: {other}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut bytes_transferred = 0;
|
||||
let result = unsafe {
|
||||
GetOverlappedResult(handle, self.as_mut_ptr(), &mut bytes_transferred, FALSE)
|
||||
};
|
||||
if result == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
Ok(bytes_transferred as usize)
|
||||
}
|
||||
|
||||
fn cancel_and_timeout(&mut self, handle: HANDLE) -> io::Error {
|
||||
let cancel_result = unsafe { CancelIoEx(handle, self.as_mut_ptr()) };
|
||||
if cancel_result == 0 {
|
||||
let cancel_error = io::Error::last_os_error();
|
||||
if cancel_error.raw_os_error() != Some(ERROR_NOT_FOUND as i32) {
|
||||
return cancel_error;
|
||||
}
|
||||
|
||||
// ERROR_NOT_FOUND means the operation completed before cancellation was issued. Drain
|
||||
// it without waiting so the timeout path cannot block past the caller's deadline.
|
||||
let mut bytes_transferred = 0;
|
||||
unsafe {
|
||||
GetOverlappedResult(handle, self.as_mut_ptr(), &mut bytes_transferred, FALSE)
|
||||
};
|
||||
return timeout_io_error();
|
||||
}
|
||||
|
||||
let mut bytes_transferred = 0;
|
||||
unsafe {
|
||||
GetOverlappedResult(handle, self.as_mut_ptr(), &mut bytes_transferred, TRUE);
|
||||
}
|
||||
timeout_io_error()
|
||||
}
|
||||
}
|
||||
|
||||
struct OwnedHandle(HANDLE);
|
||||
|
||||
impl OwnedHandle {
|
||||
fn raw(&self) -> HANDLE {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OwnedHandle {
|
||||
fn drop(&mut self) {
|
||||
if self.0 != 0 && self.0 != INVALID_HANDLE_VALUE {
|
||||
unsafe {
|
||||
CloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TokenUserBuffer {
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl TokenUserBuffer {
|
||||
fn sid(&self) -> io::Result<windows_sys::Win32::Foundation::PSID> {
|
||||
if self.buffer.len() < std::mem::size_of::<TOKEN_USER>() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"token user buffer is too small",
|
||||
));
|
||||
}
|
||||
|
||||
// GetTokenInformation writes TOKEN_USER into a byte buffer. Vec<u8> has
|
||||
// no TOKEN_USER alignment guarantee, so copy the fixed header out with
|
||||
// an unaligned read before using its SID pointer.
|
||||
let token_user =
|
||||
unsafe { std::ptr::read_unaligned(self.buffer.as_ptr() as *const TOKEN_USER) };
|
||||
Ok(token_user.User.Sid)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_pipe_server_owner(pipe_handle: HANDLE) -> io::Result<()> {
|
||||
let mut server_process_id = 0;
|
||||
let result = unsafe { GetNamedPipeServerProcessId(pipe_handle, &mut server_process_id) };
|
||||
if result == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let server_process =
|
||||
unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, server_process_id) };
|
||||
if server_process == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let server_process = OwnedHandle(server_process);
|
||||
let server_token = open_process_token(server_process.raw())?;
|
||||
let current_token = open_process_token(unsafe { GetCurrentProcess() })?;
|
||||
let server_user = token_user(server_token.raw())?;
|
||||
let current_user = token_user(current_token.raw())?;
|
||||
|
||||
if unsafe { EqualSid(server_user.sid()?, current_user.sid()?) } == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"IDE context provider is not owned by the current user",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_process_token(process: HANDLE) -> io::Result<OwnedHandle> {
|
||||
let mut token = 0;
|
||||
let result = unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) };
|
||||
if result == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
Ok(OwnedHandle(token))
|
||||
}
|
||||
|
||||
fn token_user(token: HANDLE) -> io::Result<TokenUserBuffer> {
|
||||
let mut return_length = 0;
|
||||
unsafe {
|
||||
GetTokenInformation(token, TokenUser, ptr::null_mut(), 0, &mut return_length);
|
||||
}
|
||||
if return_length == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let mut buffer = vec![0_u8; return_length as usize];
|
||||
let result = unsafe {
|
||||
GetTokenInformation(
|
||||
token,
|
||||
TokenUser,
|
||||
buffer.as_mut_ptr() as *mut _,
|
||||
return_length,
|
||||
&mut return_length,
|
||||
)
|
||||
};
|
||||
if result == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
Ok(TokenUserBuffer { buffer })
|
||||
}
|
||||
|
||||
fn remaining_timeout_ms(deadline: Instant) -> u32 {
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let millis = deadline.duration_since(now).as_millis().max(1);
|
||||
u32::try_from(millis).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
fn timeout_io_error() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::TimedOut, "timed out waiting for IDE context")
|
||||
}
|
||||
@@ -125,6 +125,7 @@ mod frames;
|
||||
mod get_git_diff;
|
||||
mod goal_display;
|
||||
mod history_cell;
|
||||
mod ide_context;
|
||||
pub(crate) mod insert_history;
|
||||
pub use insert_history::insert_history_lines;
|
||||
mod key_hint;
|
||||
|
||||
@@ -14,6 +14,7 @@ pub enum SlashCommand {
|
||||
// more frequently used commands should be listed first.
|
||||
Model,
|
||||
Fast,
|
||||
Ide,
|
||||
Approvals,
|
||||
Permissions,
|
||||
Keymap,
|
||||
@@ -105,6 +106,9 @@ impl SlashCommand {
|
||||
SlashCommand::Fast => {
|
||||
"toggle Fast mode to enable fastest inference with increased plan usage"
|
||||
}
|
||||
SlashCommand::Ide => {
|
||||
"include current selection, open files, and other context from your IDE"
|
||||
}
|
||||
SlashCommand::Personality => "choose a communication style for Codex",
|
||||
SlashCommand::Realtime => "toggle realtime voice mode (experimental)",
|
||||
SlashCommand::Settings => "configure realtime microphone/speaker",
|
||||
@@ -148,6 +152,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Goal
|
||||
| SlashCommand::Fast
|
||||
| SlashCommand::Ide
|
||||
| SlashCommand::Mcp
|
||||
| SlashCommand::Side
|
||||
| SlashCommand::Resume
|
||||
@@ -159,7 +164,11 @@ impl SlashCommand {
|
||||
pub fn available_in_side_conversation(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
SlashCommand::Copy | SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status
|
||||
SlashCommand::Copy
|
||||
| SlashCommand::Diff
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Status
|
||||
| SlashCommand::Ide
|
||||
)
|
||||
}
|
||||
|
||||
@@ -206,6 +215,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Statusline
|
||||
| SlashCommand::AutoReview
|
||||
| SlashCommand::Feedback
|
||||
| SlashCommand::Ide
|
||||
| SlashCommand::Quit
|
||||
| SlashCommand::Exit
|
||||
| SlashCommand::Side => true,
|
||||
@@ -257,6 +267,7 @@ mod tests {
|
||||
#[test]
|
||||
fn certain_commands_are_available_during_task() {
|
||||
assert!(SlashCommand::Goal.available_during_task());
|
||||
assert!(SlashCommand::Ide.available_during_task());
|
||||
assert!(SlashCommand::Title.available_during_task());
|
||||
assert!(SlashCommand::Statusline.available_during_task());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user