mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +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:
1 parent
1f5c9ba183
commit
70b5cfc73b
19 files changed
+1162
-149
No files matched your search
@@ -1611,6 +1611,9 @@ impl App {
|
|||||||
failed_scan,
|
failed_scan,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
AppEvent::StartupWorldWritableScanCompleted => {
|
||||||
|
self.windows_sandbox.startup_world_writable_scan_pending = false;
|
||||||
|
}
|
||||||
AppEvent::OpenFeedbackNote {
|
AppEvent::OpenFeedbackNote {
|
||||||
category,
|
category,
|
||||||
include_logs,
|
include_logs,
|
||||||
@@ -2298,6 +2301,7 @@ impl App {
|
|||||||
logs_base_dir,
|
logs_base_dir,
|
||||||
permission_profile,
|
permission_profile,
|
||||||
tx,
|
tx,
|
||||||
|
/*startup_scan*/ false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ pub(super) struct WindowsSandboxState {
|
|||||||
pub(super) setup_started_at: Option<Instant>,
|
pub(super) setup_started_at: Option<Instant>,
|
||||||
// One-shot suppression of the next world-writable scan after user confirmation.
|
// One-shot suppression of the next world-writable scan after user confirmation.
|
||||||
pub(super) skip_world_writable_scan_once: bool,
|
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 {
|
impl App {
|
||||||
@@ -21,6 +23,7 @@ impl App {
|
|||||||
logs_base_dir: AbsolutePathBuf,
|
logs_base_dir: AbsolutePathBuf,
|
||||||
permission_profile: PermissionProfile,
|
permission_profile: PermissionProfile,
|
||||||
tx: AppEventSender,
|
tx: AppEventSender,
|
||||||
|
startup_scan: bool,
|
||||||
) {
|
) {
|
||||||
let Ok(permissions) =
|
let Ok(permissions) =
|
||||||
codex_windows_sandbox::ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_workspace_roots(
|
codex_windows_sandbox::ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_workspace_roots(
|
||||||
@@ -28,6 +31,9 @@ impl App {
|
|||||||
workspace_roots.as_slice(),
|
workspace_roots.as_slice(),
|
||||||
)
|
)
|
||||||
else {
|
else {
|
||||||
|
if startup_scan {
|
||||||
|
tx.send(AppEvent::StartupWorldWritableScanCompleted);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,6 +51,9 @@ impl App {
|
|||||||
// Scan failed: warn without examples.
|
// Scan failed: warn without examples.
|
||||||
send_world_writable_scan_failed(&tx);
|
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(_))))
|
.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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
tui: &mut tui::Tui,
|
tui: &mut tui::Tui,
|
||||||
@@ -584,6 +595,7 @@ See the Codex keymap documentation for supported actions and examples."
|
|||||||
.hide_world_writable_warning
|
.hide_world_writable_warning
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if should_check {
|
if should_check {
|
||||||
|
app.windows_sandbox.startup_world_writable_scan_pending = true;
|
||||||
let cwd = app.config.cwd.clone();
|
let cwd = app.config.cwd.clone();
|
||||||
let workspace_roots = app.config.effective_workspace_roots();
|
let workspace_roots = app.config.effective_workspace_roots();
|
||||||
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
|
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,
|
logs_base_dir,
|
||||||
startup_permission_profile,
|
startup_permission_profile,
|
||||||
tx,
|
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);
|
.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();
|
let event_stream_started_at = Instant::now();
|
||||||
tui.schedule_screen_size_recheck(Duration::ZERO);
|
tui.schedule_screen_size_recheck(Duration::ZERO);
|
||||||
if let Err(err) = render_startup_frame(&mut app, tui) {
|
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
|
app.chat_widget
|
||||||
.restore_startup_draft_when_ready(&mut pending_startup_draft);
|
.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),
|
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 app_test_support::create_fake_parented_rollout_with_source;
|
||||||
use codex_app_server_protocol::ToolRequestUserInputOption;
|
use codex_app_server_protocol::ToolRequestUserInputOption;
|
||||||
use codex_app_server_protocol::ToolRequestUserInputQuestion;
|
use codex_app_server_protocol::ToolRequestUserInputQuestion;
|
||||||
|
use codex_utils_approval_presets::builtin_approval_presets;
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use crossterm::event::KeyModifiers;
|
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]
|
#[test]
|
||||||
fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() {
|
fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -1032,6 +1032,10 @@ pub(crate) enum AppEvent {
|
|||||||
failed_scan: bool,
|
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.
|
/// Prompt to enable the Windows sandbox feature before using Agent mode.
|
||||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
OpenWindowsSandboxEnablePrompt {
|
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]
|
#[test]
|
||||||
fn footer_hint_row_is_separated_from_composer() {
|
fn footer_hint_row_is_separated_from_composer() {
|
||||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ pub(crate) struct SelectionItem {
|
|||||||
pub is_disabled: bool,
|
pub is_disabled: bool,
|
||||||
pub actions: Vec<SelectionAction>,
|
pub actions: Vec<SelectionAction>,
|
||||||
pub dismiss_on_select: bool,
|
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 dismiss_parent_on_child_accept: bool,
|
||||||
pub search_value: Option<String>,
|
pub search_value: Option<String>,
|
||||||
pub disabled_reason: 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)]
|
#[cfg(test)]
|
||||||
pub(crate) fn set_search_query(&mut self, query: String) {
|
pub(crate) fn set_search_query(&mut self, query: String) {
|
||||||
self.search_query = query;
|
self.search_query = query;
|
||||||
@@ -1047,8 +1065,7 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
.is_some_and(|shortcut| shortcut.is_press(key_event))
|
.is_some_and(|shortcut| shortcut.is_press(key_event))
|
||||||
&& Self::item_is_enabled(item)
|
&& Self::item_is_enabled(item)
|
||||||
}) {
|
}) {
|
||||||
self.state.selected_idx = Some(idx);
|
self.select_shortcut(idx);
|
||||||
self.accept();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(idx) = c
|
if let Some(idx) = c
|
||||||
@@ -1056,8 +1073,7 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
.map(|d| d as usize)
|
.map(|d| d as usize)
|
||||||
.and_then(|number| self.actual_idx_for_enabled_number(number))
|
.and_then(|number| self.actual_idx_for_enabled_number(number))
|
||||||
{
|
{
|
||||||
self.state.selected_idx = Some(idx);
|
self.select_shortcut(idx);
|
||||||
self.accept();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
+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]
|
#[tokio::test]
|
||||||
async fn required_windows_sandbox_setup_defers_configured_initial_prompt() {
|
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;
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ impl ChatWidget {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(any(target_os = "windows", test))]
|
||||||
pub(crate) fn open_world_writable_warning_confirmation(
|
pub(crate) fn open_world_writable_warning_confirmation(
|
||||||
&mut self,
|
&mut self,
|
||||||
preset: Option<ApprovalPreset>,
|
preset: Option<ApprovalPreset>,
|
||||||
@@ -185,6 +185,7 @@ impl ChatWidget {
|
|||||||
description: Some(format!("Apply {mode_label} for this session")),
|
description: Some(format!("Apply {mode_label} for this session")),
|
||||||
actions: accept_actions,
|
actions: accept_actions,
|
||||||
dismiss_on_select: true,
|
dismiss_on_select: true,
|
||||||
|
require_explicit_confirmation: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
SelectionItem {
|
SelectionItem {
|
||||||
@@ -192,6 +193,7 @@ impl ChatWidget {
|
|||||||
description: Some(format!("Enable {mode_label} and remember this choice")),
|
description: Some(format!("Enable {mode_label} and remember this choice")),
|
||||||
actions: accept_and_remember_actions,
|
actions: accept_and_remember_actions,
|
||||||
dismiss_on_select: true,
|
dismiss_on_select: true,
|
||||||
|
require_explicit_confirmation: true,
|
||||||
..Default::default()
|
..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(
|
pub(crate) fn open_world_writable_warning_confirmation(
|
||||||
&mut self,
|
&mut self,
|
||||||
_preset: Option<ApprovalPreset>,
|
_preset: Option<ApprovalPreset>,
|
||||||
@@ -288,6 +290,7 @@ impl ChatWidget {
|
|||||||
});
|
});
|
||||||
})],
|
})],
|
||||||
dismiss_on_select: true,
|
dismiss_on_select: true,
|
||||||
|
require_explicit_confirmation: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -412,6 +415,7 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
})],
|
})],
|
||||||
dismiss_on_select: true,
|
dismiss_on_select: true,
|
||||||
|
require_explicit_confirmation: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,9 @@ impl KeyboardHandler for TrustDirectoryWidget {
|
|||||||
} else if keys::MOVE_DOWN.is_pressed(key_event) {
|
} else if keys::MOVE_DOWN.is_pressed(key_event) {
|
||||||
self.highlighted = TrustDirectorySelection::Quit;
|
self.highlighted = TrustDirectorySelection::Quit;
|
||||||
} else if keys::SELECT_FIRST.is_pressed(key_event) {
|
} 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)
|
} else if keys::SELECT_SECOND.is_pressed(key_event)
|
||||||
|| keys::QUIT.is_pressed(key_event)
|
|| keys::QUIT.is_pressed(key_event)
|
||||||
|| keys::CANCEL.is_pressed(key_event)
|
|| keys::CANCEL.is_pressed(key_event)
|
||||||
@@ -232,6 +234,24 @@ mod tests {
|
|||||||
assert!(widget.should_quit);
|
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]
|
#[test]
|
||||||
fn renders_snapshot_for_git_repo() {
|
fn renders_snapshot_for_git_repo() {
|
||||||
let widget = widget(/*error*/ None);
|
let widget = widget(/*error*/ None);
|
||||||
|
|||||||
@@ -241,7 +241,10 @@ fn selection_view_params(
|
|||||||
footer_hint: Some(standard_popup_hint_line_for_keymap(&keymap.list)),
|
footer_hint: Some(standard_popup_hint_line_for_keymap(&keymap.list)),
|
||||||
items: vec![
|
items: vec![
|
||||||
selection_item("Review hooks", trusting_all),
|
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),
|
selection_item("Continue without trusting (hooks won't run)", trusting_all),
|
||||||
],
|
],
|
||||||
header: Box::new(header),
|
header: Box::new(header),
|
||||||
@@ -297,10 +300,13 @@ impl WidgetRef for &StandaloneSelectionView<'_> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::StartupHooksReviewSelection;
|
||||||
use super::review_is_needed;
|
use super::review_is_needed;
|
||||||
|
use super::selected_choice;
|
||||||
use super::selection_view;
|
use super::selection_view;
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
|
use crate::bottom_pane::BottomPaneView;
|
||||||
use crate::keymap::RuntimeKeymap;
|
use crate::keymap::RuntimeKeymap;
|
||||||
use crate::render::renderable::Renderable;
|
use crate::render::renderable::Renderable;
|
||||||
use crate::test_support::PathBufExt;
|
use crate::test_support::PathBufExt;
|
||||||
@@ -311,6 +317,9 @@ mod tests {
|
|||||||
use codex_app_server_protocol::HookSource;
|
use codex_app_server_protocol::HookSource;
|
||||||
use codex_app_server_protocol::HookTrustStatus;
|
use codex_app_server_protocol::HookTrustStatus;
|
||||||
use codex_app_server_protocol::HooksListEntry;
|
use codex_app_server_protocol::HooksListEntry;
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use crossterm::event::KeyModifiers;
|
||||||
use insta::assert_snapshot;
|
use insta::assert_snapshot;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
@@ -385,6 +394,56 @@ mod tests {
|
|||||||
assert!(review_is_needed(/*bypass_hook_trust*/ false, &entry()));
|
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]
|
#[test]
|
||||||
fn renders_prompt() {
|
fn renders_prompt() {
|
||||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
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 {
|
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_for_terminal(
|
||||||
stdout_color_level(),
|
stdout_color_level(),
|
||||||
terminal_info().name,
|
terminal_info().name,
|
||||||
@@ -89,10 +94,39 @@ pub struct DefaultColors {
|
|||||||
bg: (u8, u8, u8),
|
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> {
|
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()
|
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)> {
|
pub fn default_fg() -> Option<(u8, u8, u8)> {
|
||||||
default_colors().map(|c| c.fg)
|
default_colors().map(|c| c.fg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ use std::time::Duration;
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
mod startup_replay;
|
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.
|
/// Default wall-clock budget for each startup probe group.
|
||||||
pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100);
|
pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
@@ -709,136 +713,10 @@ mod imp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod imp {
|
#[path = "terminal_probe/windows.rs"]
|
||||||
use super::DefaultColors;
|
mod imp;
|
||||||
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;
|
|
||||||
|
|
||||||
/// Reads default colors from the console screen buffer without consuming terminal input.
|
#[cfg(any(unix, windows, test))]
|
||||||
///
|
|
||||||
/// 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))]
|
|
||||||
fn parse_osc_color(buffer: &[u8], slot: u8) -> Option<(u8, u8, u8)> {
|
fn parse_osc_color(buffer: &[u8], slot: u8) -> Option<(u8, u8, u8)> {
|
||||||
let prefix = format!("\x1B]{slot};");
|
let prefix = format!("\x1B]{slot};");
|
||||||
let start = find_subslice(buffer, prefix.as_bytes())?;
|
let start = find_subslice(buffer, prefix.as_bytes())?;
|
||||||
@@ -856,7 +734,7 @@ fn parse_default_colors(buffer: &[u8]) -> Option<DefaultColors> {
|
|||||||
Some(DefaultColors { fg, bg })
|
Some(DefaultColors { fg, bg })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(unix, test))]
|
#[cfg(any(unix, windows, test))]
|
||||||
fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> {
|
fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> {
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
while idx < buffer.len() {
|
while idx < buffer.len() {
|
||||||
@@ -869,7 +747,7 @@ fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(unix, test))]
|
#[cfg(any(unix, windows, test))]
|
||||||
fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> {
|
fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> {
|
||||||
let (prefix, values) = payload.trim().split_once(':')?;
|
let (prefix, values) = payload.trim().split_once(':')?;
|
||||||
if !prefix.eq_ignore_ascii_case("rgb") && !prefix.eq_ignore_ascii_case("rgba") {
|
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))
|
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> {
|
fn parse_osc_component(component: &str) -> Option<u8> {
|
||||||
match component.len() {
|
if !(1..=4).contains(&component.len()) {
|
||||||
2 => u8::from_str_radix(component, 16).ok(),
|
return None;
|
||||||
4 => u16::from_str_radix(component, 16)
|
|
||||||
.ok()
|
|
||||||
.map(|value| (value / 257) as u8),
|
|
||||||
_ => 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> {
|
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||||
haystack
|
haystack
|
||||||
.windows(needle.len())
|
.windows(needle.len())
|
||||||
@@ -925,12 +803,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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:00/80/ff"), Some((0, 128, 255)));
|
||||||
|
assert_eq!(parse_osc_rgb("rgb:fff/800/000"), Some((255, 127, 0)));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_osc_rgb("rgba:ffff/8000/0000/ffff"),
|
parse_osc_rgb("rgba:ffff/8000/0000/ffff"),
|
||||||
Some((255, 127, 0))
|
Some((255, 127, 0))
|
||||||
);
|
);
|
||||||
|
assert_eq!(parse_osc_rgb("rgb:fffff/0/0"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
!keyboard_modes::keyboard_enhancement_disabled() && detect_keyboard_enhancement_supported();
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[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 tui = CustomTerminal::with_options_and_cursor_position(backend, cursor_pos)?;
|
||||||
let stderr_guard = terminal_stderr::TerminalStderrGuard::install()?;
|
let stderr_guard = terminal_stderr::TerminalStderrGuard::install()?;
|
||||||
@@ -693,6 +695,15 @@ impl Tui {
|
|||||||
self.event_broker.resume_events();
|
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.
|
/// Reclaim terminal modes and stderr after a panic hook ran inside a recovery boundary.
|
||||||
pub(crate) fn recover_after_caught_panic(&mut self) -> Result<()> {
|
pub(crate) fn recover_after_caught_panic(&mut self) -> Result<()> {
|
||||||
set_modes()?;
|
set_modes()?;
|
||||||
|
|||||||
Reference in new issue
Block a user