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
This commit is contained in:
Felipe Coury
2026-08-25 12:20:41 +00:00
committed by copyberry
parent 1f5c9ba183
commit 70b5cfc73b
19 changed files with 1162 additions and 149 deletions
+4
View File
@@ -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,
);
}
}
+9
View File
@@ -10,6 +10,8 @@ pub(super) struct WindowsSandboxState {
pub(super) setup_started_at: Option<Instant>,
// 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);
}
});
}
}
+28
View File
@@ -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<String, String> = 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),
}
+100
View File
@@ -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<AppEvent>) {
)
}
#[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!(
+4
View File
@@ -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 {
@@ -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::<AppEvent>();
@@ -143,6 +143,8 @@ pub(crate) struct SelectionItem {
pub is_disabled: bool,
pub actions: Vec<SelectionAction>,
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<String>,
pub disabled_reason: Option<String>,
@@ -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);
}
}
_ => {}
@@ -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,
]
}
@@ -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;
@@ -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<ApprovalPreset>,
@@ -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<ApprovalPreset>,
@@ -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()
});
}
+21 -1
View File
@@ -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);
+60 -1
View File
@@ -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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
+34
View File
@@ -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<Option<DefaultColors>> = const {
std::cell::Cell::new(None)
};
}
pub fn default_colors() -> Option<DefaultColors> {
#[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<T>(
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)
}
+21 -140
View File
@@ -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<Option<DefaultColors>> {
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<Option<DefaultColors>> {
let mut info = unsafe { std::mem::zeroed::<CONSOLE_SCREEN_BUFFER_INFOEX>() };
info.cbSize = std::mem::size_of::<CONSOLE_SCREEN_BUFFER_INFOEX>() 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<HANDLE> {
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<DefaultColors> {
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<u8> {
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<usize> {
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]
+335
View File
@@ -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<Option<DefaultColors>> {
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<Option<DefaultColors>> {
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<INPUT_RECORD>,
bytes: Vec<u8>,
byte_record_indices: Vec<usize>,
}
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<INPUT_RECORD> {
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<Option<DefaultColors>> {
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<usize> {
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::<INPUT_RECORD>() }; 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<Option<DefaultColors>> {
let mut info = unsafe { std::mem::zeroed::<CONSOLE_SCREEN_BUFFER_INFOEX>() };
info.cbSize = std::mem::size_of::<CONSOLE_SCREEN_BUFFER_INFOEX>() 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<HANDLE> {
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;
@@ -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<Range<usize>> {
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<super::DefaultColors> {
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;
@@ -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<u8> {
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),
})
);
}
@@ -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),
}
);
}
+12 -1
View File
@@ -498,7 +498,9 @@ pub(crate) fn init() -> Result<InitializedTerminal> {
!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()?;