From 70b5cfc73b25458a7af225d24b16ef4794f8f380 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 25 Aug 2026 12:13:25 +0000 Subject: [PATCH] Probe Windows terminal colors without risking startup input (#40598) ## Why Windows Terminal and xterm.js can render a theme that differs from the underlying console color table. Querying that visible theme through OSC shares the console input queue with keystrokes, including input for security-sensitive startup prompts. ## What changed - Defer the Windows OSC color probe until protected startup decisions and the world-writable workspace scan have completed. - Preserve and replay unrelated console input records around the probe, while removing only valid OSC 10 and 11 replies and retaining the console palette as a fallback. - Require an explicit confirmation key after shortcuts select trust, sandbox, or world-writable warning choices, so a fragmented terminal reply cannot accept them. - Accept one- through four-digit OSC RGB components so the visible terminal palette is applied across supported response formats. ## Testing Added coverage for delayed startup scans, protected prompt confirmation, console-record replay, bracketed paste and malformed OSC preservation, RGB response parsing, and light-theme composer rendering. GitOrigin-RevId: b8ae456fe32c701ba3af31e9cc452fc4e8bffa22 --- codex-rs/tui/src/app/event_dispatch.rs | 4 + codex-rs/tui/src/app/platform_actions.rs | 9 + codex-rs/tui/src/app/startup.rs | 28 ++ codex-rs/tui/src/app/tests/startup.rs | 100 ++++++ codex-rs/tui/src/app_event.rs | 4 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 23 ++ .../src/bottom_pane/list_selection_view.rs | 24 +- ...ests__light_terminal_palette_composer.snap | 27 ++ .../tui/src/chatwidget/tests/permissions.rs | 74 ++++ .../src/chatwidget/windows_sandbox_prompts.rs | 8 +- .../tui/src/onboarding/trust_directory.rs | 22 +- codex-rs/tui/src/startup_hooks_review.rs | 61 +++- codex-rs/tui/src/terminal_palette.rs | 34 ++ codex-rs/tui/src/terminal_probe.rs | 161 ++------- codex-rs/tui/src/terminal_probe/windows.rs | 335 ++++++++++++++++++ .../tui/src/terminal_probe/windows_replay.rs | 84 +++++ .../terminal_probe/windows_replay_tests.rs | 94 +++++ .../tui/src/terminal_probe/windows_tests.rs | 206 +++++++++++ codex-rs/tui/src/tui.rs | 13 +- 19 files changed, 1162 insertions(+), 149 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__light_terminal_palette_composer.snap create mode 100644 codex-rs/tui/src/terminal_probe/windows.rs create mode 100644 codex-rs/tui/src/terminal_probe/windows_replay.rs create mode 100644 codex-rs/tui/src/terminal_probe/windows_replay_tests.rs create mode 100644 codex-rs/tui/src/terminal_probe/windows_tests.rs diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index cfe317bc8b..cbee2c1fc6 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1611,6 +1611,9 @@ impl App { failed_scan, ); } + AppEvent::StartupWorldWritableScanCompleted => { + self.windows_sandbox.startup_world_writable_scan_pending = false; + } AppEvent::OpenFeedbackNote { category, include_logs, @@ -2298,6 +2301,7 @@ impl App { logs_base_dir, permission_profile, tx, + /*startup_scan*/ false, ); } } diff --git a/codex-rs/tui/src/app/platform_actions.rs b/codex-rs/tui/src/app/platform_actions.rs index f4a708a026..a745209dfa 100644 --- a/codex-rs/tui/src/app/platform_actions.rs +++ b/codex-rs/tui/src/app/platform_actions.rs @@ -10,6 +10,8 @@ pub(super) struct WindowsSandboxState { pub(super) setup_started_at: Option, // One-shot suppression of the next world-writable scan after user confirmation. pub(super) skip_world_writable_scan_once: bool, + /// A startup filesystem scan can still enqueue a protected warning after the app queue drains. + pub(super) startup_world_writable_scan_pending: bool, } impl App { @@ -21,6 +23,7 @@ impl App { logs_base_dir: AbsolutePathBuf, permission_profile: PermissionProfile, tx: AppEventSender, + startup_scan: bool, ) { let Ok(permissions) = codex_windows_sandbox::ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_workspace_roots( @@ -28,6 +31,9 @@ impl App { workspace_roots.as_slice(), ) else { + if startup_scan { + tx.send(AppEvent::StartupWorldWritableScanCompleted); + } return; }; @@ -45,6 +51,9 @@ impl App { // Scan failed: warn without examples. send_world_writable_scan_failed(&tx); } + if startup_scan { + tx.send(AppEvent::StartupWorldWritableScanCompleted); + } }); } } diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 959ebca611..2fa3aed86d 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -67,6 +67,17 @@ impl App { .any(|event| matches!(event, ThreadBufferedEvent::Request(_)))) } + /// Wait until visible and queued startup decisions cannot consume a delayed OSC response. + #[cfg(any(windows, test))] + pub(super) fn ready_for_terminal_color_probe(&self, has_pending_app_events: bool) -> bool { + !has_pending_app_events + && !self.chat_widget.has_active_view() + && !self.windows_sandbox.startup_world_writable_scan_pending + && !self.startup_pending_protected_request + && !self.has_queued_startup_protected_request() + && !self.chat_widget.has_pending_protected_request() + } + #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, @@ -584,6 +595,7 @@ See the Codex keymap documentation for supported actions and examples." .hide_world_writable_warning .unwrap_or(false); if should_check { + app.windows_sandbox.startup_world_writable_scan_pending = true; let cwd = app.config.cwd.clone(); let workspace_roots = app.config.effective_workspace_roots(); let env_map: std::collections::HashMap = std::env::vars().collect(); @@ -596,6 +608,7 @@ See the Codex keymap documentation for supported actions and examples." logs_base_dir, startup_permission_profile, tx, + /*startup_scan*/ true, ); } } @@ -625,6 +638,14 @@ See the Codex keymap documentation for supported actions and examples." .restore_startup_draft_when_ready(&mut pending_startup_draft); } + #[cfg(windows)] + let mut terminal_color_probe_pending = true; + #[cfg(windows)] + if app.ready_for_terminal_color_probe(!app_event_rx.is_empty()) { + tui.probe_default_colors_after_protected_startup(); + terminal_color_probe_pending = false; + } + let event_stream_started_at = Instant::now(); tui.schedule_screen_size_recheck(Duration::ZERO); if let Err(err) = render_startup_frame(&mut app, tui) { @@ -802,6 +823,13 @@ See the Codex keymap documentation for supported actions and examples." app.chat_widget .restore_startup_draft_when_ready(&mut pending_startup_draft); } + #[cfg(windows)] + if terminal_color_probe_pending + && app.ready_for_terminal_color_probe(!app_event_rx.is_empty()) + { + tui.probe_default_colors_after_protected_startup(); + terminal_color_probe_pending = false; + } } AppRunControl::Exit(reason) => break Ok(reason), } diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index 5ebd9569af..f49eaad61d 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -6,6 +6,7 @@ use crate::tui::FrameRequester; use app_test_support::create_fake_parented_rollout_with_source; use codex_app_server_protocol::ToolRequestUserInputOption; use codex_app_server_protocol::ToolRequestUserInputQuestion; +use codex_utils_approval_presets::builtin_approval_presets; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -30,6 +31,105 @@ fn startup_bottom_pane() -> (BottomPane, UnboundedReceiver) { ) } +#[tokio::test] +async fn terminal_color_probe_waits_for_startup_sandbox_choice() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.startup_protected_input_boundary = true; + while app_event_rx.try_recv().is_ok() {} + + assert!(app.ready_for_terminal_color_probe(/*has_pending_app_events*/ false)); + assert!(!app.ready_for_terminal_color_probe(/*has_pending_app_events*/ true)); + + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("auto preset"); + app.chat_widget + .open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None); + + assert!(!app.ready_for_terminal_color_probe(/*has_pending_app_events*/ false)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + assert!(!app.ready_for_terminal_color_probe(/*has_pending_app_events*/ false)); + assert!(app_event_rx.try_recv().is_err()); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!( + app_event_rx.try_recv(), + Ok(AppEvent::BeginWindowsSandboxLegacySetup { .. }) + )); + assert!(app.ready_for_terminal_color_probe(/*has_pending_app_events*/ false)); +} + +#[tokio::test] +async fn terminal_color_probe_waits_for_delayed_world_writable_scan_failure() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.startup_protected_input_boundary = true; + app.windows_sandbox.startup_world_writable_scan_pending = true; + while app_event_rx.try_recv().is_ok() {} + + assert!(!app.ready_for_terminal_color_probe(/*has_pending_app_events*/ false)); + + app.app_event_tx + .send(AppEvent::OpenWorldWritableWarningConfirmation { + preset: None, + profile_selection: None, + sample_paths: Vec::new(), + extra_count: 0, + failed_scan: true, + }); + app.app_event_tx + .send(AppEvent::StartupWorldWritableScanCompleted); + assert!(!app.ready_for_terminal_color_probe(/*has_pending_app_events*/ true)); + + let warning = app_event_rx + .try_recv() + .expect("the delayed scan should queue its warning before completion"); + let AppEvent::OpenWorldWritableWarningConfirmation { + preset, + profile_selection, + sample_paths, + extra_count, + failed_scan, + } = warning + else { + panic!("the delayed scan should open a protected warning before completion"); + }; + app.chat_widget.open_world_writable_warning_confirmation( + preset, + profile_selection, + sample_paths, + extra_count, + failed_scan, + ); + assert!(matches!( + app_event_rx.try_recv(), + Ok(AppEvent::StartupWorldWritableScanCompleted) + )); + app.windows_sandbox.startup_world_writable_scan_pending = false; + + assert!(!app.windows_sandbox.startup_world_writable_scan_pending); + assert!(!app.ready_for_terminal_color_probe(/*has_pending_app_events*/ false)); + for character in "20;rgb:2222/ffff/ffff".chars() { + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)); + assert!(app_event_rx.try_recv().is_err()); + } + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!( + app_event_rx.try_recv(), + Ok(AppEvent::UpdateWorldWritableWarningAcknowledged(true)) + )); + assert!(matches!( + app_event_rx.try_recv(), + Ok(AppEvent::PersistWorldWritableWarningAcknowledged) + )); + assert!(app.ready_for_terminal_color_probe(/*has_pending_app_events*/ false)); +} + #[test] fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() { assert_eq!( diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b5e00f55cc..b6b82ef7d8 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1032,6 +1032,10 @@ pub(crate) enum AppEvent { failed_scan: bool, }, + /// The startup world-writable scan finished and queued any protected warning it requires. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + StartupWorldWritableScanCompleted, + /// Prompt to enable the Windows sandbox feature before using Agent mode. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OpenWindowsSandboxEnablePrompt { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8b45703925..c2f24cd3b9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -5090,6 +5090,29 @@ mod tests { ); } + #[test] + fn light_terminal_palette_renders_light_composer_snapshot() { + let colors = crate::terminal_probe::DefaultColors { + fg: (0x55, 0x57, 0x53), + bg: (0xff, 0xff, 0xff), + }; + + crate::terminal_palette::with_test_default_colors(colors, || { + let (composer, _rx) = new_test_composer(); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 6, + ); + let mut buffer = Buffer::empty(area); + composer.render(area, &mut buffer); + + assert_eq!( + buffer[(0, 1)].bg, + crate::terminal_palette::rgb_color((244, 244, 244)) + ); + insta::assert_snapshot!("light_terminal_palette_composer", format!("{buffer:?}")); + }); + } + #[test] fn footer_hint_row_is_separated_from_composer() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 04c6514f62..7c01406612 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -143,6 +143,8 @@ pub(crate) struct SelectionItem { pub is_disabled: bool, pub actions: Vec, pub dismiss_on_select: bool, + /// Require an explicit accept key after a direct shortcut highlights this sensitive item. + pub require_explicit_confirmation: bool, pub dismiss_parent_on_child_accept: bool, pub search_value: Option, pub disabled_reason: Option, @@ -807,6 +809,22 @@ impl ListSelectionView { } } + fn select_shortcut(&mut self, actual_idx: usize) { + let previously_selected = self.selected_actual_idx(); + self.state.selected_idx = Some(actual_idx); + if self + .active_items() + .get(actual_idx) + .is_some_and(|item| item.require_explicit_confirmation) + { + if self.selected_actual_idx() != previously_selected { + self.fire_selection_changed(); + } + } else { + self.accept(); + } + } + #[cfg(test)] pub(crate) fn set_search_query(&mut self, query: String) { self.search_query = query; @@ -1047,8 +1065,7 @@ impl BottomPaneView for ListSelectionView { .is_some_and(|shortcut| shortcut.is_press(key_event)) && Self::item_is_enabled(item) }) { - self.state.selected_idx = Some(idx); - self.accept(); + self.select_shortcut(idx); return; } if let Some(idx) = c @@ -1056,8 +1073,7 @@ impl BottomPaneView for ListSelectionView { .map(|d| d as usize) .and_then(|number| self.actual_idx_for_enabled_number(number)) { - self.state.selected_idx = Some(idx); - self.accept(); + self.select_shortcut(idx); } } _ => {} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__light_terminal_palette_composer.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__light_terminal_palette_composer.snap new file mode 100644 index 0000000000..413a7347cd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__light_terminal_palette_composer.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: "format!(\"{buffer:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 48, height: 6 }, + content: [ + " ", + "› Ask Codex to do anything ", + " ", + " ", + " ", + " ? for shortcuts 100% context left ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Rgb(244, 244, 244), underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Rgb(244, 244, 244), underline: Reset, modifier: BOLD, + x: 1, y: 1, fg: Reset, bg: Rgb(244, 244, 244), underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Rgb(244, 244, 244), underline: Reset, modifier: DIM, + x: 26, y: 1, fg: Reset, bg: Rgb(244, 244, 244), underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 17, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 29, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 46, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 1f8fe08bc0..8a359534da 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -524,6 +524,80 @@ async fn windows_sandbox_required_enable_prompt_reopens_on_cancel_when_unelevate )); } +#[tokio::test] +async fn fragmented_terminal_response_cannot_select_non_admin_windows_sandbox() { + for use_fallback_prompt in [false, true] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("auto preset"); + + if use_fallback_prompt { + chat.open_windows_sandbox_fallback_prompt(preset, /*profile_selection*/ None); + } else { + chat.open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None); + } + + for character in "20;rgb:2222/ffff/ffff".chars() { + chat.handle_key_event(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)); + assert!( + !matches!( + rx.try_recv(), + Ok(AppEvent::BeginWindowsSandboxLegacySetup { .. }) + ), + "a fragmented terminal response must not choose the non-admin sandbox" + ); + } + + assert!(chat.has_active_view()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::BeginWindowsSandboxLegacySetup { .. }) + )); + } +} + +#[tokio::test] +async fn fragmented_terminal_response_cannot_acknowledge_world_writable_warning() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.open_world_writable_warning_confirmation( + /*preset*/ None, + /*profile_selection*/ None, + Vec::new(), + /*extra_count*/ 0, + /*failed_scan*/ true, + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert!(chat.has_active_view()); + assert!(rx.try_recv().is_err()); + + for character in "20;rgb:2222/ffff/ffff".chars() { + chat.handle_key_event(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)); + assert!( + !matches!( + rx.try_recv(), + Ok(AppEvent::UpdateWorldWritableWarningAcknowledged(_) + | AppEvent::PersistWorldWritableWarningAcknowledged) + ), + "a fragmented terminal response must not acknowledge the world-writable warning" + ); + } + + assert!(chat.has_active_view()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::UpdateWorldWritableWarningAcknowledged(true)) + )); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::PersistWorldWritableWarningAcknowledged) + )); +} + #[tokio::test] async fn required_windows_sandbox_setup_defers_configured_initial_prompt() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index 6bef4f93d1..15ebd816be 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -66,7 +66,7 @@ impl ChatWidget { None } - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", test))] pub(crate) fn open_world_writable_warning_confirmation( &mut self, preset: Option, @@ -185,6 +185,7 @@ impl ChatWidget { description: Some(format!("Apply {mode_label} for this session")), actions: accept_actions, dismiss_on_select: true, + require_explicit_confirmation: true, ..Default::default() }, SelectionItem { @@ -192,6 +193,7 @@ impl ChatWidget { description: Some(format!("Enable {mode_label} and remember this choice")), actions: accept_and_remember_actions, dismiss_on_select: true, + require_explicit_confirmation: true, ..Default::default() }, ]; @@ -204,7 +206,7 @@ impl ChatWidget { }); } - #[cfg(not(target_os = "windows"))] + #[cfg(all(not(target_os = "windows"), not(test)))] pub(crate) fn open_world_writable_warning_confirmation( &mut self, _preset: Option, @@ -288,6 +290,7 @@ impl ChatWidget { }); })], dismiss_on_select: true, + require_explicit_confirmation: true, ..Default::default() }); } @@ -412,6 +415,7 @@ impl ChatWidget { } })], dismiss_on_select: true, + require_explicit_confirmation: true, ..Default::default() }); } diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index ed34860d5e..9f9301a005 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -136,7 +136,9 @@ impl KeyboardHandler for TrustDirectoryWidget { } else if keys::MOVE_DOWN.is_pressed(key_event) { self.highlighted = TrustDirectorySelection::Quit; } else if keys::SELECT_FIRST.is_pressed(key_event) { - self.handle_trust(); + // A terminal response fragment can start with `1`; trust always requires an explicit + // Enter confirmation after the directory prompt is visible. + self.highlighted = TrustDirectorySelection::Trust; } else if keys::SELECT_SECOND.is_pressed(key_event) || keys::QUIT.is_pressed(key_event) || keys::CANCEL.is_pressed(key_event) @@ -232,6 +234,24 @@ mod tests { assert!(widget.should_quit); } + #[test] + fn fragmented_terminal_response_cannot_grant_directory_trust() { + let mut widget = widget(/*error*/ None); + widget.highlighted = TrustDirectorySelection::Quit; + + // The prefix may have been consumed by the protected-screen input drain, leaving the + // numeric OSC slot as the first key delivered after the trust prompt becomes active. + for character in "10;rgb:ffff/ffff/ffff".chars() { + widget.handle_key_event(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)); + } + + assert_eq!(widget.selection, None); + assert_eq!(widget.highlighted, TrustDirectorySelection::Trust); + + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(widget.selection, Some(TrustDirectorySelection::Trust)); + } + #[test] fn renders_snapshot_for_git_repo() { let widget = widget(/*error*/ None); diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index 4a8f643338..d6967527c2 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -241,7 +241,10 @@ fn selection_view_params( footer_hint: Some(standard_popup_hint_line_for_keymap(&keymap.list)), items: vec![ selection_item("Review hooks", trusting_all), - selection_item("Trust all and continue", trusting_all), + SelectionItem { + require_explicit_confirmation: true, + ..selection_item("Trust all and continue", trusting_all) + }, selection_item("Continue without trusting (hooks won't run)", trusting_all), ], header: Box::new(header), @@ -297,10 +300,13 @@ impl WidgetRef for &StandaloneSelectionView<'_> { #[cfg(test)] mod tests { + use super::StartupHooksReviewSelection; use super::review_is_needed; + use super::selected_choice; use super::selection_view; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::BottomPaneView; use crate::keymap::RuntimeKeymap; use crate::render::renderable::Renderable; use crate::test_support::PathBufExt; @@ -311,6 +317,9 @@ mod tests { use codex_app_server_protocol::HookSource; use codex_app_server_protocol::HookTrustStatus; use codex_app_server_protocol::HooksListEntry; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; use insta::assert_snapshot; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -385,6 +394,56 @@ mod tests { assert!(review_is_needed(/*bypass_hook_trust*/ false, &entry())); } + #[test] + fn fragmented_terminal_response_cannot_grant_startup_hook_trust() { + let (tx_raw, _rx) = unbounded_channel::(); + let keymap = RuntimeKeymap::defaults(); + let mut view = selection_view( + &entry(), + /*trust_all_error*/ None, + /*trusting_all*/ false, + AppEventSender::new(tx_raw), + &keymap, + ); + + // A delayed OSC reply can lose its escape prefix in the protected-screen input drain. + for character in "20;rgb:2222/ffff/ffff".chars() { + view.handle_key_event(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)); + assert_ne!( + selected_choice(&mut view), + Some(StartupHooksReviewSelection::TrustAllAndContinue) + ); + } + + assert_eq!(view.selected_index(), Some(1)); + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + selected_choice(&mut view), + Some(StartupHooksReviewSelection::TrustAllAndContinue) + ); + } + + #[test] + fn non_trust_startup_hook_shortcuts_remain_immediate() { + let keymap = RuntimeKeymap::defaults(); + for (shortcut, expected) in [ + ('1', StartupHooksReviewSelection::ReviewHooks), + ('3', StartupHooksReviewSelection::ContinueWithoutTrusting), + ] { + let (tx_raw, _rx) = unbounded_channel::(); + let mut view = selection_view( + &entry(), + /*trust_all_error*/ None, + /*trusting_all*/ false, + AppEventSender::new(tx_raw), + &keymap, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char(shortcut), KeyModifiers::NONE)); + assert_eq!(selected_choice(&mut view), Some(expected)); + } + } + #[test] fn renders_prompt() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index 2c2a3bf03b..9c4b4eb696 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -41,6 +41,11 @@ pub fn best_color_for_level(target: (u8, u8, u8), color_level: StdoutColorLevel) } pub(crate) fn effective_stdout_color_level() -> StdoutColorLevel { + #[cfg(test)] + if TEST_DEFAULT_COLORS.with(|colors| colors.get().is_some()) { + return StdoutColorLevel::TrueColor; + } + stdout_color_level_for_terminal( stdout_color_level(), terminal_info().name, @@ -89,10 +94,39 @@ pub struct DefaultColors { bg: (u8, u8, u8), } +#[cfg(test)] +thread_local! { + static TEST_DEFAULT_COLORS: std::cell::Cell> = const { + std::cell::Cell::new(None) + }; +} + pub fn default_colors() -> Option { + #[cfg(test)] + if let Some(colors) = TEST_DEFAULT_COLORS.with(std::cell::Cell::get) { + return Some(colors); + } + imp::default_colors() } +/// Scope a truecolor terminal palette to the current test thread while rendering a real widget. +#[cfg(test)] +pub(crate) fn with_test_default_colors( + colors: crate::terminal_probe::DefaultColors, + render: impl FnOnce() -> T, +) -> T { + TEST_DEFAULT_COLORS.with(|override_colors| { + let previous = override_colors.replace(Some(DefaultColors { + fg: colors.fg, + bg: colors.bg, + })); + let result = render(); + override_colors.set(previous); + result + }) +} + pub fn default_fg() -> Option<(u8, u8, u8)> { default_colors().map(|c| c.fg) } diff --git a/codex-rs/tui/src/terminal_probe.rs b/codex-rs/tui/src/terminal_probe.rs index b2be4615f0..0117bbe330 100644 --- a/codex-rs/tui/src/terminal_probe.rs +++ b/codex-rs/tui/src/terminal_probe.rs @@ -16,6 +16,10 @@ use std::time::Duration; #[cfg(unix)] mod startup_replay; +#[cfg(any(windows, test))] +#[path = "terminal_probe/windows_replay.rs"] +mod windows_replay; + /// Default wall-clock budget for each startup probe group. pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100); @@ -709,136 +713,10 @@ mod imp { } #[cfg(windows)] -mod imp { - use super::DefaultColors; - use std::io; - use std::time::Duration; - use windows_sys::Win32::Foundation::HANDLE; - use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; - use windows_sys::Win32::System::Console::CONSOLE_SCREEN_BUFFER_INFOEX; - use windows_sys::Win32::System::Console::GetConsoleScreenBufferInfoEx; - use windows_sys::Win32::System::Console::GetStdHandle; - use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; +#[path = "terminal_probe/windows.rs"] +mod imp; - /// Reads default colors from the console screen buffer without consuming terminal input. - /// - /// OSC replies share the console input queue with user keystrokes on Windows. Reading them - /// directly during startup can discard composer typeahead, so use the non-reading console - /// color fallback instead. - pub(crate) fn default_colors(_timeout: Duration) -> io::Result> { - let Ok(output) = std_handle(STD_OUTPUT_HANDLE) else { - return Ok(None); - }; - Ok(query_console_default_colors(output).ok().flatten()) - } - - fn query_console_default_colors(output: HANDLE) -> io::Result> { - let mut info = unsafe { std::mem::zeroed::() }; - info.cbSize = std::mem::size_of::() as u32; - if unsafe { GetConsoleScreenBufferInfoEx(output, &mut info) } == 0 { - return Err(io::Error::last_os_error()); - } - Ok(Some(decode_console_default_colors( - info.wAttributes, - &info.ColorTable, - ))) - } - - fn decode_console_default_colors(attributes: u16, color_table: &[u32; 16]) -> DefaultColors { - let fg_index = (attributes & 0x0f) as usize; - let bg_index = ((attributes >> 4) & 0x0f) as usize; - // COMMON_LVB_REVERSE_VIDEO changes how cells render, but this probe is discovering the - // configured default colors for palette blending. Keep the attribute fg/bg indices as-is. - DefaultColors { - fg: decode_color_ref(color_table[fg_index]), - bg: decode_color_ref(color_table[bg_index]), - } - } - - fn decode_color_ref(color_ref: u32) -> (u8, u8, u8) { - ( - (color_ref & 0xff) as u8, - ((color_ref >> 8) & 0xff) as u8, - ((color_ref >> 16) & 0xff) as u8, - ) - } - - fn std_handle(kind: u32) -> io::Result { - let handle = unsafe { GetStdHandle(kind) }; - if handle == 0 || handle == INVALID_HANDLE_VALUE { - return Err(io::Error::last_os_error()); - } - Ok(handle) - } - - #[cfg(test)] - mod tests { - use super::*; - use pretty_assertions::assert_eq; - use windows_sys::Win32::System::Console::COMMON_LVB_REVERSE_VIDEO; - - fn color_table() -> [u32; 16] { - [ - 0x00000000, 0x00000080, 0x00008000, 0x00008080, 0x00800000, 0x00800080, 0x00808000, - 0x00c0c0c0, 0x00808080, 0x000000ff, 0x0000ff00, 0x0000ffff, 0x00ff0000, 0x00ff00ff, - 0x00ffff00, 0x00ffffff, - ] - } - - #[test] - fn decodes_console_color_attribute_indices() { - assert_eq!( - decode_console_default_colors(/*attributes*/ 0x21, &color_table()), - DefaultColors { - fg: (128, 0, 0), - bg: (0, 128, 0), - } - ); - } - - #[test] - fn decodes_console_color_intensity_indices() { - assert_eq!( - decode_console_default_colors(/*attributes*/ 0xe9, &color_table()), - DefaultColors { - fg: (255, 0, 0), - bg: (0, 255, 255), - } - ); - } - - #[test] - fn decodes_console_color_ref_byte_order() { - let mut colors = color_table(); - colors[3] = 0x00112233; - colors[4] = 0x00aabbcc; - - assert_eq!( - decode_console_default_colors(/*attributes*/ 0x43, &colors), - DefaultColors { - fg: (0x33, 0x22, 0x11), - bg: (0xcc, 0xbb, 0xaa), - } - ); - } - - #[test] - fn ignores_reverse_video_when_decoding_default_colors() { - assert_eq!( - decode_console_default_colors( - /*attributes*/ COMMON_LVB_REVERSE_VIDEO | 0x21, - &color_table(), - ), - DefaultColors { - fg: (128, 0, 0), - bg: (0, 128, 0), - } - ); - } - } -} - -#[cfg(any(unix, test))] +#[cfg(any(unix, windows, test))] fn parse_osc_color(buffer: &[u8], slot: u8) -> Option<(u8, u8, u8)> { let prefix = format!("\x1B]{slot};"); let start = find_subslice(buffer, prefix.as_bytes())?; @@ -856,7 +734,7 @@ fn parse_default_colors(buffer: &[u8]) -> Option { Some(DefaultColors { fg, bg }) } -#[cfg(any(unix, test))] +#[cfg(any(unix, windows, test))] fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> { let mut idx = 0; while idx < buffer.len() { @@ -869,7 +747,7 @@ fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> { None } -#[cfg(any(unix, test))] +#[cfg(any(unix, windows, test))] fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> { let (prefix, values) = payload.trim().split_once(':')?; if !prefix.eq_ignore_ascii_case("rgb") && !prefix.eq_ignore_ascii_case("rgba") { @@ -886,18 +764,18 @@ fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> { parts.next().is_none().then_some((r, g, b)) } -#[cfg(any(unix, test))] +#[cfg(any(unix, windows, test))] fn parse_osc_component(component: &str) -> Option { - match component.len() { - 2 => u8::from_str_radix(component, 16).ok(), - 4 => u16::from_str_radix(component, 16) - .ok() - .map(|value| (value / 257) as u8), - _ => None, + if !(1..=4).contains(&component.len()) { + return None; } + + let value = u32::from(u16::from_str_radix(component, /*radix*/ 16).ok()?); + let maximum = (1_u32 << (component.len() * 4)) - 1; + Some((value * u32::from(u8::MAX) / maximum) as u8) } -#[cfg(any(unix, test))] +#[cfg(any(unix, windows, test))] fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { haystack .windows(needle.len()) @@ -925,12 +803,15 @@ mod tests { } #[test] - fn parses_two_and_four_digit_color_components() { + fn parses_one_to_four_digit_color_components() { + assert_eq!(parse_osc_rgb("rgb:f/e/d"), Some((255, 238, 221))); assert_eq!(parse_osc_rgb("rgb:00/80/ff"), Some((0, 128, 255))); + assert_eq!(parse_osc_rgb("rgb:fff/800/000"), Some((255, 127, 0))); assert_eq!( parse_osc_rgb("rgba:ffff/8000/0000/ffff"), Some((255, 127, 0)) ); + assert_eq!(parse_osc_rgb("rgb:fffff/0/0"), None); } #[test] diff --git a/codex-rs/tui/src/terminal_probe/windows.rs b/codex-rs/tui/src/terminal_probe/windows.rs new file mode 100644 index 0000000000..bbaafcf274 --- /dev/null +++ b/codex-rs/tui/src/terminal_probe/windows.rs @@ -0,0 +1,335 @@ +//! Probe visible Windows terminal colors without consuming startup typeahead. + +use super::DefaultColors; +use super::windows_replay::terminal_color_response_ranges; +use super::windows_replay::terminal_default_colors; +use std::io; +use std::io::ErrorKind; +use std::time::Duration; +use std::time::Instant; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::Foundation::WAIT_OBJECT_0; +use windows_sys::Win32::Foundation::WAIT_TIMEOUT; +use windows_sys::Win32::Storage::FileSystem::WriteFile; +use windows_sys::Win32::System::Console::CONSOLE_SCREEN_BUFFER_INFOEX; +use windows_sys::Win32::System::Console::GetConsoleScreenBufferInfoEx; +use windows_sys::Win32::System::Console::GetNumberOfConsoleInputEvents; +use windows_sys::Win32::System::Console::GetStdHandle; +use windows_sys::Win32::System::Console::INPUT_RECORD; +use windows_sys::Win32::System::Console::KEY_EVENT; +use windows_sys::Win32::System::Console::ReadConsoleInputW; +use windows_sys::Win32::System::Console::STD_INPUT_HANDLE; +use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; +use windows_sys::Win32::System::Console::WriteConsoleInputW; +use windows_sys::Win32::System::Threading::WaitForSingleObject; + +const MAX_WINDOWS_PROBE_RECORDS: usize = 64 * 1_024; +const WINDOWS_PROBE_READ_RECORDS: usize = 64; + +/// Prefer the terminal renderer's OSC colors while retaining the native console palette fallback. +/// +/// Windows Terminal and xterm.js can render a theme unrelated to ConPTY's console color table. +/// Their OSC responses share the input queue with user input, so the probe preserves and restores +/// the original console records without changing Windows' input-record mode. +pub(crate) fn default_colors(timeout: Duration) -> io::Result> { + let Ok(output) = std_handle(STD_OUTPUT_HANDLE) else { + return Ok(None); + }; + + if let Ok(input) = std_handle(STD_INPUT_HANDLE) + && let Ok(Some(colors)) = query_osc_default_colors(input, output, timeout) + { + tracing::debug!( + foreground = ?colors.fg, + background = ?colors.bg, + "using Windows terminal OSC default colors" + ); + return Ok(Some(colors)); + } + + let colors = query_console_default_colors(output).ok().flatten(); + if let Some(colors) = colors { + tracing::debug!( + foreground = ?colors.fg, + background = ?colors.bg, + "using Windows console palette default colors" + ); + } + Ok(colors) +} + +fn query_osc_default_colors( + input: HANDLE, + output: HANDLE, + timeout: Duration, +) -> io::Result> { + let deadline = Instant::now() + timeout; + write_all(output, b"\x1b]10;?\x1b\\\x1b]11;?\x1b\\")?; + + let mut replay = ConsoleInputReplay::new(input); + let result = replay.read_until(deadline); + let replay_result = replay.replay(); + replay_result?; + result +} + +/// Preserve native console records and map response bytes back to their original records. +#[derive(Default)] +struct BufferedConsoleInput { + records: Vec, + bytes: Vec, + byte_record_indices: Vec, +} + +impl BufferedConsoleInput { + fn push(&mut self, record: INPUT_RECORD) { + let index = self.records.len(); + self.records.push(record); + + if record.EventType != KEY_EVENT as u16 { + return; + } + + // SAFETY: EventType identifies the active INPUT_RECORD union member. + let key = unsafe { record.Event.KeyEvent }; + if key.bKeyDown == 0 { + return; + } + + // SAFETY: ReadConsoleInputW populates the UnicodeChar member of KEY_EVENT_RECORD. + let character = unsafe { key.uChar.UnicodeChar }; + if let Ok(byte) = u8::try_from(character) + && byte.is_ascii() + && byte != 0 + { + self.bytes.push(byte); + self.byte_record_indices.push(index); + } + } + + fn preserved_records(&self) -> Vec { + let mut omitted = vec![false; self.records.len()]; + for response in terminal_color_response_ranges(&self.bytes) { + for byte_index in response { + let record_index = self.byte_record_indices[byte_index]; + omitted[record_index] = true; + } + } + + self.records + .iter() + .copied() + .enumerate() + .filter_map(|(index, record)| (!omitted[index]).then_some(record)) + .collect() + } +} + +/// Replay every non-response console event even when probing exits through an error path. +struct ConsoleInputReplay { + handle: HANDLE, + input: BufferedConsoleInput, + replayed_records: usize, +} + +impl ConsoleInputReplay { + fn new(handle: HANDLE) -> Self { + Self { + handle, + input: BufferedConsoleInput::default(), + replayed_records: 0, + } + } + + fn read_until(&mut self, deadline: Instant) -> io::Result> { + loop { + if let Some(colors) = terminal_default_colors(&self.input.bytes) { + return Ok(Some(colors)); + } + if self.input.records.len() >= MAX_WINDOWS_PROBE_RECORDS { + return Ok(None); + } + + let now = Instant::now(); + if now >= deadline { + return Ok(None); + } + let timeout_ms = deadline + .saturating_duration_since(now) + .as_millis() + .min(u32::MAX as u128) as u32; + + match unsafe { + WaitForSingleObject(self.handle, timeout_ms.max(/*other*/ 1)) + } { + WAIT_OBJECT_0 => { + self.read_available()?; + } + WAIT_TIMEOUT => return Ok(None), + _ => return Err(io::Error::last_os_error()), + } + } + } + + fn read_available(&mut self) -> io::Result { + let mut pending = 0; + if unsafe { GetNumberOfConsoleInputEvents(self.handle, &mut pending) } == 0 { + return Err(io::Error::last_os_error()); + } + if pending == 0 { + return Ok(0); + } + + let mut records = + [unsafe { std::mem::zeroed::() }; WINDOWS_PROBE_READ_RECORDS]; + let max_records = records + .len() + .min(pending as usize) + .min(MAX_WINDOWS_PROBE_RECORDS.saturating_sub(self.input.records.len())); + if max_records == 0 { + return Ok(0); + } + let mut read = 0; + let result = unsafe { + ReadConsoleInputW( + self.handle, + records.as_mut_ptr(), + max_records as u32, + &mut read, + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + for record in records.into_iter().take(read as usize) { + self.input.push(record); + } + Ok(read as usize) + } + + fn replay(&mut self) -> io::Result<()> { + if self.input.records.is_empty() { + return Ok(()); + } + + if self.replayed_records == 0 { + // WriteConsoleInputW appends, so include already queued later records before restoring + // the earlier records. Preserve one bounded queue snapshot without waiting on input. + let mut pending = 0; + if unsafe { GetNumberOfConsoleInputEvents(self.handle, &mut pending) } == 0 { + return Err(io::Error::last_os_error()); + } + let mut remaining = (pending as usize) + .min(MAX_WINDOWS_PROBE_RECORDS.saturating_sub(self.input.records.len())); + while remaining != 0 { + let read = self.read_available()?; + if read == 0 { + break; + } + remaining = remaining.saturating_sub(read); + } + } + + let preserved = self.input.preserved_records(); + while self.replayed_records < preserved.len() { + let remaining = &preserved[self.replayed_records..]; + let mut written = 0; + let result = unsafe { + WriteConsoleInputW( + self.handle, + remaining.as_ptr(), + remaining.len().min(u32::MAX as usize) as u32, + &mut written, + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + if written == 0 { + return Err(io::Error::from(ErrorKind::WriteZero)); + } + self.replayed_records += written as usize; + } + + self.input.records.clear(); + self.input.bytes.clear(); + self.input.byte_record_indices.clear(); + self.replayed_records = 0; + Ok(()) + } +} + +impl Drop for ConsoleInputReplay { + fn drop(&mut self) { + if let Err(error) = self.replay() { + tracing::warn!(%error, "failed to restore Windows terminal startup input"); + } + } +} + +fn write_all(handle: HANDLE, mut bytes: &[u8]) -> io::Result<()> { + while !bytes.is_empty() { + let mut written = 0; + let result = unsafe { + WriteFile( + handle, + bytes.as_ptr().cast(), + bytes.len().min(u32::MAX as usize) as u32, + &mut written, + std::ptr::null_mut(), + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + if written == 0 { + return Err(io::Error::from(ErrorKind::WriteZero)); + } + bytes = &bytes[written as usize..]; + } + Ok(()) +} + +fn query_console_default_colors(output: HANDLE) -> io::Result> { + let mut info = unsafe { std::mem::zeroed::() }; + info.cbSize = std::mem::size_of::() as u32; + if unsafe { GetConsoleScreenBufferInfoEx(output, &mut info) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(Some(decode_console_default_colors( + info.wAttributes, + &info.ColorTable, + ))) +} + +fn decode_console_default_colors(attributes: u16, color_table: &[u32; 16]) -> DefaultColors { + let fg_index = (attributes & 0x0f) as usize; + let bg_index = ((attributes >> 4) & 0x0f) as usize; + // COMMON_LVB_REVERSE_VIDEO changes how cells render, but this probe is discovering the + // configured default colors for palette blending. Keep the attribute fg/bg indices as-is. + DefaultColors { + fg: decode_color_ref(color_table[fg_index]), + bg: decode_color_ref(color_table[bg_index]), + } +} + +fn decode_color_ref(color_ref: u32) -> (u8, u8, u8) { + ( + (color_ref & 0xff) as u8, + ((color_ref >> 8) & 0xff) as u8, + ((color_ref >> 16) & 0xff) as u8, + ) +} + +fn std_handle(kind: u32) -> io::Result { + let handle = unsafe { GetStdHandle(kind) }; + if handle == 0 || handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + Ok(handle) +} + +#[cfg(test)] +#[path = "windows_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/terminal_probe/windows_replay.rs b/codex-rs/tui/src/terminal_probe/windows_replay.rs new file mode 100644 index 0000000000..a91ead179b --- /dev/null +++ b/codex-rs/tui/src/terminal_probe/windows_replay.rs @@ -0,0 +1,84 @@ +//! Identify terminal-color replies without changing unrelated Windows console input. + +use std::ops::Range; + +const MAX_TERMINAL_COLOR_RESPONSE_BYTES: usize = 1_024; +const PASTE_START: &[u8] = b"\x1b[200~"; +const PASTE_END: &[u8] = b"\x1b[201~"; + +/// Return the byte ranges occupied by complete, valid OSC 10/11 color responses. +/// +/// The Windows probe maps these byte ranges back to their original `INPUT_RECORD`s so keyboard +/// modifiers, UTF-16 input, mouse events, and focus changes can be replayed without reconstruction. +pub(super) fn terminal_color_response_ranges(input: &[u8]) -> Vec> { + let mut ranges = Vec::new(); + let mut cursor = 0; + + while cursor < input.len() { + if input[cursor..].starts_with(PASTE_START) { + let payload_start = cursor + PASTE_START.len(); + let Some(payload_end) = super::find_subslice(&input[payload_start..], PASTE_END) else { + break; + }; + cursor = payload_start + payload_end + PASTE_END.len(); + continue; + } + if !input[cursor..].starts_with(b"\x1b]") { + cursor += 1; + continue; + } + + let start = cursor; + let response = &input[start..]; + let Some((slot, prefix)) = [(10_u8, b"\x1b]10;"), (11_u8, b"\x1b]11;")] + .into_iter() + .find(|(_, prefix)| response.starts_with(*prefix)) + else { + cursor = start + b"\x1b]".len(); + continue; + }; + + let payload_start = start + prefix.len(); + let bounded_end = input + .len() + .min(start.saturating_add(MAX_TERMINAL_COLOR_RESPONSE_BYTES)); + let Some((payload_len, terminator_len)) = + super::osc_payload_end(&input[payload_start..bounded_end]) + else { + cursor = payload_start; + continue; + }; + let end = payload_start + payload_len + terminator_len; + if super::parse_osc_color(&input[start..end], slot).is_some() { + ranges.push(start..end); + } + cursor = end; + } + + ranges +} + +/// Parse only real probe replies, never OSC-looking content inside a bracketed paste. +pub(super) fn terminal_default_colors(input: &[u8]) -> Option { + let mut foreground = None; + let mut background = None; + + for range in terminal_color_response_ranges(input) { + let response = &input[range]; + if foreground.is_none() { + foreground = super::parse_osc_color(response, /*slot*/ 10); + } + if background.is_none() { + background = super::parse_osc_color(response, /*slot*/ 11); + } + } + + Some(super::DefaultColors { + fg: foreground?, + bg: background?, + }) +} + +#[cfg(test)] +#[path = "windows_replay_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/terminal_probe/windows_replay_tests.rs b/codex-rs/tui/src/terminal_probe/windows_replay_tests.rs new file mode 100644 index 0000000000..3b7a590a8a --- /dev/null +++ b/codex-rs/tui/src/terminal_probe/windows_replay_tests.rs @@ -0,0 +1,94 @@ +use super::MAX_TERMINAL_COLOR_RESPONSE_BYTES; +use super::terminal_color_response_ranges; +use super::terminal_default_colors; +use crate::terminal_probe::DefaultColors; +use pretty_assertions::assert_eq; + +fn preserved_input(input: &[u8]) -> Vec { + let ranges = terminal_color_response_ranges(input); + input + .iter() + .enumerate() + .filter_map(|(index, byte)| { + (!ranges.iter().any(|range| range.contains(&index))).then_some(*byte) + }) + .collect() +} + +#[test] +fn preserves_typeahead_before_between_and_after_terminal_color_replies() { + let input = b"before\x1b]10;rgb:5555/5757/5353\x1b\\between\x1b]11;rgb:ffff/ffff/ffff\x07after"; + + assert_eq!(preserved_input(input), b"beforebetweenafter"); +} + +#[test] +fn preserves_unrelated_malformed_and_partial_osc_sequences() { + let input = b"\x1b]52;clipboard\x07\x1b]10;not-a-color\x07typed\x1b]11;rgb:ffff/ffff"; + + assert_eq!(preserved_input(input), input); +} + +#[test] +fn preserves_terminal_color_response_that_exceeds_the_byte_limit() { + let mut input = b"prefix\x1b]10;".to_vec(); + input.extend(std::iter::repeat_n(b'x', MAX_TERMINAL_COLOR_RESPONSE_BYTES)); + input.extend_from_slice(b"\x07suffix"); + + assert_eq!(preserved_input(&input), input); +} + +#[test] +fn finds_terminal_color_replies_after_unrelated_escape_sequences() { + let input = b"\x1b]52;clipboard\x07typed\x1b]11;rgba:ffff/eeee/dddd/ffff\x1b\\suffix"; + + assert_eq!(preserved_input(input), b"\x1b]52;clipboard\x07typedsuffix"); +} + +#[test] +fn consumes_one_and_three_digit_terminal_color_replies() { + let input = b"before\x1b]10;rgb:f/e/d\x07middle\x1b]11;rgb:fff/800/000\x1b\\after"; + + assert_eq!(preserved_input(input), b"beforemiddleafter"); + assert_eq!( + terminal_default_colors(input), + Some(DefaultColors { + fg: (255, 238, 221), + bg: (255, 127, 0), + }) + ); +} + +#[test] +fn preserves_complete_osc_looking_bracketed_paste() { + let input = b"before\x1b[200~\x1b]10;rgb:f/e/d\x07\x1b]11;rgb:fff/800/000\x07\x1b[201~after"; + + assert_eq!(preserved_input(input), input); + assert_eq!(terminal_default_colors(input), None); +} + +#[test] +fn preserves_unfinished_osc_looking_bracketed_paste() { + let input = b"before\x1b[200~\x1b]10;rgb:f/e/d\x07\x1b]11;rgb:fff/800/000\x07"; + + assert_eq!(preserved_input(input), input); + assert_eq!(terminal_default_colors(input), None); +} + +#[test] +fn parses_real_color_replies_around_bracketed_paste() { + let input = + b"\x1b]10;rgb:f/e/d\x07\x1b[200~\x1b]11;rgb:0/0/0\x07\x1b[201~\x1b]11;rgb:fff/800/000\x07"; + + assert_eq!( + preserved_input(input), + b"\x1b[200~\x1b]11;rgb:0/0/0\x07\x1b[201~" + ); + assert_eq!( + terminal_default_colors(input), + Some(DefaultColors { + fg: (255, 238, 221), + bg: (255, 127, 0), + }) + ); +} diff --git a/codex-rs/tui/src/terminal_probe/windows_tests.rs b/codex-rs/tui/src/terminal_probe/windows_tests.rs new file mode 100644 index 0000000000..60ca87bdc7 --- /dev/null +++ b/codex-rs/tui/src/terminal_probe/windows_tests.rs @@ -0,0 +1,206 @@ +use super::BufferedConsoleInput; +use super::DefaultColors; +use super::decode_console_default_colors; +use pretty_assertions::assert_eq; +use windows_sys::Win32::System::Console::COMMON_LVB_REVERSE_VIDEO; +use windows_sys::Win32::System::Console::FOCUS_EVENT; +use windows_sys::Win32::System::Console::FOCUS_EVENT_RECORD; +use windows_sys::Win32::System::Console::INPUT_RECORD; +use windows_sys::Win32::System::Console::INPUT_RECORD_0; +use windows_sys::Win32::System::Console::KEY_EVENT; +use windows_sys::Win32::System::Console::KEY_EVENT_RECORD; +use windows_sys::Win32::System::Console::KEY_EVENT_RECORD_0; + +fn color_table() -> [u32; 16] { + [ + 0x00000000, 0x00000080, 0x00008000, 0x00008080, 0x00800000, 0x00800080, 0x00808000, + 0x00c0c0c0, 0x00808080, 0x000000ff, 0x0000ff00, 0x0000ffff, 0x00ff0000, 0x00ff00ff, + 0x00ffff00, 0x00ffffff, + ] +} + +fn key_record(character: u16, pressed: bool, control_key_state: u32) -> INPUT_RECORD { + INPUT_RECORD { + EventType: KEY_EVENT as u16, + Event: INPUT_RECORD_0 { + KeyEvent: KEY_EVENT_RECORD { + bKeyDown: i32::from(pressed), + wRepeatCount: 1, + wVirtualKeyCode: character, + wVirtualScanCode: 0, + uChar: KEY_EVENT_RECORD_0 { + UnicodeChar: character, + }, + dwControlKeyState: control_key_state, + }, + }, + } +} + +fn push_ascii(input: &mut BufferedConsoleInput, bytes: &[u8]) { + for byte in bytes { + input.push(key_record( + u16::from(*byte), + /*pressed*/ true, + /*control_key_state*/ 0, + )); + } +} + +#[test] +fn preserves_typeahead_records_and_modifier_state_around_color_responses() { + let mut input = BufferedConsoleInput::default(); + let before = key_record( + u16::from(b'a'), + /*pressed*/ true, + /*control_key_state*/ 8, + ); + let after = key_record( + u16::from(b'b'), + /*pressed*/ true, + /*control_key_state*/ 16, + ); + input.push(before); + push_ascii(&mut input, b"\x1b]10;rgb:5555/5757/5353\x1b\\"); + input.push(after); + push_ascii(&mut input, b"\x1b]11;rgb:ffff/ffff/ffff\x07"); + + let preserved = input.preserved_records(); + assert_eq!(preserved.len(), 2); + // SAFETY: Both preserved records are constructed as KEY_EVENT records above. + let states: Vec<_> = preserved + .iter() + .map(|record| unsafe { record.Event.KeyEvent.dwControlKeyState }) + .collect(); + assert_eq!(states, vec![8, 16]); +} + +#[test] +fn preserves_focus_unicode_and_key_release_console_records() { + let mut input = BufferedConsoleInput::default(); + let focus = INPUT_RECORD { + EventType: FOCUS_EVENT as u16, + Event: INPUT_RECORD_0 { + FocusEvent: FOCUS_EVENT_RECORD { bSetFocus: 1 }, + }, + }; + let unicode = key_record( + /*character*/ 0x00e9, /*pressed*/ true, /*control_key_state*/ 0, + ); + let release = key_record( + u16::from(b'x'), + /*pressed*/ false, + /*control_key_state*/ 2, + ); + input.push(focus); + push_ascii(&mut input, b"\x1b]10;rgb:5555/5757/5353\x1b\\"); + input.push(unicode); + input.push(release); + push_ascii(&mut input, b"\x1b]11;rgb:ffff/ffff/ffff\x07"); + + let preserved = input.preserved_records(); + assert_eq!(preserved.len(), 3); + assert_eq!(preserved[0].EventType, FOCUS_EVENT as u16); + // SAFETY: The final records were constructed as KEY_EVENT records above. + let unicode_character = unsafe { preserved[1].Event.KeyEvent.uChar.UnicodeChar }; + let release_pressed = unsafe { preserved[2].Event.KeyEvent.bKeyDown }; + assert_eq!(unicode_character, 0x00e9); + assert_eq!(release_pressed, 0); +} + +#[test] +fn preserves_fifo_order_when_later_console_records_are_buffered() { + let mut input = BufferedConsoleInput::default(); + input.push(key_record( + u16::from(b'a'), + /*pressed*/ true, + /*control_key_state*/ 8, + )); + push_ascii(&mut input, b"\x1b]10;rgb:5/5/5\x07"); + push_ascii(&mut input, b"\x1b]11;rgb:f/f/f\x07"); + + // replay() drains records that arrived after its last probe read before writing anything. + input.push(key_record( + u16::from(b'b'), + /*pressed*/ true, + /*control_key_state*/ 16, + )); + + let preserved = input.preserved_records(); + let characters: Vec<_> = preserved + .iter() + // SAFETY: Both surviving records are KEY_EVENT values constructed above. + .map(|record| unsafe { record.Event.KeyEvent.uChar.UnicodeChar }) + .collect(); + assert_eq!(characters, vec![u16::from(b'a'), u16::from(b'b')]); +} + +#[test] +fn preserves_osc_looking_bracketed_paste_records() { + let mut input = BufferedConsoleInput::default(); + push_ascii( + &mut input, + b"\x1b[200~\x1b]10;rgb:f/e/d\x07\x1b]11;rgb:0/0/0\x07\x1b[201~", + ); + + assert_eq!(input.preserved_records().len(), input.records.len()); +} + +#[test] +fn preserves_every_record_when_no_valid_color_response_arrives() { + let mut input = BufferedConsoleInput::default(); + push_ascii(&mut input, b"typed\x1b]10;incomplete"); + + assert_eq!(input.preserved_records().len(), input.records.len()); +} + +#[test] +fn decodes_console_color_attribute_indices() { + assert_eq!( + decode_console_default_colors(/*attributes*/ 0x21, &color_table()), + DefaultColors { + fg: (128, 0, 0), + bg: (0, 128, 0), + } + ); +} + +#[test] +fn decodes_console_color_intensity_indices() { + assert_eq!( + decode_console_default_colors(/*attributes*/ 0xe9, &color_table()), + DefaultColors { + fg: (255, 0, 0), + bg: (0, 255, 255), + } + ); +} + +#[test] +fn decodes_console_color_ref_byte_order() { + let mut colors = color_table(); + colors[3] = 0x00112233; + colors[4] = 0x00aabbcc; + + assert_eq!( + decode_console_default_colors(/*attributes*/ 0x43, &colors), + DefaultColors { + fg: (0x33, 0x22, 0x11), + bg: (0xcc, 0xbb, 0xaa), + } + ); +} + +#[test] +fn ignores_reverse_video_when_decoding_default_colors() { + assert_eq!( + decode_console_default_colors( + /*attributes*/ COMMON_LVB_REVERSE_VIDEO | 0x21, + &color_table(), + ), + DefaultColors { + fg: (128, 0, 0), + bg: (0, 128, 0), + } + ); +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 623a9944a0..4ae3d7b93d 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -498,7 +498,9 @@ pub(crate) fn init() -> Result { !keyboard_modes::keyboard_enhancement_disabled() && detect_keyboard_enhancement_supported(); #[cfg(windows)] - probe_windows_default_colors(); + // OSC replies can arrive after their deadline. Do not issue terminal queries before directory + // trust and other protected startup screens have finished accepting their security decisions. + crate::terminal_palette::set_default_colors_from_startup_probe(/*colors*/ None); let tui = CustomTerminal::with_options_and_cursor_position(backend, cursor_pos)?; let stderr_guard = terminal_stderr::TerminalStderrGuard::install()?; @@ -693,6 +695,15 @@ impl Tui { self.event_broker.resume_events(); } + /// Discover the visible Windows theme only after protected startup decisions have completed. + #[cfg(windows)] + pub(crate) fn probe_default_colors_after_protected_startup(&mut self) { + self.pause_events(); + probe_windows_default_colors(); + self.resume_events(); + self.frame_requester.schedule_frame(); + } + /// Reclaim terminal modes and stderr after a panic hook ran inside a recovery boundary. pub(crate) fn recover_after_caught_panic(&mut self) -> Result<()> { set_modes()?;