mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
+27
@@ -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()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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()?;
|
||||
|
||||
Reference in New Issue
Block a user