mirror of
https://github.com/openai/codex.git
synced 2026-11-05 08:28:29 +00:00
## Why Configuration and app-server initialization can take time before the main TUI is ready, leaving users unable to begin drafting a prompt. ## What changed - Show a provisional composer while startup work runs and carry its text, cursor position, paste state, and attachments into the initialized chat. - Limit the provisional composer to safe editing and cancellation, and quarantine input around session pickers, approvals, and other actionable startup screens. - Preserve configuration validation before terminal checks and recover terminal state cleanly from startup failures and caught panics. ## Testing - Cover startup editing, multiline and large pastes, keymaps, cancellation, session-picker handoff, approval boundaries, and draft restoration. - Verify non-interactive launches report configuration errors before terminal errors. GitOrigin-RevId: fe04a85cfbbcff21ff87fa81fd17474827858575
394 lines
17 KiB
Rust
394 lines
17 KiB
Rust
use std::fs::File;
|
|
use std::io::Read as _;
|
|
use std::io::Write as _;
|
|
use std::os::fd::FromRawFd;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::process::Stdio;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
use crossterm::event::Event;
|
|
use crossterm::event::KeyCode;
|
|
|
|
const STARTUP_INPUT_CHILD_TEST: &str = "tui::startup_tests::startup_typeahead_pty_child";
|
|
|
|
#[test]
|
|
fn startup_preserves_typeahead_and_discards_buffered_action_key_after_first_draw() {
|
|
let mut master = -1;
|
|
let mut slave = -1;
|
|
let mut size = libc::winsize {
|
|
ws_row: 24,
|
|
ws_col: 80,
|
|
ws_xpixel: 0,
|
|
ws_ypixel: 0,
|
|
};
|
|
// Safety: `openpty` initializes the two file descriptors and reads the supplied size.
|
|
assert_eq!(
|
|
unsafe {
|
|
libc::openpty(
|
|
&mut master,
|
|
&mut slave,
|
|
std::ptr::null_mut(),
|
|
std::ptr::null_mut(),
|
|
std::ptr::addr_of_mut!(size),
|
|
)
|
|
},
|
|
0,
|
|
"failed to create a test pty: {}",
|
|
std::io::Error::last_os_error()
|
|
);
|
|
// Safety: `openpty` returned owned descriptors on success.
|
|
let mut master = unsafe { File::from_raw_fd(master) };
|
|
// Safety: `openpty` returned owned descriptors on success.
|
|
let slave = unsafe { File::from_raw_fd(slave) };
|
|
|
|
// Queue canonical-mode typeahead before raw mode. Reinserting the old startup `tcflush`
|
|
// drops this input before the bounded probe can preserve it.
|
|
master
|
|
.write_all(b"helo\x7Flo\x04")
|
|
.expect("queue typeahead");
|
|
|
|
let signals = tempfile::tempdir().expect("create pty synchronization directory");
|
|
let draft_ready = signals.path().join("draft-ready");
|
|
let draft_queued = signals.path().join("draft-queued");
|
|
let handoff_ready = signals.path().join("handoff-ready");
|
|
let handoff_queued = signals.path().join("handoff-queued");
|
|
let action_ready = signals.path().join("action-ready");
|
|
let action_queued = signals.path().join("action-queued");
|
|
let fresh_ready = signals.path().join("fresh-ready");
|
|
let fresh_queued = signals.path().join("fresh-queued");
|
|
let boundary_ready = signals.path().join("boundary-ready");
|
|
let boundary_queued = signals.path().join("boundary-queued");
|
|
let boundary_consumed = signals.path().join("boundary-consumed");
|
|
let boundary_action_queued = signals.path().join("boundary-action-queued");
|
|
let control_ready = signals.path().join("control-ready");
|
|
let control_queued = signals.path().join("control-queued");
|
|
let mut child = Command::new(std::env::current_exe().expect("current test binary"))
|
|
.arg("--exact")
|
|
.arg(STARTUP_INPUT_CHILD_TEST)
|
|
.arg("--ignored")
|
|
.arg("--nocapture")
|
|
.env("CODEX_TUI_STARTUP_INPUT_PTY_DIR", signals.path())
|
|
.stdin(Stdio::from(slave.try_clone().expect("clone pty slave")))
|
|
.stdout(Stdio::from(slave))
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.expect("spawn startup input pty child");
|
|
let mut output = master.try_clone().expect("clone pty output reader");
|
|
let output_reader = std::thread::spawn(move || {
|
|
let mut captured = Vec::new();
|
|
let mut buffer = [0; 4096];
|
|
loop {
|
|
match output.read(&mut buffer) {
|
|
Ok(0) => break,
|
|
Ok(read) => captured.extend_from_slice(&buffer[..read]),
|
|
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
captured
|
|
});
|
|
|
|
let result = (|| -> Result<(), String> {
|
|
wait_for_file(&draft_ready)?;
|
|
master
|
|
.write_all(b"abc\r\x1b[DZ")
|
|
.map_err(|err| format!("failed to queue editable startup input: {err}"))?;
|
|
std::fs::write(&draft_queued, [])
|
|
.map_err(|err| format!("failed to signal editable startup input: {err}"))?;
|
|
|
|
wait_for_file(&handoff_ready)?;
|
|
master
|
|
.write_all(b"!\x1b[D")
|
|
.map_err(|err| format!("failed to queue post-handoff startup input: {err}"))?;
|
|
std::fs::write(&handoff_queued, [])
|
|
.map_err(|err| format!("failed to signal post-handoff startup input: {err}"))?;
|
|
|
|
for suffix in ["y", "1"] {
|
|
let ready = signals.path().join(format!("alt-{suffix}-ready"));
|
|
wait_for_file(&ready)?;
|
|
std::thread::sleep(Duration::from_millis(/*millis*/ 60));
|
|
master
|
|
.write_all(suffix.as_bytes())
|
|
.map_err(|err| format!("failed to queue delayed modifier suffix: {err}"))?;
|
|
std::fs::write(signals.path().join(format!("alt-{suffix}-queued")), [])
|
|
.map_err(|err| format!("failed to signal delayed modifier suffix: {err}"))?;
|
|
}
|
|
|
|
wait_for_file(&action_ready)?;
|
|
master
|
|
.write_all(b"y")
|
|
.map_err(|err| format!("failed to queue buffered action key: {err}"))?;
|
|
std::fs::write(&action_queued, [])
|
|
.map_err(|err| format!("failed to signal buffered action key: {err}"))?;
|
|
std::thread::sleep(Duration::from_millis(/*millis*/ 30));
|
|
master
|
|
.write_all(b"1y\r\x1b[201~1\r")
|
|
.map_err(|err| format!("failed to queue delayed bracketed-paste suffix: {err}"))?;
|
|
wait_for_file(&fresh_ready)?;
|
|
master
|
|
.write_all(b"n")
|
|
.map_err(|err| format!("failed to queue fresh key: {err}"))?;
|
|
std::fs::write(&fresh_queued, [])
|
|
.map_err(|err| format!("failed to signal fresh key: {err}"))?;
|
|
|
|
wait_for_file(&boundary_ready)?;
|
|
master
|
|
.write_all(b"\x1b[201~\x1b[12;3R")
|
|
.map_err(|err| format!("failed to queue completed paste boundary: {err}"))?;
|
|
std::fs::write(&boundary_queued, [])
|
|
.map_err(|err| format!("failed to signal queued paste boundary: {err}"))?;
|
|
wait_for_file(&boundary_consumed)?;
|
|
master
|
|
.write_all(b"1\r")
|
|
.map_err(|err| format!("failed to queue unread action after paste boundary: {err}"))?;
|
|
std::fs::write(&boundary_action_queued, [])
|
|
.map_err(|err| format!("failed to signal queued unread action: {err}"))?;
|
|
|
|
wait_for_file(&control_ready)?;
|
|
master
|
|
.write_all(b"1y\r\x1b")
|
|
.map_err(|err| format!("failed to queue delayed control-sequence payload: {err}"))?;
|
|
std::fs::write(&control_queued, [])
|
|
.map_err(|err| format!("failed to signal queued control-sequence payload: {err}"))?;
|
|
std::thread::sleep(Duration::from_millis(/*millis*/ 30));
|
|
master
|
|
.write_all(b"\\")
|
|
.map_err(|err| format!("failed to terminate the delayed control sequence: {err}"))?;
|
|
|
|
let started_at = Instant::now();
|
|
while child
|
|
.try_wait()
|
|
.map_err(|err| format!("failed to poll startup input pty child: {err}"))?
|
|
.is_none()
|
|
{
|
|
if started_at.elapsed() >= Duration::from_secs(/*secs*/ 5) {
|
|
return Err("timed out waiting for startup input pty child".to_string());
|
|
}
|
|
std::thread::sleep(Duration::from_millis(/*millis*/ 10));
|
|
}
|
|
Ok(())
|
|
})();
|
|
if result.is_err() {
|
|
let _ = child.kill();
|
|
}
|
|
let status = child.wait().expect("wait for startup input pty child");
|
|
let output = output_reader.join().expect("join pty output reader");
|
|
let mut stderr = String::new();
|
|
child
|
|
.stderr
|
|
.take()
|
|
.expect("child stderr")
|
|
.read_to_string(&mut stderr)
|
|
.expect("read child stderr");
|
|
assert!(
|
|
result.is_ok() && status.success(),
|
|
"startup input pty child failed: {}\n{stderr}\n{}",
|
|
result
|
|
.err()
|
|
.unwrap_or_else(|| "child exited unsuccessfully".to_string()),
|
|
String::from_utf8_lossy(&output)
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
#[ignore]
|
|
async fn startup_typeahead_pty_child() {
|
|
let Some(signals) = std::env::var_os("CODEX_TUI_STARTUP_INPUT_PTY_DIR") else {
|
|
return;
|
|
};
|
|
let signals = PathBuf::from(signals);
|
|
let mut startup_draft = crate::startup_draft::StartupDraft::new(
|
|
crate::startup_draft::StartupDraftInitialScreen::Composer,
|
|
)
|
|
.expect("initialize the real startup composer");
|
|
|
|
let mut typeahead = Vec::new();
|
|
for _ in 0..5 {
|
|
assert!(
|
|
crossterm::event::poll(Duration::from_secs(/*secs*/ 1))
|
|
.expect("poll startup typeahead"),
|
|
"startup typeahead was lost"
|
|
);
|
|
match crossterm::event::read().expect("read startup typeahead") {
|
|
Event::Key(key) => typeahead.push(key.code),
|
|
event => panic!("unexpected startup event: {event:?}"),
|
|
}
|
|
}
|
|
assert_eq!(
|
|
typeahead,
|
|
vec![
|
|
KeyCode::Char('h'),
|
|
KeyCode::Char('e'),
|
|
KeyCode::Char('l'),
|
|
KeyCode::Char('l'),
|
|
KeyCode::Char('o'),
|
|
]
|
|
);
|
|
if crossterm::event::poll(Duration::ZERO).expect("poll the canonical-mode EOF") {
|
|
// Canonical EOF does not have the same decoded representation on every Unix platform.
|
|
crossterm::event::read().expect("read the canonical-mode EOF");
|
|
}
|
|
crossterm::event::buffer_input(b"\x1b").expect("buffer an incomplete escape sequence");
|
|
crossterm::event::buffer_input(b"[A").expect("complete the buffered escape sequence");
|
|
assert!(crossterm::event::poll(Duration::from_secs(/*secs*/ 1)).expect("poll split arrow"));
|
|
assert!(matches!(
|
|
crossterm::event::read().expect("read split arrow"),
|
|
Event::Key(key) if key.code == KeyCode::Up
|
|
));
|
|
|
|
std::fs::write(signals.join("draft-ready"), []).expect("signal editable startup composer");
|
|
let draft_queued = signals.join("draft-queued");
|
|
let startup_result = startup_draft
|
|
.run_until(async {
|
|
while !draft_queued.exists() {
|
|
tokio::time::sleep(Duration::from_millis(/*millis*/ 10)).await;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(/*millis*/ 100)).await;
|
|
"startup completed"
|
|
})
|
|
.await
|
|
.expect("edit the real composer while startup work is pending");
|
|
assert_eq!(startup_result, "startup completed");
|
|
|
|
let (mut tui, mut terminal_restore_guard, mut startup_draft) = startup_draft.into_parts();
|
|
std::fs::write(signals.join("handoff-ready"), []).expect("signal startup terminal handoff");
|
|
let handoff_queued = signals.join("handoff-queued");
|
|
startup_draft
|
|
.run_until(&mut tui, async {
|
|
while !handoff_queued.exists() {
|
|
tokio::time::sleep(Duration::from_millis(/*millis*/ 10)).await;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(/*millis*/ 100)).await;
|
|
})
|
|
.await
|
|
.expect("keep the real composer editable after terminal ownership transfers");
|
|
startup_draft
|
|
.flush_pending_events(&mut tui)
|
|
.await
|
|
.expect("flush real startup terminal input");
|
|
tui.pause_events();
|
|
let draft = startup_draft.into_draft();
|
|
assert_eq!(
|
|
draft.text, "abZ!c",
|
|
"startup Enter must not submit the draft"
|
|
);
|
|
assert_eq!(draft.cursor, 3, "startup cursor edits must survive handoff");
|
|
assert!(draft.local_images.is_empty());
|
|
|
|
tui.draw(u16::MAX, |_| {}).expect("draw actionable screen");
|
|
crossterm::event::buffer_input(b"\x1b").expect("buffer a standalone escape");
|
|
let escape_started_at = Instant::now();
|
|
assert!(
|
|
crossterm::event::poll(Duration::from_millis(/*millis*/ 250))
|
|
.expect("poll the standalone escape")
|
|
);
|
|
assert!(matches!(
|
|
crossterm::event::read().expect("read the standalone escape"),
|
|
Event::Key(key) if key.code == KeyCode::Esc
|
|
));
|
|
assert!(escape_started_at.elapsed() < Duration::from_millis(/*millis*/ 250));
|
|
|
|
for suffix in ["y", "1"] {
|
|
crossterm::event::buffer_input(b"\x1b")
|
|
.expect("buffer an incomplete modifier before the security screen");
|
|
std::fs::write(signals.join(format!("alt-{suffix}-ready")), [])
|
|
.expect("signal incomplete modifier");
|
|
tui.discard_pending_input_before_interactive_screen()
|
|
.expect("discard the delayed modifier suffix");
|
|
wait_for_file(&signals.join(format!("alt-{suffix}-queued")))
|
|
.expect("wait for the delayed modifier suffix");
|
|
assert!(
|
|
!crossterm::event::poll(Duration::from_millis(/*millis*/ 50))
|
|
.expect("poll discarded modifier suffix"),
|
|
"delayed modifier suffix became an unmodified action key"
|
|
);
|
|
}
|
|
|
|
crossterm::event::buffer_input(b"y\x1b[200~unfinished")
|
|
.expect("buffer action key and incomplete bracketed paste before the security screen");
|
|
std::fs::write(signals.join("action-ready"), []).expect("signal actionable screen");
|
|
wait_for_file(&signals.join("action-queued")).expect("wait for the buffered action key");
|
|
tui.discard_pending_input_before_interactive_screen()
|
|
.expect("discard buffered action key");
|
|
assert!(
|
|
!crossterm::event::poll(Duration::from_millis(/*millis*/ 50))
|
|
.expect("poll discarded action key"),
|
|
"buffered action key was delivered after the first draw"
|
|
);
|
|
|
|
std::fs::write(signals.join("fresh-ready"), []).expect("signal fresh input readiness");
|
|
wait_for_file(&signals.join("fresh-queued")).expect("wait for the fresh input");
|
|
assert!(crossterm::event::poll(Duration::from_secs(/*secs*/ 1)).expect("poll fresh key"));
|
|
assert!(matches!(
|
|
crossterm::event::read().expect("read fresh key"),
|
|
Event::Key(key) if key.code == KeyCode::Char('n')
|
|
));
|
|
|
|
crossterm::event::buffer_input(b"\x1b[200~unfinished")
|
|
.expect("buffer bracketed paste before an unread action boundary");
|
|
assert_eq!(
|
|
crossterm::event::discard_buffered_input().expect("quarantine unfinished paste"),
|
|
crossterm::event::InputDiscardStatus::BracketedPasteInProgress
|
|
);
|
|
std::fs::write(signals.join("boundary-ready"), []).expect("signal paste-boundary readiness");
|
|
wait_for_file(&signals.join("boundary-queued")).expect("wait for the paste boundary");
|
|
assert!(
|
|
!crossterm::event::poll(Duration::ZERO).expect("poll a completed paste with unread input")
|
|
);
|
|
assert_eq!(
|
|
crossterm::event::discard_buffered_input().expect("discard completed paste boundary"),
|
|
crossterm::event::InputDiscardStatus::Complete
|
|
);
|
|
std::fs::write(signals.join("boundary-consumed"), []).expect("signal completed paste boundary");
|
|
wait_for_file(&signals.join("boundary-action-queued")).expect("wait for the unread action");
|
|
assert!(super::terminal_input_is_readable().expect("check unread input after an elapsed poll"));
|
|
tui.discard_pending_input_before_interactive_screen()
|
|
.expect("discard actionable unread input after the paste boundary");
|
|
assert!(
|
|
!crossterm::event::poll(Duration::from_millis(/*millis*/ 50))
|
|
.expect("poll discarded unread action"),
|
|
"actionable input queued after the paste boundary survived the security drain"
|
|
);
|
|
|
|
crossterm::event::buffer_input(b"\x1b]10;unfinished")
|
|
.expect("buffer an unfinished control sequence before the security screen");
|
|
assert_eq!(
|
|
crossterm::event::discard_buffered_input().expect("quarantine unfinished control sequence"),
|
|
crossterm::event::InputDiscardStatus::ControlSequenceInProgress
|
|
);
|
|
std::fs::write(signals.join("control-ready"), []).expect("signal control-sequence readiness");
|
|
wait_for_file(&signals.join("control-queued")).expect("wait for the control sequence");
|
|
tui.discard_pending_input_before_interactive_screen()
|
|
.expect("discard delayed control-sequence payload");
|
|
assert!(
|
|
!crossterm::event::poll(Duration::from_millis(/*millis*/ 50))
|
|
.expect("poll discarded control-sequence payload"),
|
|
"control-sequence payload survived the security drain"
|
|
);
|
|
|
|
crossterm::event::buffer_input(b"\x1b")
|
|
.expect("buffer an ambiguous escape before the security screen");
|
|
let err = tui
|
|
.discard_pending_input_before_interactive_screen()
|
|
.expect_err("an unresolved escape at a protected boundary must fail closed");
|
|
assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
|
|
|
|
terminal_restore_guard
|
|
.restore()
|
|
.expect("restore terminal after startup draft handoff");
|
|
}
|
|
|
|
fn wait_for_file(path: &Path) -> Result<(), String> {
|
|
let started_at = Instant::now();
|
|
while !path.exists() {
|
|
if started_at.elapsed() >= Duration::from_secs(/*secs*/ 5) {
|
|
return Err(format!("timed out waiting for {}", path.display()));
|
|
}
|
|
std::thread::sleep(Duration::from_millis(/*millis*/ 10));
|
|
}
|
|
Ok(())
|
|
}
|