mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Preserve terminal input during startup
Keep normal terminal settings during slow startup, then capture bounded printable input when the TUI activates so early typing reaches the composer without swallowing signals or corrupting output. Flush captured input on startup prompts and failures, and suppress delayed Enter until thread and MCP startup are complete.
This commit is contained in:
@@ -574,6 +574,7 @@ pub(crate) struct App {
|
||||
primary_thread_id: Option<ThreadId>,
|
||||
last_subagent_backfill_attempt: Option<ThreadId>,
|
||||
primary_session_configured: Option<ThreadSessionState>,
|
||||
startup_draft_protected: bool,
|
||||
pending_primary_events: VecDeque<ThreadBufferedEvent>,
|
||||
pending_app_server_requests: PendingAppServerRequests,
|
||||
pending_startup_thread_start: bool,
|
||||
@@ -779,6 +780,11 @@ impl App {
|
||||
) -> Result<AppExitInfo> {
|
||||
use tokio_stream::StreamExt;
|
||||
let startup_started_at = Instant::now();
|
||||
let allow_startup_text = matches!(&session_selection, SessionSelection::StartFresh)
|
||||
&& initial_prompt.is_none()
|
||||
&& initial_images.is_empty()
|
||||
&& !should_prompt_windows_sandbox_nux_at_startup
|
||||
&& startup_hooks_browser.is_none();
|
||||
let (app_event_tx, mut app_event_rx) = unbounded_channel();
|
||||
let app_event_tx = AppEventSender::new(app_event_tx);
|
||||
emit_project_config_warnings(&app_event_tx, &config);
|
||||
@@ -1059,6 +1065,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
primary_thread_id: None,
|
||||
last_subagent_backfill_attempt: None,
|
||||
primary_session_configured: None,
|
||||
startup_draft_protected: false,
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
pending_startup_thread_start,
|
||||
@@ -1110,6 +1117,13 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
}
|
||||
}
|
||||
|
||||
if allow_startup_text {
|
||||
if let Some(startup_text) = tui.take_startup_text()? {
|
||||
app.chat_widget.insert_str(&startup_text);
|
||||
}
|
||||
app.startup_draft_protected = true;
|
||||
}
|
||||
|
||||
let event_stream_started_at = Instant::now();
|
||||
let tui_events = tui.event_stream();
|
||||
tokio::pin!(tui_events);
|
||||
|
||||
@@ -95,6 +95,10 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
key_event: KeyEvent,
|
||||
) {
|
||||
if self.startup_draft_blocks_key(key_event) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless
|
||||
// enhanced keyboard reporting is available. We only treat those word-motion fallbacks as
|
||||
// agent-switch shortcuts when the composer is empty so we never steal the expected
|
||||
@@ -268,6 +272,20 @@ impl App {
|
||||
&& !self.chat_widget.should_handle_vim_insert_escape(key_event)
|
||||
}
|
||||
|
||||
pub(super) fn startup_draft_blocks_key(&self, key_event: KeyEvent) -> bool {
|
||||
self.startup_draft_protected
|
||||
&& (self.primary_session_configured.is_none() || self.chat_widget.mcp_startup_active())
|
||||
&& matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn reject_side_backtrack_esc(&mut self) {
|
||||
self.reset_backtrack_state();
|
||||
self.chat_widget
|
||||
@@ -286,6 +304,11 @@ impl App {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::make_test_app;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyEventState;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_keymap_shortcuts_are_disabled_while_keymap_view_is_active() {
|
||||
@@ -297,4 +320,27 @@ mod tests {
|
||||
|
||||
assert!(!app.app_keymap_shortcuts_available());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_draft_only_blocks_unmodified_enter_presses() {
|
||||
let mut app = make_test_app().await;
|
||||
app.startup_draft_protected = true;
|
||||
|
||||
assert!(app.startup_draft_blocks_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
|
||||
assert!(app.startup_draft_blocks_key(KeyEvent::new_with_kind(
|
||||
KeyCode::Enter,
|
||||
KeyModifiers::NONE,
|
||||
KeyEventKind::Repeat,
|
||||
)));
|
||||
assert!(!app.startup_draft_blocks_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)));
|
||||
assert!(
|
||||
!app.startup_draft_blocks_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE))
|
||||
);
|
||||
assert!(!app.startup_draft_blocks_key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Release,
|
||||
state: KeyEventState::NONE,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ pub(super) async fn make_test_app() -> App {
|
||||
primary_thread_id: None,
|
||||
last_subagent_backfill_attempt: None,
|
||||
primary_session_configured: None,
|
||||
startup_draft_protected: false,
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
pending_startup_thread_start: false,
|
||||
|
||||
@@ -4098,6 +4098,7 @@ async fn make_test_app() -> App {
|
||||
primary_thread_id: None,
|
||||
last_subagent_backfill_attempt: None,
|
||||
primary_session_configured: None,
|
||||
startup_draft_protected: false,
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
pending_startup_thread_start: false,
|
||||
@@ -4163,6 +4164,7 @@ async fn make_test_app_with_channels() -> (
|
||||
primary_thread_id: None,
|
||||
last_subagent_backfill_attempt: None,
|
||||
primary_session_configured: None,
|
||||
startup_draft_protected: false,
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
pending_startup_thread_start: false,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use codex_app_server_protocol::McpServerStartupState;
|
||||
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
@@ -139,6 +141,7 @@ fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() {
|
||||
async fn startup_thread_started_submits_queued_startup_input() {
|
||||
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
app.pending_startup_thread_start = true;
|
||||
app.startup_draft_protected = true;
|
||||
app.chat_widget
|
||||
.set_queue_submissions_until_session_configured(/*queue*/ true);
|
||||
app.chat_widget
|
||||
@@ -166,6 +169,34 @@ async fn startup_thread_started_submits_queued_startup_input() {
|
||||
)
|
||||
.await
|
||||
.expect("startup thread should attach");
|
||||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||||
assert!(!app.startup_draft_blocks_key(enter));
|
||||
|
||||
app.chat_widget
|
||||
.set_mcp_startup_expected_servers(["test".to_string()]);
|
||||
app.chat_widget.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
thread_id: Some(thread_id.to_string()),
|
||||
name: "test".to_string(),
|
||||
status: McpServerStartupState::Starting,
|
||||
error: None,
|
||||
failure_reason: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
assert!(app.startup_draft_blocks_key(enter));
|
||||
|
||||
app.chat_widget.handle_server_notification(
|
||||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||||
thread_id: Some(thread_id.to_string()),
|
||||
name: "test".to_string(),
|
||||
status: McpServerStartupState::Ready,
|
||||
error: None,
|
||||
failure_reason: None,
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
assert!(!app.startup_draft_blocks_key(enter));
|
||||
|
||||
match next_user_turn_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => assert_eq!(
|
||||
|
||||
@@ -181,6 +181,10 @@ impl ChatWidget {
|
||||
self.mcp_startup_expected_servers = Some(server_names.into_iter().collect());
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_startup_active(&self) -> bool {
|
||||
self.mcp_startup_status.is_some()
|
||||
}
|
||||
|
||||
pub(super) fn finish_mcp_startup(&mut self, failed: Vec<String>, cancelled: Vec<String>) {
|
||||
if !cancelled.is_empty() {
|
||||
self.on_warning(format!(
|
||||
|
||||
+14
-2
@@ -853,6 +853,7 @@ pub async fn run_main(
|
||||
loader_overrides: LoaderOverrides,
|
||||
explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
|
||||
) -> std::io::Result<AppExitInfo> {
|
||||
let prepared_terminal = tui::PreparedTerminal::prepare()?;
|
||||
let strict_config = cli.strict_config;
|
||||
let (sandbox_mode, approval_policy) = if cli.dangerously_bypass_approvals_and_sandbox {
|
||||
(
|
||||
@@ -883,6 +884,7 @@ pub async fn run_main(
|
||||
Ok(v) => v,
|
||||
#[allow(clippy::print_stderr)]
|
||||
Err(e) => {
|
||||
tui::discard_terminal_input();
|
||||
eprintln!("Error parsing -c overrides: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -893,6 +895,7 @@ pub async fn run_main(
|
||||
let codex_home = match find_codex_home() {
|
||||
Ok(codex_home) => codex_home.to_path_buf(),
|
||||
Err(err) => {
|
||||
tui::discard_terminal_input();
|
||||
eprintln!("Error finding codex home: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -1145,6 +1148,7 @@ pub async fn run_main(
|
||||
match check_execpolicy_for_warnings(&config.config_layer_stack).await {
|
||||
Ok(None) => {}
|
||||
Ok(Some(err)) | Err(err) => {
|
||||
tui::discard_terminal_input();
|
||||
eprintln!(
|
||||
"Error loading rules:\n{}",
|
||||
format_exec_policy_error_with_source(&err)
|
||||
@@ -1162,6 +1166,7 @@ pub async fn run_main(
|
||||
) {
|
||||
#[allow(clippy::print_stderr)]
|
||||
{
|
||||
tui::discard_terminal_input();
|
||||
eprintln!("Error adding directories: {warning}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -1181,6 +1186,7 @@ pub async fn run_main(
|
||||
})
|
||||
.await
|
||||
{
|
||||
tui::discard_terminal_input();
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -1258,6 +1264,7 @@ pub async fn run_main(
|
||||
.try_init();
|
||||
|
||||
run_ratatui_app(
|
||||
prepared_terminal,
|
||||
cli,
|
||||
arg0_paths,
|
||||
loader_overrides,
|
||||
@@ -1280,6 +1287,7 @@ pub async fn run_main(
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_ratatui_app(
|
||||
prepared_terminal: tui::PreparedTerminal,
|
||||
cli: Cli,
|
||||
arg0_paths: Arg0DispatchPaths,
|
||||
loader_overrides: LoaderOverrides,
|
||||
@@ -1310,15 +1318,16 @@ async fn run_ratatui_app(
|
||||
tracing::error!("panic: {info}");
|
||||
prev_hook(info);
|
||||
}));
|
||||
let mut initialized_terminal = tui::init()?;
|
||||
let mut initialized_terminal = prepared_terminal.activate()?;
|
||||
let mut terminal_restore_guard = TerminalRestoreGuard::new();
|
||||
initialized_terminal.terminal.clear()?;
|
||||
|
||||
let mut tui = Tui::new(
|
||||
initialized_terminal.terminal,
|
||||
initialized_terminal.enhanced_keys_supported,
|
||||
initialized_terminal.stderr_guard,
|
||||
initialized_terminal.startup_text,
|
||||
);
|
||||
let mut terminal_restore_guard = TerminalRestoreGuard::new();
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
@@ -1817,6 +1826,7 @@ async fn run_ratatui_app(
|
||||
reason = "TUI should no longer be displayed, so we can write to stderr."
|
||||
)]
|
||||
fn restore() {
|
||||
tui::discard_terminal_input();
|
||||
if let Err(err) = tui::restore_after_exit() {
|
||||
eprintln!(
|
||||
"failed to restore terminal. Run `reset` or restart your terminal to recover: {err}"
|
||||
@@ -1936,6 +1946,7 @@ async fn load_config_or_exit_with_fallback_cwd(
|
||||
{
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
tui::discard_terminal_input();
|
||||
eprintln!("Error loading configuration: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -1965,6 +1976,7 @@ async fn load_bootstrap_config_or_exit(
|
||||
{
|
||||
Ok(config_toml) => config_toml,
|
||||
Err(err) => {
|
||||
tui::discard_terminal_input();
|
||||
let config_error = err
|
||||
.get_ref()
|
||||
.and_then(|err| err.downcast_ref::<ConfigLoadError>())
|
||||
|
||||
@@ -8,9 +8,8 @@
|
||||
//! unavailable, and reports `None` when a response is unavailable.
|
||||
//!
|
||||
//! Probes run only while the crossterm event stream is absent or paused, so they do not share
|
||||
//! crossterm's internal skipped-event queue. Bytes read while looking for probe responses are
|
||||
//! consumed from the terminal; callers must therefore own terminal input for the duration of the
|
||||
//! short timeout and accept that unrelated buffered input will be discarded.
|
||||
//! crossterm's internal skipped-event queue. The startup probe returns plain input read alongside
|
||||
//! terminal responses so callers can preserve it without replaying terminal control sequences.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -44,11 +43,12 @@ mod imp {
|
||||
use ratatui::layout::Position;
|
||||
|
||||
/// Results from the TUI's one-shot startup terminal probe.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub(crate) struct StartupProbe {
|
||||
pub(crate) cursor_position: Option<Position>,
|
||||
pub(crate) default_colors: Option<DefaultColors>,
|
||||
pub(crate) keyboard_enhancement_supported: Option<bool>,
|
||||
pub(crate) input: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Whether the startup probe should query keyboard enhancement support.
|
||||
@@ -301,6 +301,7 @@ mod imp {
|
||||
cursor_position: None,
|
||||
default_colors: None,
|
||||
keyboard_enhancement_supported: None,
|
||||
input: Vec::new(),
|
||||
};
|
||||
let mut saw_supported_keyboard = false;
|
||||
loop {
|
||||
@@ -312,15 +313,18 @@ mod imp {
|
||||
keyboard_probe,
|
||||
);
|
||||
if startup_probe_complete(&probe, keyboard_probe) {
|
||||
probe.input = extract_plain_input(&buffer);
|
||||
return Ok(probe);
|
||||
}
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
finish_startup_probe(&mut probe, keyboard_probe, saw_supported_keyboard);
|
||||
probe.input = extract_plain_input(&buffer);
|
||||
return Ok(probe);
|
||||
}
|
||||
if !tty.poll_readable(deadline.saturating_duration_since(now))? {
|
||||
finish_startup_probe(&mut probe, keyboard_probe, saw_supported_keyboard);
|
||||
probe.input = extract_plain_input(&buffer);
|
||||
return Ok(probe);
|
||||
}
|
||||
}
|
||||
@@ -379,6 +383,56 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_plain_input(buffer: &[u8]) -> Vec<u8> {
|
||||
const MAX_INPUT_BYTES: usize = 16 * 1024;
|
||||
|
||||
let mut input = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < buffer.len() && input.len() < MAX_INPUT_BYTES {
|
||||
if buffer[index] != b'\x1b' {
|
||||
input.push(buffer[index]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
match buffer.get(index + 1) {
|
||||
Some(b'[') => {
|
||||
let Some(end) = buffer[index + 2..]
|
||||
.iter()
|
||||
.position(|byte| (0x40..=0x7e).contains(byte))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
index += end + 3;
|
||||
}
|
||||
Some(b']') => {
|
||||
let mut end = index + 2;
|
||||
let mut terminated = false;
|
||||
while end < buffer.len() {
|
||||
if buffer[end] == b'\x07' {
|
||||
end += 1;
|
||||
terminated = true;
|
||||
break;
|
||||
}
|
||||
if buffer[end] == b'\x1b' && buffer.get(end + 1) == Some(&b'\\') {
|
||||
end += 2;
|
||||
terminated = true;
|
||||
break;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
if !terminated {
|
||||
break;
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
Some(_) => index += 2,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
input
|
||||
}
|
||||
|
||||
fn parse_cursor_position(buffer: &[u8]) -> Option<Position> {
|
||||
for start in find_all_subslices(buffer, b"\x1B[") {
|
||||
let rest = &buffer[start + 2..];
|
||||
@@ -535,12 +589,13 @@ mod imp {
|
||||
cursor_position: None,
|
||||
default_colors: None,
|
||||
keyboard_enhancement_supported: None,
|
||||
input: Vec::new(),
|
||||
};
|
||||
let mut saw_supported_keyboard = false;
|
||||
update_startup_probe(
|
||||
&mut probe,
|
||||
&mut saw_supported_keyboard,
|
||||
b"\x1B[20;10R\x1B]11;rgb:1111/1111/1111\x07\x1B[?64;1;2c\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B[?7u",
|
||||
b"draft\x1B[20;10R\x1B]11;rgb:1111/1111/1111\x07\x1B[?64;1;2c\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B[?7u",
|
||||
StartupKeyboardEnhancementProbe::Query,
|
||||
);
|
||||
|
||||
@@ -553,6 +608,7 @@ mod imp {
|
||||
bg: (17, 17, 17),
|
||||
}),
|
||||
keyboard_enhancement_supported: Some(true),
|
||||
input: Vec::new(),
|
||||
}
|
||||
);
|
||||
assert!(startup_probe_complete(
|
||||
@@ -560,6 +616,16 @@ mod imp {
|
||||
StartupKeyboardEnhancementProbe::Query
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_plain_input_around_terminal_responses() {
|
||||
assert_eq!(
|
||||
extract_plain_input(
|
||||
b"draft\x1B[20;10R\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B[200~ text\x1B[201~"
|
||||
),
|
||||
b"draft text"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,6 +669,14 @@ mod imp {
|
||||
return Ok(Some(colors));
|
||||
}
|
||||
|
||||
console_default_colors()
|
||||
}
|
||||
|
||||
/// Reads the configured console palette without consuming terminal input.
|
||||
pub(crate) fn console_default_colors() -> io::Result<Option<DefaultColors>> {
|
||||
let Ok(output) = std_handle(STD_OUTPUT_HANDLE) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(query_console_default_colors(output).ok().flatten())
|
||||
}
|
||||
|
||||
|
||||
+59
-136
@@ -1,10 +1,8 @@
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Result;
|
||||
use std::io::Stdout;
|
||||
use std::io::Write;
|
||||
use std::io::stdin;
|
||||
use std::io::stdout;
|
||||
use std::panic;
|
||||
use std::pin::Pin;
|
||||
@@ -59,6 +57,7 @@ mod frame_requester;
|
||||
#[cfg(unix)]
|
||||
mod job_control;
|
||||
mod keyboard_modes;
|
||||
mod startup;
|
||||
mod terminal_stderr;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support;
|
||||
@@ -73,6 +72,16 @@ pub(crate) struct InitializedTerminal {
|
||||
pub(crate) terminal: Terminal,
|
||||
pub(crate) enhanced_keys_supported: bool,
|
||||
pub(crate) stderr_guard: terminal_stderr::TerminalStderrGuard,
|
||||
pub(crate) startup_text: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) use startup::PreparedTerminal;
|
||||
use startup::StartupInputBuffer;
|
||||
use startup::capture_startup_input;
|
||||
pub(crate) use startup::discard_terminal_input;
|
||||
|
||||
pub(super) fn flush_terminal_input_buffer() {
|
||||
startup::flush_terminal_input_buffer();
|
||||
}
|
||||
|
||||
pub(crate) fn running_in_vscode_terminal() -> bool {
|
||||
@@ -173,11 +182,21 @@ mod tests {
|
||||
}
|
||||
|
||||
pub fn set_modes() -> Result<()> {
|
||||
set_base_modes()?;
|
||||
set_event_modes();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_base_modes() -> Result<()> {
|
||||
ensure_virtual_terminal_processing()?;
|
||||
|
||||
execute!(stdout(), EnableBracketedPaste)?;
|
||||
|
||||
enable_raw_mode()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_event_modes() {
|
||||
// Enable keyboard enhancement flags so modifiers for keys like Enter are disambiguated.
|
||||
// chat_composer.rs is using a keyboard event listener to enter for any modified keys
|
||||
// to create a new line that require this.
|
||||
@@ -187,7 +206,6 @@ pub fn set_modes() -> Result<()> {
|
||||
keyboard_modes::enable_keyboard_enhancement();
|
||||
|
||||
let _ = execute!(stdout(), EnableFocusChange);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -333,137 +351,6 @@ impl RestoreMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the underlying stdin buffer to clear any input that may be buffered at the terminal level.
|
||||
/// For example, clears any user input that occurred while the crossterm EventStream was dropped.
|
||||
#[cfg(unix)]
|
||||
fn flush_terminal_input_buffer() {
|
||||
// Safety: flushing the stdin queue is safe and does not move ownership.
|
||||
let result = unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH) };
|
||||
if result != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
tracing::warn!("failed to tcflush stdin: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the underlying stdin buffer to clear any input that may be buffered at the terminal level.
|
||||
/// For example, clears any user input that occurred while the crossterm EventStream was dropped.
|
||||
#[cfg(windows)]
|
||||
fn flush_terminal_input_buffer() {
|
||||
use windows_sys::Win32::Foundation::GetLastError;
|
||||
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
|
||||
use windows_sys::Win32::System::Console::FlushConsoleInputBuffer;
|
||||
use windows_sys::Win32::System::Console::GetStdHandle;
|
||||
use windows_sys::Win32::System::Console::STD_INPUT_HANDLE;
|
||||
|
||||
let handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
|
||||
if handle == INVALID_HANDLE_VALUE || handle == 0 {
|
||||
let err = unsafe { GetLastError() };
|
||||
tracing::warn!("failed to get stdin handle for flush: error {err}");
|
||||
return;
|
||||
}
|
||||
|
||||
let result = unsafe { FlushConsoleInputBuffer(handle) };
|
||||
if result == 0 {
|
||||
let err = unsafe { GetLastError() };
|
||||
tracing::warn!("failed to flush stdin buffer: error {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(crate) fn flush_terminal_input_buffer() {}
|
||||
|
||||
/// Initialize the terminal (inline viewport; history stays in normal scrollback)
|
||||
pub(crate) fn init() -> Result<InitializedTerminal> {
|
||||
if !stdin().is_terminal() {
|
||||
return Err(std::io::Error::other("stdin is not a terminal"));
|
||||
}
|
||||
if !stdout().is_terminal() {
|
||||
return Err(std::io::Error::other("stdout is not a terminal"));
|
||||
}
|
||||
set_modes()?;
|
||||
|
||||
flush_terminal_input_buffer();
|
||||
|
||||
set_panic_hook();
|
||||
|
||||
#[cfg(unix)]
|
||||
let backend = CrosstermBackend::new(stdout());
|
||||
|
||||
#[cfg(unix)]
|
||||
let startup_probe = {
|
||||
use crate::terminal_probe::StartupKeyboardEnhancementProbe;
|
||||
|
||||
let started_at = std::time::Instant::now();
|
||||
let keyboard_probe = if keyboard_modes::keyboard_enhancement_disabled() {
|
||||
StartupKeyboardEnhancementProbe::Skip
|
||||
} else {
|
||||
StartupKeyboardEnhancementProbe::Query
|
||||
};
|
||||
match crate::terminal_probe::startup(crate::terminal_probe::DEFAULT_TIMEOUT, keyboard_probe)
|
||||
{
|
||||
Ok(probe) => {
|
||||
tracing::info!(
|
||||
duration_ms = %started_at.elapsed().as_millis(),
|
||||
cursor_position = probe.cursor_position.is_some(),
|
||||
default_colors = probe.default_colors.is_some(),
|
||||
keyboard_enhancement_supported = ?probe.keyboard_enhancement_supported,
|
||||
"terminal startup probes completed"
|
||||
);
|
||||
probe
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
duration_ms = %started_at.elapsed().as_millis(),
|
||||
"terminal startup probes failed: {err}"
|
||||
);
|
||||
crate::terminal_probe::StartupProbe {
|
||||
cursor_position: None,
|
||||
default_colors: None,
|
||||
keyboard_enhancement_supported: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
crate::terminal_palette::set_default_colors_from_startup_probe(startup_probe.default_colors);
|
||||
|
||||
#[cfg(unix)]
|
||||
let cursor_pos = match startup_probe.cursor_position {
|
||||
Some(pos) => pos,
|
||||
None => {
|
||||
tracing::warn!("initial cursor position probe timed out; defaulting to origin");
|
||||
Position { x: 0, y: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let enhanced_keys_supported = startup_probe
|
||||
.keyboard_enhancement_supported
|
||||
.unwrap_or(/*default*/ false);
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let mut backend = CrosstermBackend::new(stdout());
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let cursor_pos = cursor_position_with_crossterm(&mut backend);
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let enhanced_keys_supported =
|
||||
!keyboard_modes::keyboard_enhancement_disabled() && detect_keyboard_enhancement_supported();
|
||||
|
||||
#[cfg(windows)]
|
||||
probe_windows_default_colors();
|
||||
|
||||
let tui = CustomTerminal::with_options_and_cursor_position(backend, cursor_pos)?;
|
||||
let stderr_guard = terminal_stderr::TerminalStderrGuard::install()?;
|
||||
Ok(InitializedTerminal {
|
||||
terminal: tui,
|
||||
enhanced_keys_supported,
|
||||
stderr_guard,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn cursor_position_with_crossterm(backend: &mut CrosstermBackend<Stdout>) -> Position {
|
||||
backend.get_cursor_position().unwrap_or_else(|err| {
|
||||
@@ -482,7 +369,7 @@ fn detect_keyboard_enhancement_supported() -> bool {
|
||||
#[cfg(windows)]
|
||||
fn probe_windows_default_colors() {
|
||||
let started_at = std::time::Instant::now();
|
||||
match crate::terminal_probe::default_colors(crate::terminal_probe::DEFAULT_TIMEOUT) {
|
||||
match crate::terminal_probe::console_default_colors() {
|
||||
Ok(colors) => {
|
||||
tracing::info!(
|
||||
duration_ms = %started_at.elapsed().as_millis(),
|
||||
@@ -542,6 +429,8 @@ pub struct Tui {
|
||||
enhanced_keys_supported: bool,
|
||||
notification_backend: Option<DesktopNotificationBackend>,
|
||||
notification_condition: NotificationCondition,
|
||||
startup_text: Option<String>,
|
||||
startup_input_active: bool,
|
||||
// Raw terminal-wrapped history needs a non-scroll-region insertion path in Zellij.
|
||||
is_zellij: bool,
|
||||
// When false, enter_alt_screen() becomes a no-op.
|
||||
@@ -572,6 +461,7 @@ impl Tui {
|
||||
terminal: Terminal,
|
||||
enhanced_keys_supported: bool,
|
||||
stderr_guard: terminal_stderr::TerminalStderrGuard,
|
||||
startup_text: Option<String>,
|
||||
) -> Self {
|
||||
let (draw_tx, _) = broadcast::channel(1);
|
||||
let frame_requester = FrameRequester::new(draw_tx.clone());
|
||||
@@ -597,6 +487,8 @@ impl Tui {
|
||||
enhanced_keys_supported,
|
||||
notification_backend: Some(detect_backend(NotificationMethod::default())),
|
||||
notification_condition: NotificationCondition::default(),
|
||||
startup_text,
|
||||
startup_input_active: true,
|
||||
is_zellij,
|
||||
alt_screen_enabled: true,
|
||||
_stderr_guard: stderr_guard,
|
||||
@@ -713,7 +605,24 @@ impl Tui {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn event_stream(&self) -> Pin<Box<dyn Stream<Item = TuiEvent> + Send + 'static>> {
|
||||
/// Begin reading interactive events for a screen.
|
||||
///
|
||||
/// A screen that requests events becomes the new input owner, so text captured before the TUI
|
||||
/// was ready must not survive through it and later appear in the composer.
|
||||
pub fn event_stream(&mut self) -> Pin<Box<dyn Stream<Item = TuiEvent> + Send + 'static>> {
|
||||
self.claim_startup_input();
|
||||
self.build_event_stream()
|
||||
}
|
||||
|
||||
fn claim_startup_input(&mut self) {
|
||||
if self.startup_input_active {
|
||||
self.startup_text = None;
|
||||
discard_terminal_input();
|
||||
self.startup_input_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn build_event_stream(&self) -> Pin<Box<dyn Stream<Item = TuiEvent> + Send + 'static>> {
|
||||
#[cfg(unix)]
|
||||
let stream = TuiEventStream::new(
|
||||
self.event_broker.clone(),
|
||||
@@ -731,6 +640,20 @@ impl Tui {
|
||||
Box::pin(stream)
|
||||
}
|
||||
|
||||
pub(crate) fn take_startup_text(&mut self) -> Result<Option<String>> {
|
||||
if !self.startup_input_active {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut input = StartupInputBuffer::default();
|
||||
if let Some(text) = self.startup_text.take() {
|
||||
input.push_text(&text);
|
||||
}
|
||||
input.extend(capture_startup_input()?);
|
||||
self.startup_input_active = false;
|
||||
Ok(input.into_text())
|
||||
}
|
||||
|
||||
/// Enter alternate screen and expand the viewport to full terminal size, saving the current
|
||||
/// inline viewport for restoration when leaving.
|
||||
pub fn enter_alt_screen(&mut self) -> Result<()> {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Result;
|
||||
use std::io::stdin;
|
||||
use std::io::stdout;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::Event;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::Position;
|
||||
|
||||
use super::CustomTerminal;
|
||||
use super::InitializedTerminal;
|
||||
use super::keyboard_modes;
|
||||
|
||||
const MAX_STARTUP_INPUT_CHARS: usize = 4096;
|
||||
|
||||
/// Discards terminal input if startup exits before the TUI takes ownership of it.
|
||||
pub(crate) struct PreparedTerminal {
|
||||
active: bool,
|
||||
terminal_modes_active: bool,
|
||||
}
|
||||
|
||||
impl Drop for PreparedTerminal {
|
||||
fn drop(&mut self) {
|
||||
if self.active {
|
||||
discard_terminal_input();
|
||||
if self.terminal_modes_active {
|
||||
let _ = super::restore_after_exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct StartupInputBuffer {
|
||||
text: String,
|
||||
char_count: usize,
|
||||
}
|
||||
|
||||
impl StartupInputBuffer {
|
||||
fn handle_event(&mut self, event: Event) {
|
||||
match event {
|
||||
Event::Key(KeyEvent {
|
||||
code,
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) if modifiers.difference(KeyModifiers::SHIFT).is_empty() => match code {
|
||||
KeyCode::Char(ch) if !ch.is_control() => self.push_char(ch),
|
||||
KeyCode::Backspace if self.text.pop().is_some() => {
|
||||
self.char_count -= 1;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::Paste(text) => self.push_text(&text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_char(&mut self, ch: char) {
|
||||
if self.char_count < MAX_STARTUP_INPUT_CHARS {
|
||||
self.text.push(ch);
|
||||
self.char_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push_text(&mut self, text: &str) {
|
||||
for ch in text.chars().filter(|ch| !ch.is_control()) {
|
||||
self.push_char(ch);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn extend(&mut self, other: Self) {
|
||||
self.push_text(&other.text);
|
||||
}
|
||||
|
||||
pub(super) fn into_text(self) -> Option<String> {
|
||||
(!self.text.is_empty()).then_some(self.text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the underlying stdin buffer to clear any input buffered at the terminal level.
|
||||
#[cfg(unix)]
|
||||
pub(super) fn flush_terminal_input_buffer() {
|
||||
// Safety: flushing the stdin queue is safe and does not move ownership.
|
||||
let result = unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH) };
|
||||
if result != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
tracing::warn!("failed to tcflush stdin: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the underlying stdin buffer to clear any input buffered at the terminal level.
|
||||
#[cfg(windows)]
|
||||
pub(super) fn flush_terminal_input_buffer() {
|
||||
use windows_sys::Win32::Foundation::GetLastError;
|
||||
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
|
||||
use windows_sys::Win32::System::Console::FlushConsoleInputBuffer;
|
||||
use windows_sys::Win32::System::Console::GetStdHandle;
|
||||
use windows_sys::Win32::System::Console::STD_INPUT_HANDLE;
|
||||
|
||||
let handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
|
||||
if handle == INVALID_HANDLE_VALUE || handle == 0 {
|
||||
let err = unsafe { GetLastError() };
|
||||
tracing::warn!("failed to get stdin handle for flush: error {err}");
|
||||
return;
|
||||
}
|
||||
|
||||
let result = unsafe { FlushConsoleInputBuffer(handle) };
|
||||
if result == 0 {
|
||||
let err = unsafe { GetLastError() };
|
||||
tracing::warn!("failed to flush stdin buffer: error {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(super) fn flush_terminal_input_buffer() {}
|
||||
|
||||
pub(crate) fn discard_terminal_input() {
|
||||
flush_terminal_input_buffer();
|
||||
}
|
||||
|
||||
pub(super) fn capture_startup_input() -> Result<StartupInputBuffer> {
|
||||
let mut input = StartupInputBuffer::default();
|
||||
while crossterm::event::poll(Duration::ZERO)? {
|
||||
input.handle_event(crossterm::event::read()?);
|
||||
}
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
impl PreparedTerminal {
|
||||
/// Clear stale shell input before slower startup work begins.
|
||||
pub(crate) fn prepare() -> Result<Self> {
|
||||
if !stdin().is_terminal() {
|
||||
return Err(std::io::Error::other("stdin is not a terminal"));
|
||||
}
|
||||
if !stdout().is_terminal() {
|
||||
return Err(std::io::Error::other("stdout is not a terminal"));
|
||||
}
|
||||
discard_terminal_input();
|
||||
Ok(Self {
|
||||
active: true,
|
||||
terminal_modes_active: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Initialize the TUI and move queued printable input into application-owned memory.
|
||||
pub(crate) fn activate(mut self) -> Result<InitializedTerminal> {
|
||||
self.terminal_modes_active = true;
|
||||
super::set_base_modes()?;
|
||||
let mut startup_input = capture_startup_input()?;
|
||||
super::set_panic_hook();
|
||||
|
||||
#[cfg(unix)]
|
||||
let backend = CrosstermBackend::new(stdout());
|
||||
|
||||
#[cfg(unix)]
|
||||
let startup_probe = {
|
||||
use crate::terminal_probe::StartupKeyboardEnhancementProbe;
|
||||
|
||||
let started_at = std::time::Instant::now();
|
||||
let keyboard_probe = if keyboard_modes::keyboard_enhancement_disabled() {
|
||||
StartupKeyboardEnhancementProbe::Skip
|
||||
} else {
|
||||
StartupKeyboardEnhancementProbe::Query
|
||||
};
|
||||
match crate::terminal_probe::startup(
|
||||
crate::terminal_probe::DEFAULT_TIMEOUT,
|
||||
keyboard_probe,
|
||||
) {
|
||||
Ok(probe) => {
|
||||
tracing::info!(
|
||||
duration_ms = %started_at.elapsed().as_millis(),
|
||||
cursor_position = probe.cursor_position.is_some(),
|
||||
default_colors = probe.default_colors.is_some(),
|
||||
keyboard_enhancement_supported = ?probe.keyboard_enhancement_supported,
|
||||
"terminal startup probes completed"
|
||||
);
|
||||
probe
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
duration_ms = %started_at.elapsed().as_millis(),
|
||||
"terminal startup probes failed: {err}"
|
||||
);
|
||||
crate::terminal_probe::StartupProbe {
|
||||
cursor_position: None,
|
||||
default_colors: None,
|
||||
keyboard_enhancement_supported: None,
|
||||
input: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
startup_input.push_text(&String::from_utf8_lossy(&startup_probe.input));
|
||||
|
||||
#[cfg(unix)]
|
||||
crate::terminal_palette::set_default_colors_from_startup_probe(
|
||||
startup_probe.default_colors,
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
let cursor_pos = match startup_probe.cursor_position {
|
||||
Some(pos) => pos,
|
||||
None => {
|
||||
tracing::warn!("initial cursor position probe timed out; defaulting to origin");
|
||||
Position { x: 0, y: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let enhanced_keys_supported = startup_probe
|
||||
.keyboard_enhancement_supported
|
||||
.unwrap_or(/*default*/ false);
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let mut backend = CrosstermBackend::new(stdout());
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let cursor_pos = super::cursor_position_with_crossterm(&mut backend);
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let enhanced_keys_supported = !keyboard_modes::keyboard_enhancement_disabled()
|
||||
&& super::detect_keyboard_enhancement_supported();
|
||||
|
||||
#[cfg(windows)]
|
||||
super::probe_windows_default_colors();
|
||||
|
||||
super::set_event_modes();
|
||||
startup_input.extend(capture_startup_input()?);
|
||||
|
||||
let terminal = CustomTerminal::with_options_and_cursor_position(backend, cursor_pos)?;
|
||||
let stderr_guard = super::terminal_stderr::TerminalStderrGuard::install()?;
|
||||
let initialized = InitializedTerminal {
|
||||
terminal,
|
||||
enhanced_keys_supported,
|
||||
stderr_guard,
|
||||
startup_text: startup_input.into_text(),
|
||||
};
|
||||
self.active = false;
|
||||
Ok(initialized)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "startup_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,39 @@
|
||||
use crossterm::event::Event;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::MAX_STARTUP_INPUT_CHARS;
|
||||
use super::StartupInputBuffer;
|
||||
|
||||
#[test]
|
||||
fn startup_input_keeps_text_without_replaying_actions() {
|
||||
let mut input = StartupInputBuffer::default();
|
||||
for event in [
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)),
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::SHIFT)),
|
||||
Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
|
||||
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
Event::Key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
|
||||
Event::Paste("ello\nworld".to_string()),
|
||||
Event::Key(KeyEvent::new_with_kind(
|
||||
KeyCode::Char('!'),
|
||||
KeyModifiers::NONE,
|
||||
KeyEventKind::Release,
|
||||
)),
|
||||
] {
|
||||
input.handle_event(event);
|
||||
}
|
||||
|
||||
assert_eq!(input.into_text(), Some("helloworld".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_input_is_bounded() {
|
||||
let mut input = StartupInputBuffer::default();
|
||||
input.handle_event(Event::Paste("x".repeat(MAX_STARTUP_INPUT_CHARS + 1)));
|
||||
|
||||
assert_eq!(input.into_text(), Some("x".repeat(MAX_STARTUP_INPUT_CHARS)));
|
||||
}
|
||||
@@ -24,5 +24,6 @@ pub(crate) fn make_test_tui() -> io::Result<Tui> {
|
||||
terminal,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
stderr_guard,
|
||||
/*startup_text*/ None,
|
||||
))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user