diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 1ff81ebf6e2c..c5538c02ed89 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 9e0ba8dc7e04..4275c6743cb8 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 9c4036b56458..0b6aabf5a93c 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 df97d8d6532a..2daec5482987 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 f75d759d5e4c..9f2c33fbecae 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 000000000000..1e340ddc823e --- /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 907bff907f8b..60dec4c92de2 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 000000000000..cf89be6b75ce --- /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 cd828274f672..24a634cd0bcc 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 c376b3aa62f8..27c39f05fb6a 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 6920689ef34d..04f7e3d90714 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 ebc8cee9f24d..d801870bca0f 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 d44918eb0a6f..8b851676045d 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 a49a4da3b684..9e84b8aa8b12 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 000000000000..9701b5ad85d6 --- /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 000000000000..57942d931037 --- /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 000000000000..ec7e165ba84e --- /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 000000000000..f60afeb85de4 --- /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 334e412c0ada..6eafccce272b 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 9f4dbf57d0e6..f8e7fd88ec81 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()); }