From 6784db51c07c3d35e06685024fce0cd24c419f34 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 1 May 2026 09:39:48 -0700 Subject: [PATCH] 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. --- codex-rs/tui/Cargo.toml | 5 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 21 +- codex-rs/tui/src/bottom_pane/footer.rs | 70 +- codex-rs/tui/src/bottom_pane/mod.rs | 5 + .../tui/src/bottom_pane/slash_commands.rs | 1 + ...ne_enabled_mode_and_ide_context_right.snap | 5 + codex-rs/tui/src/chatwidget.rs | 86 +- codex-rs/tui/src/chatwidget/ide_context.rs | 132 +++ codex-rs/tui/src/chatwidget/slash_dispatch.rs | 7 + .../chatwidget/tests/composer_submission.rs | 63 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../src/chatwidget/tests/history_replay.rs | 13 +- .../tui/src/chatwidget/tests/review_mode.rs | 11 + codex-rs/tui/src/chatwidget/user_messages.rs | 25 +- codex-rs/tui/src/ide_context.rs | 117 ++ codex-rs/tui/src/ide_context/ipc.rs | 1009 +++++++++++++++++ codex-rs/tui/src/ide_context/prompt.rs | 401 +++++++ codex-rs/tui/src/ide_context/windows_pipe.rs | 339 ++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 13 +- 20 files changed, 2254 insertions(+), 71 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_and_ide_context_right.snap create mode 100644 codex-rs/tui/src/chatwidget/ide_context.rs create mode 100644 codex-rs/tui/src/ide_context.rs create mode 100644 codex-rs/tui/src/ide_context/ipc.rs create mode 100644 codex-rs/tui/src/ide_context/prompt.rs create mode 100644 codex-rs/tui/src/ide_context/windows_pipe.rs diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 1ff81ebf6..c5538c02e 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -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" diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 9e0ba8dc7..4275c6743 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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, goal_status_indicator: Option, + 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 diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 9c4036b56..0b6aabf5a 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -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, + goal_status_indicator: Option<&GoalStatusIndicator>, + ide_context_active: bool, + show_cycle_hint: bool, +) -> Option> { + 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> = 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, + 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, + 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, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index df97d8d65..2daec5482 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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(); diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index f75d759d5..9f2c33fbe 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -165,6 +165,7 @@ mod tests { assert_eq!( commands, vec![ + SlashCommand::Ide, SlashCommand::Copy, SlashCommand::Diff, SlashCommand::Mention, diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_and_ide_context_right.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_and_ide_context_right.snap new file mode 100644 index 000000000..1e340ddc8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_and_ide_context_right.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Plan mode (shift+tab to cycle) · IDE context " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 907bff907..60dec4c92 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, 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, @@ -1138,6 +1141,7 @@ pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, pending_steer_history_records: VecDeque, + pending_steer_compare_keys: VecDeque, rejected_steers_queue: VecDeque, rejected_steer_history_records: VecDeque, queued_user_messages: VecDeque, @@ -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::>(); - 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(); diff --git a/codex-rs/tui/src/chatwidget/ide_context.rs b/codex-rs/tui/src/chatwidget/ide_context.rs new file mode 100644 index 000000000..cf89be6b7 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/ide_context.rs @@ -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) { + 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()); + } +} diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index cd828274f..24a634cd0 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index c376b3aa6..27c39f05f 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -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::() + { + 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; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 6920689ef..04f7e3d90 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -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(), diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index ebc8cee9f..d801870bc 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -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::().is_some() + && let Some(cell) = cell.as_any().downcast_ref::() { - 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] diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index d44918eb0..8b8516760 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -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] diff --git a/codex-rs/tui/src/chatwidget/user_messages.rs b/codex-rs/tui/src/chatwidget/user_messages.rs index a49a4da3b..9e84b8aa8 100644 --- a/codex-rs/tui/src/chatwidget/user_messages.rs +++ b/codex-rs/tui/src/chatwidget/user_messages.rs @@ -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, remote_image_urls: Vec, ) -> 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, diff --git a/codex-rs/tui/src/ide_context.rs b/codex-rs/tui/src/ide_context.rs new file mode 100644 index 000000000..9701b5ad8 --- /dev/null +++ b/codex-rs/tui/src/ide_context.rs @@ -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, + #[serde(default)] + open_tabs: Vec, +} + +#[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, +} + +#[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(), + }], + } + ); + } +} diff --git a/codex-rs/tui/src/ide_context/ipc.rs b/codex-rs/tui/src/ide_context/ipc.rs new file mode 100644 index 000000000..57942d931 --- /dev/null +++ b/codex-rs/tui/src/ide_context/ipc.rs @@ -0,0 +1,1009 @@ +//! Private transport for fetching IDE context for TUI `/ide` support. + +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +#[cfg(any(unix, windows))] +use serde_json::Value; +#[cfg(any(unix, windows, test))] +use serde_json::json; +use thiserror::Error; + +use super::IdeContext; + +// The desktop IPC client gives requests 5 seconds to complete. Match that prompt-time budget here: +// fetching IDE context includes router discovery and extension event-loop work, so a shorter TUI +// deadline can incorrectly skip context even though the IDE answers normally. +const IDE_CONTEXT_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(any(unix, windows))] +const MAX_IPC_FRAME_BYTES: usize = 256 * 1024 * 1024; +#[cfg(any(unix, windows))] +const TUI_SOURCE_CLIENT_ID: &str = "codex-tui"; +#[cfg(any(unix, windows))] +const OPEN_IDE_HINT: &str = + "Open this project in VS Code or Cursor with the Codex extension active."; +#[cfg(any(unix, windows))] +const IDE_DID_NOT_PROVIDE_CONTEXT_HINT: &str = "The IDE extension did not provide context."; +#[cfg(any(unix, windows))] +const KEEP_TRYING_HINT: &str = "Codex will keep trying on future messages."; + +#[derive(Debug, Error)] +pub(crate) enum IdeContextError { + #[cfg(any(unix, windows))] + #[error("failed to connect to IDE context provider: {0}")] + Connect(std::io::Error), + #[cfg(any(unix, windows))] + #[error("failed to request IDE context: {0}")] + Send(std::io::Error), + #[cfg(any(unix, windows))] + #[error("failed to read IDE context: {0}")] + Read(std::io::Error), + #[cfg(any(unix, windows))] + #[error("invalid IDE context response: {0}")] + InvalidResponse(String), + #[cfg(any(unix, windows))] + #[error("IDE context response exceeded maximum size")] + ResponseTooLarge, + #[cfg(any(unix, windows))] + #[error("IDE context request failed")] + RequestFailed(String), + #[cfg(not(any(unix, windows)))] + #[error("IDE context is not supported on this platform")] + UnsupportedPlatform, +} + +impl IdeContextError { + #[cfg(any(unix, windows))] + pub(crate) fn user_facing_hint(&self) -> String { + match self { + IdeContextError::Connect(_) => OPEN_IDE_HINT.to_string(), + IdeContextError::RequestFailed(error) if error == "no-client-found" => { + OPEN_IDE_HINT.to_string() + } + IdeContextError::RequestFailed(_) => { + format!("{IDE_DID_NOT_PROVIDE_CONTEXT_HINT} Try /ide again.") + } + IdeContextError::ResponseTooLarge => { + "The selected IDE context is too large. Clear any large selection in your IDE and try /ide again.".to_string() + } + IdeContextError::Send(_) => { + "Codex could not request IDE context. Try /ide again.".to_string() + } + IdeContextError::Read(_) | IdeContextError::InvalidResponse(_) => { + "Codex could not read IDE context. Try /ide again.".to_string() + } + } + } + + #[cfg(any(unix, windows))] + pub(crate) fn prompt_skip_hint(&self) -> String { + match self { + IdeContextError::ResponseTooLarge => { + "The selected IDE context is too large. Clear any large selection in your IDE." + .to_string() + } + IdeContextError::Connect(_) => OPEN_IDE_HINT.to_string(), + IdeContextError::RequestFailed(error) if error == "no-client-found" => { + OPEN_IDE_HINT.to_string() + } + IdeContextError::Read(error) if error.kind() == std::io::ErrorKind::TimedOut => { + "Codex timed out waiting for IDE context. It will keep trying on future messages." + .to_string() + } + IdeContextError::RequestFailed(error) if error == "client-disconnected" => { + hint_with_retry("The IDE connection changed while Codex was requesting context.") + } + IdeContextError::RequestFailed(error) if error == "request-timeout" => { + hint_with_retry("The IDE extension did not answer in time.") + } + IdeContextError::RequestFailed(error) if error == "request-version-mismatch" => { + "The connected IDE extension is not compatible with this IDE context request." + .to_string() + } + IdeContextError::RequestFailed(error) if error == "no-handler-for-request" => { + "The connected IDE client does not support IDE context requests.".to_string() + } + IdeContextError::Send(_) => { + hint_with_retry("Codex lost the IDE connection while requesting context.") + } + IdeContextError::InvalidResponse(_) => { + hint_with_retry("Codex received an unexpected IDE context response.") + } + IdeContextError::RequestFailed(_) => hint_with_retry(IDE_DID_NOT_PROVIDE_CONTEXT_HINT), + IdeContextError::Read(_) => hint_with_retry("Codex could not read IDE context."), + } + } + + #[cfg(not(any(unix, windows)))] + pub(crate) fn user_facing_hint(&self) -> String { + self.to_string() + } + + #[cfg(not(any(unix, windows)))] + pub(crate) fn prompt_skip_hint(&self) -> String { + self.to_string() + } +} + +#[cfg(any(unix, windows))] +fn hint_with_retry(message: &str) -> String { + format!("{message} {KEEP_TRYING_HINT}") +} + +#[cfg(unix)] +type IdeContextStream = UnixDeadlineStream; + +#[cfg(windows)] +type IdeContextStream = super::windows_pipe::WindowsPipeStream; + +#[cfg(any(unix, windows))] +pub(crate) fn fetch_ide_context(workspace_root: &Path) -> Result { + fetch_ide_context_from_socket( + default_ipc_socket_path(), + workspace_root, + IDE_CONTEXT_REQUEST_TIMEOUT, + ) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn fetch_ide_context(_workspace_root: &Path) -> Result { + Err(IdeContextError::UnsupportedPlatform) +} + +#[cfg(unix)] +fn default_ipc_socket_path() -> PathBuf { + let uid = unsafe { libc::getuid() }; + std::env::temp_dir() + .join("codex-ipc") + .join(format!("ipc-{uid}.sock")) +} + +#[cfg(windows)] +fn default_ipc_socket_path() -> PathBuf { + PathBuf::from(r"\\.\pipe\codex-ipc") +} + +#[cfg(not(any(unix, windows)))] +fn default_ipc_socket_path() -> PathBuf { + PathBuf::new() +} + +#[cfg(any(unix, windows))] +fn fetch_ide_context_from_socket( + socket_path: PathBuf, + workspace_root: &Path, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + let mut stream = connect_stream(socket_path, deadline)?; + fetch_ide_context_from_stream(&mut stream, workspace_root, deadline) +} + +#[cfg(unix)] +fn connect_stream( + socket_path: PathBuf, + deadline: Instant, +) -> Result { + UnixDeadlineStream::connect(socket_path, deadline).map_err(IdeContextError::Connect) +} + +#[cfg(unix)] +struct UnixDeadlineStream { + stream: std::os::unix::net::UnixStream, + deadline: Instant, +} + +#[cfg(unix)] +impl UnixDeadlineStream { + fn connect(socket_path: PathBuf, deadline: Instant) -> std::io::Result { + let stream = connect_unix_stream_before_deadline(&socket_path, deadline)?; + validate_unix_peer_owner(&stream)?; + Ok(Self::new(stream, deadline)) + } + + fn new(stream: std::os::unix::net::UnixStream, deadline: Instant) -> Self { + Self { stream, deadline } + } + + fn set_deadline(&mut self, deadline: Instant) { + self.deadline = deadline; + } + + fn wait_for_ready(&self, events: libc::c_short) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + + wait_for_fd_ready(self.stream.as_raw_fd(), events, self.deadline) + } +} + +#[cfg(unix)] +fn connect_unix_stream_before_deadline( + socket_path: &Path, + deadline: Instant, +) -> std::io::Result { + use std::os::fd::AsRawFd; + use std::os::fd::FromRawFd; + use std::os::fd::IntoRawFd; + use std::os::fd::OwnedFd; + + validate_unix_socket_path(socket_path)?; + let (addr, addr_len) = unix_socket_addr(socket_path)?; + let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + set_fd_close_on_exec(fd.as_raw_fd())?; + set_fd_nonblocking(fd.as_raw_fd())?; + + let result = unsafe { + libc::connect( + fd.as_raw_fd(), + &addr as *const libc::sockaddr_un as *const libc::sockaddr, + addr_len, + ) + }; + if result != 0 { + let error = std::io::Error::last_os_error(); + if !is_in_progress_connect_error(&error) { + return Err(error); + } + + wait_for_fd_ready(fd.as_raw_fd(), libc::POLLOUT, deadline)?; + let socket_error = socket_error(fd.as_raw_fd())?; + if socket_error != 0 { + return Err(std::io::Error::from_raw_os_error(socket_error)); + } + } + + Ok(unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd.into_raw_fd()) }) +} + +#[cfg(unix)] +fn unix_socket_addr(socket_path: &Path) -> std::io::Result<(libc::sockaddr_un, libc::socklen_t)> { + use std::os::unix::ffi::OsStrExt; + + let path_bytes = socket_path.as_os_str().as_bytes(); + if path_bytes.contains(&0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "IDE context Unix socket path contains a nul byte", + )); + } + + let mut addr = unsafe { std::mem::zeroed::() }; + if path_bytes.len() >= addr.sun_path.len() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "IDE context Unix socket path is too long", + )); + } + + addr.sun_family = libc::AF_UNIX as libc::sa_family_t; + for (slot, byte) in addr.sun_path.iter_mut().zip(path_bytes) { + *slot = *byte as libc::c_char; + } + + let addr_len = + std::mem::size_of::() - addr.sun_path.len() + path_bytes.len() + 1; + #[cfg(any( + target_os = "macos", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly" + ))] + { + addr.sun_len = u8::try_from(addr_len).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "IDE context Unix socket address is too long", + ) + })?; + } + + let addr_len = libc::socklen_t::try_from(addr_len).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "IDE context Unix socket address is too long", + ) + })?; + Ok((addr, addr_len)) +} + +#[cfg(unix)] +fn set_fd_close_on_exec(fd: libc::c_int) -> std::io::Result<()> { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags < 0 { + return Err(std::io::Error::last_os_error()); + } + let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) }; + if result < 0 { + return Err(std::io::Error::last_os_error()); + } + + Ok(()) +} + +#[cfg(unix)] +fn set_fd_nonblocking(fd: libc::c_int) -> std::io::Result<()> { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 { + return Err(std::io::Error::last_os_error()); + } + let result = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) }; + if result < 0 { + return Err(std::io::Error::last_os_error()); + } + + Ok(()) +} + +#[cfg(unix)] +fn is_in_progress_connect_error(error: &std::io::Error) -> bool { + matches!( + error.raw_os_error(), + Some(code) + if code == libc::EINPROGRESS + || code == libc::EALREADY + || code == libc::EWOULDBLOCK + || code == libc::EINTR + ) +} + +#[cfg(unix)] +fn socket_error(fd: libc::c_int) -> std::io::Result { + let mut socket_error = 0; + let mut socket_error_len = libc::socklen_t::try_from(std::mem::size_of::()) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid socket error length", + ) + })?; + let result = unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_ERROR, + &mut socket_error as *mut _ as *mut libc::c_void, + &mut socket_error_len, + ) + }; + if result != 0 { + return Err(std::io::Error::last_os_error()); + } + + Ok(socket_error) +} + +#[cfg(unix)] +fn remaining_timeout(deadline: Instant) -> std::io::Result { + deadline + .checked_duration_since(Instant::now()) + .filter(|duration| !duration.is_zero()) + .ok_or_else(deadline_timeout_io_error) +} + +#[cfg(unix)] +fn remaining_timeout_ms(deadline: Instant) -> std::io::Result { + let millis = remaining_timeout(deadline)?.as_millis().max(1); + Ok(libc::c_int::try_from(millis).unwrap_or(libc::c_int::MAX)) +} + +#[cfg(unix)] +fn wait_for_fd_ready( + fd: libc::c_int, + events: libc::c_short, + deadline: Instant, +) -> std::io::Result<()> { + loop { + // Keep deadline handling in user space. Some macOS Unix socket environments reject + // SO_RCVTIMEO/SO_SNDTIMEO, but poll works consistently for our request-scoped timeout. + let mut poll_fd = libc::pollfd { + fd, + events, + revents: 0, + }; + let result = unsafe { libc::poll(&mut poll_fd, 1, remaining_timeout_ms(deadline)?) }; + if result == 0 { + return Err(deadline_timeout_io_error()); + } + if result < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(error); + } + if poll_fd.revents & libc::POLLNVAL != 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid IDE context Unix socket", + )); + } + if poll_fd.revents & (events | libc::POLLERR | libc::POLLHUP) != 0 { + return Ok(()); + } + } +} + +#[cfg(unix)] +impl std::io::Read for UnixDeadlineStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if buf.is_empty() { + return Ok(0); + } + + loop { + self.wait_for_ready(libc::POLLIN)?; + match self.stream.read(buf) { + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + result => return result, + } + } + } +} + +#[cfg(unix)] +impl std::io::Write for UnixDeadlineStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if buf.is_empty() { + return Ok(0); + } + + loop { + self.wait_for_ready(libc::POLLOUT)?; + match self.stream.write(buf) { + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + result => return result, + } + } + } + + fn flush(&mut self) -> std::io::Result<()> { + self.wait_for_ready(libc::POLLOUT)?; + self.stream.flush() + } +} + +#[cfg(unix)] +fn validate_unix_socket_path(socket_path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::FileTypeExt; + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + + let uid = unsafe { libc::getuid() }; + let parent = socket_path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "IDE context socket has no parent directory", + ) + })?; + let parent_metadata = std::fs::symlink_metadata(parent)?; + if !parent_metadata.is_dir() || parent_metadata.uid() != uid { + return Err(permission_denied_io_error( + "IDE context socket directory is not owned by the current user", + )); + } + if parent_metadata.permissions().mode() & 0o022 != 0 { + return Err(permission_denied_io_error( + "IDE context socket directory is writable by other users", + )); + } + + let socket_metadata = std::fs::symlink_metadata(socket_path)?; + if !socket_metadata.file_type().is_socket() || socket_metadata.uid() != uid { + return Err(permission_denied_io_error( + "IDE context socket is not owned by the current user", + )); + } + + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn validate_unix_peer_owner(stream: &std::os::unix::net::UnixStream) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + + let mut credentials = unsafe { std::mem::zeroed::() }; + let mut credentials_len: libc::socklen_t = + std::mem::size_of::().try_into().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid peer credential length", + ) + })?; + let result = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut credentials as *mut _ as *mut libc::c_void, + &mut credentials_len, + ) + }; + if result != 0 { + return Err(std::io::Error::last_os_error()); + } + + ensure_peer_uid_matches_current_user(credentials.uid) +} + +#[cfg(any( + target_os = "macos", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly" +))] +fn validate_unix_peer_owner(stream: &std::os::unix::net::UnixStream) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + + let mut peer_uid: libc::uid_t = 0; + let mut peer_gid: libc::gid_t = 0; + let result = unsafe { libc::getpeereid(stream.as_raw_fd(), &mut peer_uid, &mut peer_gid) }; + if result != 0 { + return Err(std::io::Error::last_os_error()); + } + + ensure_peer_uid_matches_current_user(peer_uid) +} + +#[cfg(all( + unix, + not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly" + )) +))] +fn validate_unix_peer_owner(_stream: &std::os::unix::net::UnixStream) -> std::io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn ensure_peer_uid_matches_current_user(peer_uid: libc::uid_t) -> std::io::Result<()> { + if peer_uid != unsafe { libc::getuid() } { + return Err(permission_denied_io_error( + "IDE context provider is not owned by the current user", + )); + } + + Ok(()) +} + +#[cfg(windows)] +fn connect_stream( + socket_path: PathBuf, + deadline: Instant, +) -> Result { + super::windows_pipe::WindowsPipeStream::connect(socket_path, deadline) + .map_err(IdeContextError::Connect) +} + +#[cfg(any(unix, windows))] +fn answer_unsupported_request( + stream: &mut T, + message: &Value, +) -> Result<(), IdeContextError> { + if let Some(inbound_request_id) = message.get("requestId").and_then(Value::as_str) { + let response = json!({ + "type": "response", + "requestId": inbound_request_id, + "resultType": "error", + "error": "no-handler-for-request", + }); + write_frame(stream, &response).map_err(IdeContextError::Send)?; + } + Ok(()) +} + +#[cfg(any(unix, windows))] +fn fetch_ide_context_from_stream( + stream: &mut IdeContextStream, + workspace_root: &Path, + deadline: Instant, +) -> Result { + let request_id = uuid::Uuid::new_v4().to_string(); + write_ide_context_request(stream, &request_id, workspace_root) + .map_err(IdeContextError::Send)?; + let response = read_response_frame(stream, &request_id, deadline)?; + extract_ide_context(response) +} + +#[cfg(any(unix, windows))] +fn write_ide_context_request( + stream: &mut T, + request_id: &str, + workspace_root: &Path, +) -> std::io::Result<()> { + let ide_context_request = json!({ + "type": "request", + "requestId": request_id, + "sourceClientId": TUI_SOURCE_CLIENT_ID, + "version": 0, + "method": "ide-context", + "params": { + "workspaceRoot": workspace_root.to_string_lossy(), + }, + }); + write_frame(stream, &ide_context_request) +} + +#[cfg(any(unix, windows))] +fn write_frame(stream: &mut T, message: &Value) -> std::io::Result<()> { + let payload = serde_json::to_vec(message).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid IDE context JSON message: {err}"), + ) + })?; + let payload_len = u32::try_from(payload.len()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "IDE context payload exceeds u32 length", + ) + })?; + stream.write_all(&payload_len.to_le_bytes())?; + stream.write_all(&payload)?; + stream.flush() +} + +#[cfg(any(unix, windows))] +fn read_frame( + stream: &mut T, + deadline: Instant, +) -> Result { + let mut len_bytes = [0_u8; 4]; + read_exact_before_deadline(stream, &mut len_bytes, deadline)?; + let len = u32::from_le_bytes(len_bytes) as usize; + if len > MAX_IPC_FRAME_BYTES { + return Err(IdeContextError::ResponseTooLarge); + } + + let mut payload = vec![0_u8; len]; + read_exact_before_deadline(stream, &mut payload, deadline)?; + serde_json::from_slice(&payload) + .map_err(|err| IdeContextError::InvalidResponse(format!("invalid JSON payload: {err}"))) +} + +#[cfg(any(unix, windows))] +fn read_exact_before_deadline( + stream: &mut T, + buf: &mut [u8], + deadline: Instant, +) -> Result<(), IdeContextError> { + // std::io::Read::read_exact has no way to observe our request deadline between partial reads. + // Keep the frame header and payload under the same budget as the surrounding response wait. + let mut read_so_far = 0; + while read_so_far < buf.len() { + ensure_deadline_not_expired(deadline)?; + match stream.read(&mut buf[read_so_far..]) { + Ok(0) => { + return Err(IdeContextError::Read(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "failed to fill whole IDE context frame", + ))); + } + Ok(bytes_read) => { + read_so_far += bytes_read; + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(error) => return Err(IdeContextError::Read(error)), + } + } + + ensure_deadline_not_expired(deadline) +} + +#[cfg(any(unix, windows))] +fn read_response_frame( + stream: &mut IdeContextStream, + request_id: &str, + deadline: Instant, +) -> Result { + loop { + ensure_deadline_not_expired(deadline)?; + stream.set_deadline(deadline); + let message = read_frame(stream, deadline)?; + match message.get("type").and_then(Value::as_str) { + Some("response") => { + if message.get("requestId").and_then(Value::as_str) == Some(request_id) { + return Ok(message); + } + } + Some("broadcast") => {} + Some("client-discovery-request") => { + if let Some(discovery_request_id) = message.get("requestId").and_then(Value::as_str) + { + let response = json!({ + "type": "client-discovery-response", + "requestId": discovery_request_id, + "response": { + "canHandle": false, + }, + }); + write_frame(stream, &response).map_err(IdeContextError::Send)?; + } + } + Some("client-discovery-response") => {} + Some("request") => { + answer_unsupported_request(stream, &message)?; + } + Some(other) => { + return Err(IdeContextError::InvalidResponse(format!( + "unexpected IDE context message type: {other}" + ))); + } + None => { + return Err(IdeContextError::InvalidResponse( + "IDE context message did not include a type".to_string(), + )); + } + } + } +} + +#[cfg(any(unix, windows))] +fn ensure_deadline_not_expired(deadline: Instant) -> Result<(), IdeContextError> { + if Instant::now() >= deadline { + return Err(timeout_error()); + } + + Ok(()) +} + +#[cfg(any(unix, windows))] +fn timeout_error() -> IdeContextError { + IdeContextError::Read(deadline_timeout_io_error()) +} + +#[cfg(any(unix, windows))] +fn deadline_timeout_io_error() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timed out waiting for IDE context", + ) +} + +#[cfg(unix)] +fn permission_denied_io_error(message: &'static str) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::PermissionDenied, message) +} + +#[cfg(any(unix, windows))] +fn extract_ide_context(response: Value) -> Result { + ensure_success_response(&response)?; + let ide_context = response + .get("result") + .and_then(|result| result.get("ideContext")) + .cloned() + .ok_or_else(|| { + IdeContextError::InvalidResponse( + "ide-context response did not include result.ideContext".to_string(), + ) + })?; + serde_json::from_value(ide_context) + .map_err(|err| IdeContextError::InvalidResponse(err.to_string())) +} + +#[cfg(any(unix, windows))] +fn ensure_success_response(response: &Value) -> Result<(), IdeContextError> { + match response.get("resultType").and_then(Value::as_str) { + Some("success") => Ok(()), + Some("error") => Err(IdeContextError::RequestFailed( + response + .get("error") + .and_then(Value::as_str) + .unwrap_or("unknown error") + .to_string(), + )), + _ => Err(IdeContextError::InvalidResponse( + "response did not include a success or error resultType".to_string(), + )), + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + #[cfg(unix)] + use pretty_assertions::assert_eq; + + #[cfg(unix)] + fn test_deadline() -> Instant { + Instant::now() + Duration::from_secs(1) + } + + #[cfg(unix)] + fn write_ide_context_response( + stream: &mut impl std::io::Write, + request_id: &str, + active_selection_content: &str, + ) { + if let Err(err) = write_frame( + stream, + &json!({ + "type": "response", + "requestId": request_id, + "resultType": "success", + "method": "ide-context", + "handledByClientId": "vscode-client", + "result": { + "type": "broadcast", + "ideContext": { + "activeFile": { + "label": "lib.rs", + "path": "src/lib.rs", + "fsPath": "/repo/src/lib.rs", + "selection": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 3 } + }, + "activeSelectionContent": active_selection_content, + "selections": [] + }, + "openTabs": [] + } + } + }), + ) { + panic!("write ide-context response failed: {err}"); + } + } + + #[cfg(unix)] + #[test] + fn unix_deadline_stream_uses_remaining_deadline_for_blocking_reads() { + use std::os::unix::net::UnixStream; + + let (client, _server) = UnixStream::pair().expect("create unix stream pair"); + let mut stream = + UnixDeadlineStream::new(client, Instant::now() + Duration::from_millis(50)); + let start = Instant::now(); + let mut buf = [0_u8; 1]; + + let err = std::io::Read::read(&mut stream, &mut buf) + .expect_err("read should time out at the request deadline"); + + assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); + assert!(start.elapsed() < Duration::from_secs(2)); + } + + #[cfg(unix)] + #[test] + fn validate_unix_socket_path_rejects_unsafe_parent_directory() { + use std::os::unix::fs::PermissionsExt; + use std::os::unix::net::UnixListener; + + let tempdir = tempfile::tempdir().expect("tempdir"); + std::fs::set_permissions(tempdir.path(), std::fs::Permissions::from_mode(0o777)) + .expect("set unsafe permissions"); + let socket_path = tempdir.path().join("codex-ipc.sock"); + let _listener = UnixListener::bind(&socket_path).expect("bind socket"); + + let err = validate_unix_socket_path(&socket_path) + .expect_err("world-writable parent directory should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + } + + #[cfg(unix)] + #[test] + fn fetch_ide_context_uses_unregistered_request_route() { + use std::os::unix::net::UnixListener; + use std::thread; + + let tempdir = tempfile::tempdir().expect("tempdir"); + let socket_path = tempdir.path().join("codex-ipc.sock"); + let listener = UnixListener::bind(&socket_path).expect("bind socket"); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + + let ide_context = read_frame(&mut stream, test_deadline()).expect("read ide-context"); + assert_eq!( + ide_context.get("method").and_then(Value::as_str), + Some("ide-context") + ); + assert_eq!( + ide_context.get("sourceClientId").and_then(Value::as_str), + Some(TUI_SOURCE_CLIENT_ID) + ); + assert_eq!( + ide_context + .get("params") + .and_then(|params| params.get("workspaceRoot")) + .and_then(Value::as_str), + Some("/repo") + ); + let ide_context_request_id = ide_context + .get("requestId") + .and_then(Value::as_str) + .expect("ide-context request id"); + write_frame( + &mut stream, + &json!({ + "type": "request", + "requestId": "inbound-request", + "sourceClientId": "vscode-client", + "version": 0, + "method": "unknown-method", + "params": {} + }), + ) + .expect("write inbound request before ide-context response"); + let inbound_response = read_frame(&mut stream, test_deadline()) + .expect("read inbound request response before ide-context response"); + assert_eq!( + inbound_response, + json!({ + "type": "response", + "requestId": "inbound-request", + "resultType": "error", + "error": "no-handler-for-request" + }) + ); + + write_frame( + &mut stream, + &json!({ + "type": "client-discovery-request", + "requestId": "discovery-request", + "request": ide_context.clone(), + }), + ) + .expect("write client discovery request"); + let discovery_response = + read_frame(&mut stream, test_deadline()).expect("read client discovery response"); + assert_eq!( + discovery_response.get("type").and_then(Value::as_str), + Some("client-discovery-response") + ); + assert_eq!( + discovery_response.get("requestId").and_then(Value::as_str), + Some("discovery-request") + ); + assert_eq!( + discovery_response + .get("response") + .and_then(|response| response.get("canHandle")) + .and_then(Value::as_bool), + Some(false) + ); + + write_frame( + &mut stream, + &json!({ + "type": "broadcast", + "method": "thread-stream-state-changed", + "params": "x".repeat(2 * 1024 * 1024), + }), + ) + .expect("write large broadcast"); + write_ide_context_response(&mut stream, ide_context_request_id, "use"); + }); + + let context = + fetch_ide_context_from_socket(socket_path, Path::new("/repo"), Duration::from_secs(1)) + .expect("fetch ide context"); + + server.join().expect("server joins"); + assert_eq!( + context + .active_file + .as_ref() + .map(|file| file.active_selection_content.as_str()), + Some("use") + ); + } +} diff --git a/codex-rs/tui/src/ide_context/prompt.rs b/codex-rs/tui/src/ide_context/prompt.rs new file mode 100644 index 000000000..ec7e165ba --- /dev/null +++ b/codex-rs/tui/src/ide_context/prompt.rs @@ -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, +) -> 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) -> 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 { + 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::>(); + + 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::>(); + 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")); + } +} diff --git a/codex-rs/tui/src/ide_context/windows_pipe.rs b/codex-rs/tui/src/ide_context/windows_pipe.rs new file mode 100644 index 000000000..f60afeb85 --- /dev/null +++ b/codex-rs/tui/src/ide_context/windows_pipe.rs @@ -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 { + let wide_path = pipe_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + + 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 { + 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 { + 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 { + 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.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 { + 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, +} + +impl TokenUserBuffer { + fn sid(&self) -> io::Result { + if self.buffer.len() < std::mem::size_of::() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "token user buffer is too small", + )); + } + + // GetTokenInformation writes TOKEN_USER into a byte buffer. Vec 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 { + 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 { + 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") +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index d3d9aa4af..15567a7d2 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 9f4dbf57d..f8e7fd88e 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -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()); }