Preserve startup drafts and submit them when the session is ready (#46750)

## Why

The startup composer accepted edits but ignored submission. Its first frame also preceded configured screen settings, and startup failures could leave typed text unavailable.

## What changed

- Honor configured submit bindings during startup, retaining one confirmation until the session and protected input screens are ready. Repeated submit keys do not duplicate the message; edits, `Esc`, disconnects, or destination changes cancel confirmation while preserving the draft.
- Apply local screen, status-line, keymap, and paste settings before the first editable frame. Anchor the composer at the bottom in owned-screen mode and retain the loading frame until resumed history is ready to render.
- Keep an in-memory recovery copy through submission acknowledgment. On startup failure, restore the terminal before printing unsent text, expanding paste placeholders and escaping terminal control characters.

## Testing

Add regression coverage for deferred single submission, configured key chords, cancellation, protected input, paste handling, draft recovery, initial screen settings, and loading-frame handoff, plus a startup layout snapshot.

GitOrigin-RevId: 1eb4c63027f7d59af5d42d8545cd5cb5c3594306
This commit is contained in:
Eric Traut
2026-09-20 03:02:02 +00:00
committed by copyberry
parent de6ae71ad4
commit 9ed1b74699
32 changed files with 1987 additions and 302 deletions
+41 -20
View File
@@ -2,6 +2,7 @@
//!
//! Owns the main app run loop from app-server bootstrap through terminal shutdown. Startup input
//! remains isolated from protected interactive requests until the initialized composer owns it.
//! Queued resume history replaces the provisional loading frame only when it is ready to render.
use super::reconnect::ReconnectState;
use super::*;
@@ -100,6 +101,26 @@ pub(super) fn startup_model(
}
impl App {
/// Keep the provisional loading frame until queued history reaches the owned transcript.
/// Visible startup decisions and the agent overview must still render immediately.
pub(super) fn render_startup_frame(
&mut self,
tui: &mut tui::Tui,
app_event_rx: &mpsc::UnboundedReceiver<AppEvent>,
) -> Result<()> {
if tui.is_owned_screen() && !app_event_rx.is_empty() && !self.chat_widget.has_active_view()
{
return Ok(());
}
self.chat_widget.pre_draw_tick();
self.render_chat_widget_frame(tui, tui.terminal.last_known_screen_size)?;
if self.chat_widget.has_active_view() && self.startup_protected_input_boundary {
tui.discard_pending_input_before_interactive_screen()?;
self.startup_pending_protected_request = false;
}
Ok(())
}
/// Recognizes queued requests before they become visible protected screens.
pub(super) fn has_queued_startup_protected_request(&self) -> bool {
self.startup_protected_input_boundary
@@ -172,16 +193,6 @@ impl App {
Err(error.into())
}
fn render_startup_frame(app: &mut App, tui: &mut tui::Tui) -> Result<()> {
app.chat_widget.pre_draw_tick();
app.render_chat_widget_frame(tui, tui.terminal.last_known_screen_size)?;
if app.chat_widget.has_active_view() && app.startup_protected_input_boundary {
tui.discard_pending_input_before_interactive_screen()?;
app.startup_pending_protected_request = false;
}
Ok(())
}
// Adopt actual launch ownership before constructing session-local preferences.
tui.prepare_owned_screen(config.features.enabled(Feature::TranscriptV2))?;
let mut local_settings = crate::local_settings::LocalSettings::for_tui(&config, tui);
@@ -922,12 +933,15 @@ See the Codex keymap documentation for supported actions and examples."
{
return shutdown_on_startup_error(app_server, err).await;
}
// Input for the cancelled resume/fork must not appear in a later selected session.
let mut pending_startup_draft =
(!startup_session_cancelled).then(|| startup_draft.into_draft());
// Keep cancelled resume/fork text editable, but never carry confirmation to another session.
let mut pending_startup_submission =
startup_draft.take_submission_intent() && !startup_session_cancelled;
let mut pending_startup_draft = Some(startup_draft.into_draft());
if app_event_rx.is_empty() && !app.has_queued_startup_protected_request() {
app.chat_widget
.restore_startup_draft_when_ready(&mut pending_startup_draft);
app.chat_widget.restore_startup_input_when_ready(
&mut pending_startup_draft,
&mut pending_startup_submission,
);
}
#[cfg(windows)]
@@ -940,7 +954,7 @@ See the Codex keymap documentation for supported actions and examples."
let event_stream_started_at = Instant::now();
tui.schedule_screen_size_recheck(Duration::ZERO);
if let Err(err) = render_startup_frame(&mut app, tui) {
if let Err(err) = app.render_startup_frame(tui, &app_event_rx) {
return shutdown_on_startup_error(app_server, err).await;
}
let tui_events = tui.event_stream();
@@ -1094,7 +1108,7 @@ See the Codex keymap documentation for supported actions and examples."
}
if !had_active_view
&& app.chat_widget.has_active_view()
&& let Err(err) = render_startup_frame(&mut app, tui)
&& let Err(err) = app.render_startup_frame(tui, &app_event_rx)
{
break Err(err);
}
@@ -1236,16 +1250,22 @@ See the Codex keymap documentation for supported actions and examples."
}
if !had_active_view
&& app.chat_widget.has_active_view()
&& let Err(err) = render_startup_frame(&mut app, tui)
&& let Err(err) = app.render_startup_frame(tui, &app_event_rx)
{
break Err(err);
}
}
match control {
AppRunControl::Continue => {
if app.reconnect.offline {
pending_startup_submission = false;
app.chat_widget.cancel_startup_submission();
}
if app_event_rx.is_empty() && !app.has_queued_startup_protected_request() {
app.chat_widget
.restore_startup_draft_when_ready(&mut pending_startup_draft);
app.chat_widget.restore_startup_input_when_ready(
&mut pending_startup_draft,
&mut pending_startup_submission,
);
}
#[cfg(windows)]
if terminal_color_probe_pending
@@ -1264,6 +1284,7 @@ See the Codex keymap documentation for supported actions and examples."
}
let clear_pet_result = tui.clear_ambient_pet_image();
let clear_result = tui.terminal.clear();
// Keep the alternate screen active until the outer guard restores both keyboard stacks.
let exit_reason = match exit_reason_result {
Ok(exit_reason) => {
clear_pet_result?;
+2
View File
@@ -76,6 +76,8 @@ mod safety_buffering;
mod session_lifecycle_requests;
mod session_summary;
mod startup;
#[path = "tests/startup_frame_tests.rs"]
mod startup_frame_tests;
#[path = "tests/startup_warnings_tests.rs"]
mod startup_warnings_tests;
#[path = "tests/stream_animation_tests.rs"]
@@ -0,0 +1,131 @@
//! Startup handoff preserves the loading frame until replayed cells can replace it.
use super::*;
use crate::custom_terminal::test_support::last_rendered_buffer;
use crate::startup_draft::tests::quiet_startup_test_pump;
use pretty_assertions::assert_eq;
fn frame_text(tui: &tui::Tui) -> String {
last_rendered_buffer(&tui.terminal)
.content
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
#[tokio::test]
async fn owned_startup_preserves_loading_until_resume_replay_is_applied() -> Result<()> {
for items in [
Vec::new(),
vec![ThreadItem::AgentMessage {
id: "answer".into(),
text: "Retained **answer** after resume.".into(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
}],
] {
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
app.local_settings.tui.show_tooltips = false;
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let thread_id = ThreadId::new();
let mut startup = quiet_startup_test_pump();
startup.apply_config(&app.config);
startup.update_session_selection(
&mut tui,
&SessionSelection::Resume(crate::resume_picker::SessionTarget {
path: None,
thread_id,
cwd: None,
history_mode: None,
}),
)?;
let loading = last_rendered_buffer(&tui.terminal).clone();
let loading_cursor = tui.terminal.last_known_cursor_pos;
assert!(frame_text(&tui).contains("Resuming session…"));
let has_answer = !items.is_empty();
let turns = if has_answer {
vec![test_turn("resumed-turn", TurnStatus::Completed, items)]
} else {
Vec::new()
};
app.enqueue_primary_thread_session(
test_thread_session(thread_id, app.config.cwd.to_path_buf()),
turns,
)
.await?;
let mut draft = Some(startup.into_draft());
assert!(!events.is_empty());
assert!(app.transcript_cells.is_empty());
while !events.is_empty() {
app.render_startup_frame(&mut tui, &events)?;
assert_eq!(last_rendered_buffer(&tui.terminal), &loading);
assert_eq!(tui.terminal.last_known_cursor_pos, loading_cursor);
let event = events.try_recv()?;
let control = Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?;
assert!(matches!(control, AppRunControl::Continue));
}
app.chat_widget.restore_startup_draft_when_ready(&mut draft);
app.render_startup_frame(&mut tui, &events)?;
assert!(draft.is_none());
let rendered = frame_text(&tui);
assert!(!rendered.contains("Resuming session…"));
if has_answer {
assert!(rendered.contains("Retained answer after resume."));
} else {
assert!(rendered.contains("OpenAI Codex"));
}
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
}
Ok(())
}
#[tokio::test]
async fn owned_startup_renders_visible_decisions_and_overview_before_pending_events() -> Result<()>
{
for overview in [false, true] {
let (mut app, events, _ops) = make_test_app_with_channels().await;
app.app_event_tx
.send(AppEvent::BeginInitialHistoryReplayBuffer);
if overview {
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
} else {
let preset = codex_utils_approval_presets::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);
}
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
assert!(!events.is_empty());
app.render_startup_frame(&mut tui, &events)?;
assert!(frame_text(&tui).contains(if overview {
"Agent command center"
} else {
"sandbox"
}));
tui.set_owned_screen(/*owned*/ false)?;
}
Ok(())
}
#[tokio::test]
async fn inline_startup_still_renders_with_pending_history() -> Result<()> {
let (mut app, events, _ops) = make_test_app_with_channels().await;
app.app_event_tx
.send(AppEvent::BeginInitialHistoryReplayBuffer);
app.chat_widget
.apply_external_edit("inline startup draft".into());
let mut tui = crate::tui::test_support::make_test_tui()?;
app.render_startup_frame(&mut tui, &events)?;
assert!(frame_text(&tui).contains("inline startup draft"));
Ok(())
}
+9 -1
View File
@@ -762,7 +762,14 @@ impl App {
)
.await
{
Ok(_) => return Ok(true),
Ok(_) => {
if self.active_thread_id == Some(thread_id)
&& self.chat_widget.thread_id() == Some(thread_id)
{
crate::startup_recovery::acknowledged(client_user_message_id);
}
return Ok(true);
}
Err(error) => {
if let Some(turn_error) =
active_turn_not_steerable_turn_error(&error)
@@ -882,6 +889,7 @@ impl App {
if self.active_thread_id == Some(thread_id)
&& self.chat_widget.thread_id() == Some(thread_id)
{
crate::startup_recovery::acknowledged(client_user_message_id);
self.chat_widget
.record_safety_buffering_turn(response.turn.id, op);
}
+13 -2
View File
@@ -89,8 +89,10 @@
//!
//! # Startup Draft Handoff
//!
//! Startup uses a provisional plain-text composer: editing remains available, but submission,
//! popups, attachments, and other actions are disabled. [`ComposerDraftSnapshot`] transfers its
//! Startup uses a provisional plain-text composer: editing remains available while its owner
//! records one submit intent. Popups, attachments, and direct dispatch stay disabled. After the
//! protected startup gates finish, `prepare_startup_submission` applies normal submission parsing
//! once, including expansion of paste placeholders. [`ComposerDraftSnapshot`] transfers its
//! text, cursor, pending paste placeholders, local history, and recent activity to the fully
//! initialized composer.
//! `ChatWidget` merges the draft with any existing initial prompt and attachments, rebasing cursor
@@ -3123,6 +3125,15 @@ impl ChatComposer {
result
}
/// Prepare a startup-confirmed draft through the normal queue parser, independent of keymaps.
pub(super) fn prepare_startup_submission(&mut self) -> InputResult {
// Startup's plain-text editor cannot show shell mode; require confirmation here.
if self.is_bang_shell_command() {
return InputResult::None;
}
self.handle_submission(/*should_queue*/ true).0
}
fn reset_vim_mode_after_successful_dispatch(&mut self, result: &InputResult) {
if matches!(
result,
@@ -4,6 +4,19 @@ use super::*;
use crate::bottom_pane::paste_burst::FlushResult;
impl ChatComposer {
/// Include accepted but unflushed keys without changing live paste detection.
pub(crate) fn recovery_snapshot(&self) -> ComposerDraftSnapshot {
let mut snapshot = self.draft_snapshot();
if let Some(pending) = self.draft.paste_burst.clone().flush_before_modified_input() {
let mut textarea = TextArea::new();
textarea.set_text_with_elements(&snapshot.text, &snapshot.text_elements);
textarea.insert_str_at(snapshot.cursor, &pending);
snapshot.text = textarea.text().to_owned();
snapshot.text_elements = textarea.text_elements();
}
snapshot
}
/// Classify an explicit paste before integrating text shared with the buffered key path.
pub fn handle_paste(&mut self, pasted: String) -> bool {
self.note_sparkle_paste(&pasted);
+1 -1
View File
@@ -169,7 +169,7 @@ const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8);
#[cfg(windows)]
const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60);
#[derive(Default)]
#[derive(Clone, Default)]
pub(crate) struct PasteBurst {
last_plain_char_time: Option<Instant>,
consecutive_plain_char_burst: u16,
+36 -11
View File
@@ -1,7 +1,7 @@
//! Bottom-pane adapters for the provisional startup composer and its protected handoff.
//!
//! Startup accepts safe editor input while keeping submission, search, and other actions
//! unavailable. Once initialization finishes, these adapters preserve buffered paste text,
//! Startup accepts safe editor input and records submission intent while keeping search and
//! other actions unavailable. Once initialization finishes, these adapters preserve buffered paste text,
//! composer history and cursor state, and the approval-idle timing inherited by the real composer.
use std::time::Instant;
@@ -14,6 +14,16 @@ use super::HistoryEntry;
use crate::key_hint::KeyBindingListExt;
impl BottomPane {
/// Use the effective submit binding for startup confirmation as soon as config is loaded.
pub(crate) fn is_startup_submit_key(&self, key_event: KeyEvent) -> bool {
self.keymap.composer.submit.is_pressed(key_event)
}
/// Consume a confirmed startup draft only after its caller has checked submission readiness.
pub(crate) fn prepare_startup_submission(&mut self) -> super::InputResult {
self.composer.prepare_startup_submission()
}
/// Return whether a key would submit, queue, or search outside the startup draft.
pub(crate) fn is_startup_composer_action(&self, key_event: KeyEvent) -> bool {
let composer = &self.keymap.composer;
@@ -30,9 +40,27 @@ impl BottomPane {
/// Return whether a key is bound only to local startup-safe text editing.
pub(crate) fn is_safe_startup_editor_key(&self, key_event: KeyEvent) -> bool {
let editor = &self.keymap.editor;
self.is_startup_cursor_key(key_event)
|| [
&editor.insert_newline,
&editor.delete_backward,
&editor.delete_forward,
&editor.delete_backward_word,
&editor.delete_forward_word,
&editor.kill_line_start,
&editor.kill_whole_line,
&editor.kill_line_end,
&editor.yank,
]
.into_iter()
.any(|bindings| bindings.is_pressed(key_event))
}
/// Resolve cursor movement through the same configured bindings as the editor.
pub(crate) fn is_startup_cursor_key(&self, key_event: KeyEvent) -> bool {
let editor = &self.keymap.editor;
[
&editor.insert_newline,
&editor.move_left,
&editor.move_right,
&editor.move_up,
@@ -41,14 +69,6 @@ impl BottomPane {
&editor.move_word_right,
&editor.move_line_start,
&editor.move_line_end,
&editor.delete_backward,
&editor.delete_forward,
&editor.delete_backward_word,
&editor.delete_forward_word,
&editor.kill_line_start,
&editor.kill_whole_line,
&editor.kill_line_end,
&editor.yank,
]
.into_iter()
.any(|bindings| bindings.is_pressed(key_event))
@@ -73,6 +93,11 @@ impl BottomPane {
draft
}
/// Preserve keys still buffered by paste detection if startup exits before its next frame.
pub(crate) fn composer_recovery_snapshot(&self) -> ComposerDraftSnapshot {
self.composer.recovery_snapshot()
}
/// Restore the visible cursor before synchronizing composer-owned popups.
pub(crate) fn set_composer_cursor(&mut self, cursor: usize) {
self.composer.set_current_cursor(cursor);
+1
View File
@@ -348,6 +348,7 @@ mod input_restore;
mod input_submission;
mod interrupts;
mod questions;
mod startup_submission;
use self::interrupts::InterruptManager;
mod keymap_picker;
mod mcp_startup;
@@ -21,6 +21,8 @@ pub(super) struct PendingInputPreview {
#[derive(Debug, Default)]
pub(super) struct InputQueueState {
/// The visible draft confirmed during startup, awaiting the protected-input handoff.
pub(super) startup_submission: Option<crate::bottom_pane::ComposerDraftSnapshot>,
/// User inputs queued while a turn is in progress.
pub(super) queued_user_messages: VecDeque<QueuedUserMessage>,
/// History records for queued user messages. Slash commands such as `/goal`
@@ -56,6 +58,7 @@ impl InputQueueState {
}
pub(super) fn clear(&mut self) {
self.startup_submission = None;
self.recovered_queue = false;
self.queued_user_messages.clear();
self.queued_user_message_history_records.clear();
@@ -96,6 +96,7 @@ impl ChatWidget {
if startup_has_content && self.local_settings.tui.vim_mode_default {
self.bottom_pane.enable_vim_in_insert_mode();
}
crate::startup_recovery::handed_off(|| self.bottom_pane.composer_recovery_snapshot());
}
/// Includes protected prompts deferred by streaming or the approval idle timer.
@@ -423,6 +423,7 @@ impl ChatWidget {
let submitted_image_display = (render_in_history && !local_images.is_empty())
.then(|| Self::user_message_display_from_inputs(&items));
let client_user_message_id = uuid::Uuid::new_v4().to_string();
crate::startup_recovery::bind_submission(&text, &client_user_message_id);
let pending_steer = (!render_in_history).then(|| PendingSteer {
client_id: client_user_message_id.clone(),
user_message: UserMessage {
@@ -14,6 +14,9 @@ impl ChatWidget {
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.handle_startup_submission_key(key_event) {
return;
}
if self.handle_question_key(key_event) {
return;
}
@@ -160,6 +163,7 @@ impl ChatWidget {
{
if let Some(composer) = self.pop_latest_queued_composer_state() {
self.restore_composer_state(composer);
self.refresh_startup_recovery();
self.refresh_pending_input_preview();
self.request_redraw();
} else {
@@ -221,6 +225,13 @@ impl ChatWidget {
let should_pause_active_goal =
self.bottom_pane.should_interrupt_running_task(key_event);
let input_result = self.bottom_pane.handle_key_event(key_event);
if matches!(
input_result,
InputResult::None | InputResult::ParentOwnedInputBlocked
) {
self.refresh_startup_recovery();
}
crate::startup_recovery::submitted(&input_result);
self.sync_backend_banner_view();
if should_pause_active_goal {
self.pause_active_goal_for_interrupt();
@@ -244,6 +255,7 @@ impl ChatWidget {
}
tracing::info!("attach_image path={path:?}");
self.bottom_pane.attach_image(path);
self.refresh_startup_recovery();
self.request_redraw();
}
@@ -253,6 +265,7 @@ impl ChatWidget {
pub(crate) fn apply_external_edit(&mut self, text: String) {
self.bottom_pane.apply_external_edit(text);
self.refresh_startup_recovery();
self.request_redraw();
}
@@ -498,12 +511,17 @@ impl ChatWidget {
if self.external_writer_view && !self.bottom_pane.has_active_view() {
return;
}
if !self.startup_submission_has_protected_input() {
self.cancel_startup_submission();
}
self.bottom_pane.handle_paste(text);
self.refresh_startup_recovery();
}
// Returns true if caller should skip rendering this frame (a future frame is scheduled).
pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool {
if self.bottom_pane.flush_paste_burst_if_due() {
self.refresh_startup_recovery();
// A paste just flushed; request an immediate redraw and skip this frame.
self.request_redraw();
true
@@ -537,6 +555,7 @@ impl ChatWidget {
KeyModifiers::CONTROL,
));
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
self.refresh_startup_recovery();
if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if modal_or_popup_active {
self.quit_shortcut_expires_at = None;
+1
View File
@@ -7,6 +7,7 @@ use crate::bottom_pane::RestrictedInputMode;
impl ChatWidget {
pub(crate) fn pause_for_disconnect(&mut self) {
self.cancel_startup_submission();
self.cancel_image_submission();
// The app-server transport can fail while the separate WebRTC helper
// still sends microphone audio. Retire local media before showing offline UI.
@@ -0,0 +1,123 @@
//! Carry one startup submission intent across draft handoff without consuming editable text early.
//!
//! Confirmation applies to the visible draft only. Editing, cancellation, or disconnect removes
//! the intent; normal submission parsing runs once the session and protected-input gates are ready.
use super::*;
use crate::bottom_pane::ComposerDraftSnapshot;
impl ChatWidget {
/// Keep recovery current while startup still owns an unsent, handed-off draft.
pub(super) fn refresh_startup_recovery(&self) {
crate::startup_recovery::refresh_after_handoff(|| {
self.bottom_pane.composer_recovery_snapshot()
});
}
/// Restore startup text first, then submit its confirmed contents once the session is ready.
pub(crate) fn restore_startup_input_when_ready(
&mut self,
pending_draft: &mut Option<ComposerDraftSnapshot>,
pending_submission: &mut bool,
) {
let had_draft = pending_draft.is_some();
let destination_has_content = !self.bottom_pane.composer_is_empty()
|| !self.bottom_pane.composer_pending_pastes().is_empty();
self.restore_startup_draft_when_ready(pending_draft);
if self.blocks_direct_input {
*pending_submission = false;
self.cancel_startup_submission();
return;
}
if had_draft
&& pending_draft.is_none()
&& std::mem::take(pending_submission)
&& !destination_has_content
{
self.input_queue.startup_submission = Some(self.bottom_pane.composer_draft_snapshot());
self.bottom_pane.set_footer_hint_override(Some(vec![
("Waiting for startup".into(), String::new()),
("esc".into(), "cancel".into()),
]));
}
if pending_draft.is_some()
|| !self.is_session_configured()
|| self
.effective_collaboration_mode()
.model()
.trim()
.is_empty()
|| !self.bottom_pane.composer_input_enabled()
|| self.startup_submission_has_protected_input()
|| self.is_plan_streaming_in_tui()
|| self.is_user_turn_pending_or_running()
|| self.input_queue.suppress_queue_autosend
|| self.input_queue.rate_limit_recovery_pending
|| self.input_queue.recovered_queue
{
return;
}
#[cfg(any(target_os = "windows", test))]
if self.windows_sandbox_host == crate::app::WindowsSandboxHost::Local
&& self.elevated_windows_sandbox_setup_required()
{
return;
}
let Some(confirmed) = self.input_queue.startup_submission.take() else {
crate::startup_recovery::clear();
return;
};
self.bottom_pane.set_footer_hint_override(/*items*/ None);
let current = self.bottom_pane.composer_draft_snapshot();
if current.text != confirmed.text
|| current.text_elements != confirmed.text_elements
|| current.local_images != confirmed.local_images
|| current.remote_image_urls != confirmed.remote_image_urls
|| current.mention_bindings != confirmed.mention_bindings
|| current.pending_pastes != confirmed.pending_pastes
{
return;
}
let result = self.bottom_pane.prepare_startup_submission();
crate::startup_recovery::submitted(&result);
self.handle_composer_input_result(result, /*had_modal_or_popup*/ false);
}
/// Cancel only the intent; the editor keeps the complete draft for editing or recovery.
pub(crate) fn cancel_startup_submission(&mut self) {
if self.input_queue.startup_submission.take().is_some() {
self.bottom_pane.set_footer_hint_override(/*items*/ None);
self.refresh_startup_recovery();
}
}
pub(super) fn handle_startup_submission_key(&mut self, key: KeyEvent) -> bool {
if self.input_queue.startup_submission.is_none()
|| self.startup_submission_has_protected_input()
|| !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
{
return false;
}
if self.bottom_pane.is_startup_submit_key(key) {
return true;
}
if key.code == KeyCode::Esc {
self.cancel_startup_submission();
return true;
}
if !self.bottom_pane.is_startup_cursor_key(key) {
self.cancel_startup_submission();
}
false
}
pub(super) fn startup_submission_has_protected_input(&self) -> bool {
self.has_active_view()
|| self
.bottom_pane
.questions
.as_ref()
.is_some_and(|q| q.expanded)
|| self.has_pending_protected_request()
}
}
+2
View File
@@ -292,6 +292,8 @@ mod side;
mod slash_commands;
#[path = "tests/sparkle_submission_tests.rs"]
mod sparkle_submission_tests;
#[path = "tests/startup_submission_tests.rs"]
mod startup_submission_tests;
mod status_and_layout;
mod status_command_tests;
mod status_surface_previews;
@@ -0,0 +1,137 @@
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn startup_submission_waits_for_session_and_sends_exactly_once() {
let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
source.bottom_pane.insert_str("confirmed before startup");
let mut draft = Some(source.bottom_pane.composer_draft_snapshot());
let mut confirmed = true;
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
assert!(draft.is_none());
assert_eq!(chat.bottom_pane.composer_text(), "confirmed before startup");
for _ in 0..3 {
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
}
for key in [
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT),
] {
chat.handle_key_event(key);
}
assert_no_submit_op(&mut op_rx);
chat.thread_id = Some(ThreadId::new());
let model = chat.effective_collaboration_mode().model().to_string();
chat.set_model("");
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
assert_no_submit_op(&mut op_rx);
assert_eq!(chat.bottom_pane.composer_text(), "confirmed before startup");
chat.set_model(&model);
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { items, .. } if items == vec![UserInput::Text {
text: "confirmed before startup".into(), text_elements: Vec::new(),
}]);
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
assert_no_submit_op(&mut op_rx);
assert_eq!(chat.bottom_pane.composer_text(), "");
}
#[tokio::test]
async fn startup_submission_escape_restores_editing_without_submitting() {
let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
source.bottom_pane.insert_str("keep the draft");
source.bottom_pane.set_composer_cursor(/*cursor*/ 4);
let mut draft = Some(source.bottom_pane.composer_draft_snapshot());
let mut confirmed = true;
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
let before = chat.bottom_pane.composer_draft_snapshot();
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
chat.thread_id = Some(ThreadId::new());
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
assert_eq!(chat.bottom_pane.composer_draft_snapshot(), before);
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn startup_submission_waits_for_protected_view_and_preserves_paste_provenance() {
for payload in [
" !echo hi\n# visible tail".to_string(),
format!("!echo {}", "x".repeat(/*n*/ 1000)),
] {
let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
source.handle_paste(payload.clone());
let mut draft = Some(source.bottom_pane.composer_draft_snapshot());
let mut confirmed = true;
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.open_approvals_popup();
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
assert!(draft.is_some());
assert_no_submit_op(&mut op_rx);
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
if payload.starts_with(' ') {
assert!(op_rx.try_recv().is_err());
assert_eq!(chat.bottom_pane.composer_text(), payload);
chat.handle_key_event(KeyCode::Enter.into());
assert_matches!(op_rx.try_recv(), Ok(Op::RunUserShellCommand { command })
if command == payload.trim_start().trim_start_matches('!'));
} else {
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { items, .. } if items == vec![UserInput::Text {
text: payload, text_elements: Vec::new(),
}]);
}
assert_no_submit_op(&mut op_rx);
}
}
#[tokio::test]
async fn startup_submission_edit_or_disconnect_cancels_without_losing_draft() {
for action in ["edit", "disconnect", "parent-owned"] {
let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
source.bottom_pane.insert_str("confirmed draft");
let mut draft = Some(source.bottom_pane.composer_draft_snapshot());
let mut confirmed = true;
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
if action == "parent-owned" {
chat.set_parent_owned_thread();
}
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
if action == "disconnect" {
chat.pause_for_disconnect();
} else if action == "edit" {
chat.handle_paste(" edited".into());
}
chat.thread_id = Some(ThreadId::new());
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
assert_eq!(
chat.bottom_pane.composer_text(),
if action != "edit" {
"confirmed draft"
} else {
"confirmed draft edited"
}
);
assert!(chat.input_queue.startup_submission.is_none());
assert_no_submit_op(&mut op_rx);
}
}
#[tokio::test]
async fn startup_submission_does_not_confirm_merged_destination_text() {
let (mut source, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
source.bottom_pane.insert_str("startup draft");
let mut draft = Some(source.bottom_pane.composer_draft_snapshot());
let mut confirmed = true;
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane.insert_str("existing draft");
chat.thread_id = Some(ThreadId::new());
chat.restore_startup_input_when_ready(&mut draft, &mut confirmed);
assert_eq!(
chat.bottom_pane.composer_text(),
"existing draft\nstartup draft"
);
assert_no_submit_op(&mut op_rx);
}
+72 -31
View File
@@ -73,6 +73,7 @@ use codex_utils_home_dir::find_codex_home;
use codex_utils_oss::ensure_oss_provider_ready;
use codex_utils_oss::get_default_model_for_oss_provider;
use color_eyre::eyre::WrapErr;
use crossterm::SynchronizedUpdate;
use cwd_prompt::CwdPromptAction;
pub use session_archive_commands::DeleteConfirmation;
pub use session_archive_commands::SessionArchiveAction;
@@ -200,6 +201,8 @@ mod startup_error;
mod startup_hooks_review;
mod startup_orchestration;
mod startup_preflight;
mod startup_presentation;
mod startup_recovery;
mod status;
mod status_indicator_widget;
mod streaming;
@@ -595,14 +598,26 @@ pub(crate) async fn start_app_server_for_picker(
pub(crate) async fn start_embedded_app_server_for_picker(
config: &Config,
) -> color_eyre::Result<AppServerSession> {
let state_db = init_state_db_for_app_server_target(config, &AppServerTarget::Embedded).await?;
start_app_server_for_picker(
config,
&AppServerTarget::Embedded,
state_db,
let mut target = AppServerTarget::Embedded;
let mut state_db = init_state_db_for_app_server_target(config, &target).await?;
let app_server = start_app_server(
&mut target,
Arg0DispatchPaths::default(),
config.clone(),
Vec::new(),
LoaderOverrides::without_managed_config_for_tests(),
/*strict_config*/ false,
CloudConfigBundleLoader::default(),
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,
&mut state_db,
Arc::new(EnvironmentManager::default_for_tests()),
)
.await
.await?;
Ok(
AppServerSession::new(app_server, target.thread_params_mode())
.with_local_codex_home(&config.codex_home),
)
}
#[allow(clippy::too_many_arguments)]
@@ -1015,6 +1030,7 @@ fn restore_terminal_before_fatal_exit() {
if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) {
let _ = tui::restore_after_exit();
}
startup_recovery::print_unsent_draft();
}
pub async fn run_main(
@@ -1024,25 +1040,37 @@ pub async fn run_main(
explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
) -> std::io::Result<AppExitInfo> {
system_motion::initialize().await;
// Keep the startup future out of the CLI caller's frame while the TUI is running.
match Box::pin(startup_orchestration::run_main_inner(
cli,
arg0_paths,
loader_overrides,
explicit_remote_endpoint,
))
startup_recovery::scope(async move {
// Startup retains a large future for the whole session. Keep it off callers' stacks,
// which also need room to construct a replacement chat widget on `/new`.
match Box::pin(startup_orchestration::run_main_inner(
cli,
arg0_paths,
loader_overrides,
explicit_remote_endpoint,
))
.await
{
Err(err) if startup_draft::StartupCancelled::matches(&err) => Ok(AppExitInfo {
token_usage: TokenUsage::default(),
thread_id: None,
resume_hint: None,
disconnect_info: None,
update_action: None,
exit_reason: ExitReason::UserRequested,
}),
Err(err) => {
restore_terminal_before_fatal_exit();
Err(err)
}
Ok(info) if matches!(&info.exit_reason, ExitReason::Fatal(_)) => {
restore_terminal_before_fatal_exit();
Ok(info)
}
result => result,
}
})
.await
{
Err(err) if startup_draft::StartupCancelled::matches(&err) => Ok(AppExitInfo {
token_usage: TokenUsage::default(),
thread_id: None,
resume_hint: None,
disconnect_info: None,
update_action: None,
exit_reason: ExitReason::UserRequested,
}),
result => result,
}
}
#[allow(clippy::too_many_arguments)]
@@ -1429,11 +1457,13 @@ async fn run_ratatui_app(
let Some(app_server) = app_server.take() else {
unreachable!("app server should be initialized for --fork picker");
};
let picker_local_settings =
crate::local_settings::LocalSettings::for_tui(&config, &tui);
match resume_picker::run_fork_picker_with_app_server(
uses_remote_workspace_or_environment(&app_server_target, &environment_manager),
&mut tui,
&config,
&crate::local_settings::LocalSettings::from(&config),
&picker_local_settings,
cli.fork_show_all,
app_server,
)
@@ -1526,11 +1556,12 @@ async fn run_ratatui_app(
let Some(app_server) = app_server.take() else {
unreachable!("app server should be initialized for --resume picker");
};
let picker_local_settings = crate::local_settings::LocalSettings::for_tui(&config, &tui);
match resume_picker::run_resume_picker_with_app_server(
uses_remote_workspace_or_environment(&app_server_target, &environment_manager),
&mut tui,
&config,
&crate::local_settings::LocalSettings::from(&config),
&picker_local_settings,
cli.resume_show_all,
cli.resume_include_non_interactive,
app_server,
@@ -1809,7 +1840,21 @@ async fn run_ratatui_app(
}
startup_draft.apply_config(&config);
let local_settings = crate::local_settings::LocalSettings::from(&config);
// Cloud configuration and session selection can change screen policy after first paint.
let use_alt_screen = determine_alt_screen_mode(cli.no_alt_screen, config.tui_alternate_screen);
let mode = crate::transcript_mode::TranscriptMode::resolve(
config.features.enabled(Feature::TranscriptV2),
use_alt_screen,
);
if use_alt_screen != tui.is_alt_screen_enabled() || mode.is_owned() != tui.is_owned_screen() {
std::io::stdout().sync_update(|_| {
tui.set_alt_screen_enabled(use_alt_screen);
tui.set_owned_screen(mode.is_owned())?;
startup_draft.redraw_if_visible(&mut tui)
})??;
}
let local_settings = crate::local_settings::LocalSettings::for_tui(&config, &tui);
// Configure syntax highlighting theme from the final config — onboarding
// and resume/fork can both reload config with a different tui_theme, so
// this must happen after the last possible reload.
@@ -1830,15 +1875,11 @@ async fn run_ratatui_app(
let Cli {
prompt,
shared,
no_alt_screen,
daemon_cli_executable,
..
} = cli;
let images = shared.into_inner().images;
let use_alt_screen =
determine_alt_screen_mode(no_alt_screen, local_settings.tui.alternate_screen);
tui.set_alt_screen_enabled(use_alt_screen);
// Persistent app-server resumes may attach to an already-running thread,
// where resume config overrides are ignored.
let is_persistent_resume = !matches!(&app_server_target, AppServerTarget::Embedded)
@@ -0,0 +1,21 @@
---
source: tui/src/startup_draft_layout_tests.rs
expression: startup_layout
---
cursor=Some((13, 12))
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯
› first line
second line
Waiting for startup · esc cancel
+141 -182
View File
@@ -1,4 +1,5 @@
//! Display an editable, non-submitting composer while startup work continues.
//! Keep the first composer editable and bottom-anchored while startup work continues.
//! Submit keys confirm one draft locally; session dispatch waits for the protected handoff.
use std::future::Future;
use std::io;
@@ -8,10 +9,7 @@ use std::task::Poll;
use std::time::Duration;
use std::time::Instant;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use crossterm::SynchronizedUpdate;
use ratatui::layout::Size;
use ratatui::style::Modifier;
use ratatui::style::Style;
@@ -31,7 +29,6 @@ use crate::bottom_pane::ChatComposerConfig;
use crate::bottom_pane::ComposerDraftSnapshot;
use crate::history_cell;
use crate::history_cell::HistoryCell;
use crate::key_hint;
use crate::keymap::RuntimeKeymap;
use crate::legacy_core::config::Config;
use crate::render::Insets;
@@ -49,6 +46,23 @@ use crate::version::CODEX_CLI_VERSION;
const STARTUP_EVENT_BATCH_SIZE: usize = 64;
const STARTUP_PASTE_NEWLINE_TIMEOUT: Duration = Duration::from_millis(120);
#[path = "startup_draft_layout.rs"]
mod layout;
#[path = "startup_draft_input.rs"]
mod input;
#[cfg(test)]
use input::handle_startup_draft_key;
/// Locally resolved presentation used before the first editable frame.
pub(crate) struct StartupScreen {
pub(crate) use_alt_screen: bool,
pub(crate) transcript_mode: crate::transcript_mode::TranscriptMode,
pub(crate) status_line_enabled: bool,
pub(crate) keymap: RuntimeKeymap,
pub(crate) disable_paste_burst: bool,
}
/// Identifies the first interactive surface expected for the current invocation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum StartupDraftInitialScreen {
@@ -61,7 +75,6 @@ pub(crate) enum StartupDraftInitialScreen {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum StartupDraftSessionAction {
New,
/// Keep in-app creation alive while allowing draft edits until setup finishes.
NewFromCommandCenter,
Resume,
Fork,
@@ -87,7 +100,7 @@ pub(crate) struct StartupDraft {
pump: StartupDraftPump,
}
/// Keeps the existing terminal responsive without owning it or permitting startup submission.
/// Keeps terminal input responsive and carries one submission intent into the live session.
pub(crate) struct StartupDraftPump {
header: Box<dyn HistoryCell>,
bottom_pane: BottomPane,
@@ -95,31 +108,50 @@ pub(crate) struct StartupDraftPump {
app_event_rx: UnboundedReceiver<AppEvent>,
initial_screen: StartupDraftInitialScreen,
session_action: StartupDraftSessionAction,
resolved_selection: Option<SessionSelection>,
configured_cwd: Option<PathBuf>,
pending_paste_newline: Option<(Instant, String)>,
submission_pending: bool,
key_chord_matcher: crate::keymap::KeyChordMatcher,
key_chords: std::sync::Arc<crate::keymap::RuntimeChordKeymap>,
}
impl StartupDraft {
/// Initialize the terminal without showing a composer before an expected session picker.
/// Adopt the initialized terminal and apply editing policy before the first composer paint.
pub(crate) fn new(
mut initialized_terminal: tui::InitializedTerminal,
terminal_restore_guard: TerminalRestoreGuard,
initial_screen: StartupDraftInitialScreen,
session_action: StartupDraftSessionAction,
screen: StartupScreen,
) -> io::Result<Self> {
let mut initialized_terminal = tui::init()?;
let terminal_restore_guard = TerminalRestoreGuard::new();
initialized_terminal.terminal.clear()?;
let tui = Tui::new(
let mut tui = Tui::new(
initialized_terminal.terminal,
initialized_terminal.enhanced_keys_supported,
initialized_terminal.stderr_guard,
);
let pump = StartupDraftPump::new(&tui, initial_screen, session_action);
tui.set_alt_screen_enabled(screen.use_alt_screen);
let mut pump = StartupDraftPump::new(&tui, initial_screen, session_action);
pump.bottom_pane
.set_status_line_enabled(screen.status_line_enabled);
pump.bottom_pane.set_keymap_bindings(&screen.keymap);
pump.bottom_pane
.set_disable_paste_burst(screen.disable_paste_burst);
pump.key_chords = screen.keymap.chords;
let mut draft = Self {
tui,
terminal_restore_guard,
pump,
};
draft.pump.show_initial_screen(&mut draft.tui)?;
// Present the screen switch and its first composer frame together.
std::io::stdout().sync_update(|_| {
draft
.tui
.set_owned_screen(screen.transcript_mode.is_owned())?;
draft.pump.redraw_if_visible(&mut draft.tui)
})??;
Ok(draft)
}
@@ -143,6 +175,7 @@ impl StartupDraft {
/// Lend the original terminal to an existing interactive startup screen.
pub(crate) fn tui_mut(&mut self) -> &mut Tui {
self.pump.key_chord_matcher.cancel();
&mut self.tui
}
@@ -171,9 +204,19 @@ impl StartupDraftPump {
initial_screen,
session_action,
pending_paste_newline: None,
resolved_selection: None,
configured_cwd: None,
submission_pending: false,
key_chord_matcher: Default::default(),
key_chords: RuntimeKeymap::defaults().chords,
}
}
pub(crate) fn take_draft(&mut self) -> ComposerDraftSnapshot {
self.bottom_pane.flush_composer_paste_burst();
self.bottom_pane.composer_draft_snapshot()
}
/// Keep a provisional composer responsive when the caller has one to display.
pub(crate) async fn run_with_optional_draft<F, T, E>(
draft: Option<&mut Self>,
@@ -192,17 +235,39 @@ impl StartupDraftPump {
/// Refresh the session header and safe editor shortcuts without enabling modal editing.
pub(crate) fn apply_config(&mut self, config: &Config) {
if self
.configured_cwd
.as_deref()
.is_some_and(|cwd| cwd != config.cwd.as_path())
{
self.cancel_submission();
}
self.configured_cwd = Some(config.cwd.to_path_buf());
let local_settings = crate::local_settings::LocalSettings::from(config);
self.header = startup_session_header(Some(config));
self.bottom_pane.set_status_line_enabled(
local_settings
.tui
.status_line
.as_ref()
.is_none_or(|items| !items.is_empty()),
);
self.bottom_pane
.set_disable_paste_burst(local_settings.tui.disable_paste_burst.unwrap_or(false));
self.bottom_pane.request_redraw();
if let Ok(keymap) = RuntimeKeymap::from_config(&local_settings.tui.keymap) {
if crate::keymap::keymap_action_id("composer", "submit").is_none_or(|submit| {
self.key_chords.configured_specs(submit) != keymap.chords.configured_specs(submit)
}) {
self.cancel_submission();
}
self.bottom_pane.set_keymap_bindings(&keymap);
self.key_chord_matcher.cancel();
self.key_chords = keymap.chords;
}
}
/// Align the provisional loading message with the resolved session selection.
/// Align the loading message and revoke confirmation when startup changes its destination.
pub(crate) fn update_session_selection(
&mut self,
tui: &mut Tui,
@@ -215,9 +280,31 @@ impl StartupDraftPump {
SessionSelection::Resume(_) => StartupDraftSessionAction::Resume,
SessionSelection::Fork(_) => StartupDraftSessionAction::Fork,
};
if self.session_action == session_action {
return Ok(());
let destination_changed = match self.resolved_selection.as_ref() {
Some(SessionSelection::StartFresh) => {
!matches!(session_selection, SessionSelection::StartFresh)
}
Some(SessionSelection::AgentsOverview) => {
!matches!(session_selection, SessionSelection::AgentsOverview)
}
Some(SessionSelection::Exit) => !matches!(session_selection, SessionSelection::Exit),
Some(SessionSelection::Resume(previous)) => {
!matches!(session_selection, SessionSelection::Resume(next) if previous.thread_id == next.thread_id && previous.cwd == next.cwd)
}
Some(SessionSelection::Fork(previous)) => {
!matches!(session_selection, SessionSelection::Fork(next) if previous.thread_id == next.thread_id && previous.cwd == next.cwd)
}
None => self.session_action != session_action,
};
if destination_changed
|| matches!(
session_selection,
SessionSelection::AgentsOverview | SessionSelection::Exit
)
{
self.cancel_submission();
}
self.resolved_selection = Some(session_selection.clone());
self.session_action = session_action;
if self.initial_screen == StartupDraftInitialScreen::Composer {
self.draw(tui, tui.terminal.last_known_screen_size)?;
@@ -289,16 +376,24 @@ impl StartupDraftPump {
/// Preserve the editable draft, its cursor, and any pending large-paste placeholders.
pub(crate) fn into_draft(mut self) -> ComposerDraftSnapshot {
self.take_draft()
let draft = self.take_draft();
crate::startup_recovery::remember(draft.clone());
draft
}
pub(crate) fn take_draft(&mut self) -> ComposerDraftSnapshot {
self.bottom_pane.flush_composer_paste_burst();
self.bottom_pane.composer_draft_snapshot()
/// Transfer the single confirmed submission without removing its visible draft.
pub(crate) fn take_submission_intent(&mut self) -> bool {
std::mem::take(&mut self.submission_pending)
}
/// Draw the initial composer only when no protected startup screen must appear first.
fn show_initial_screen(&mut self, tui: &mut Tui) -> io::Result<()> {
fn cancel_submission(&mut self) {
if std::mem::take(&mut self.submission_pending) {
self.bottom_pane.set_footer_hint_override(/*items*/ None);
}
}
/// Redraw the composer without revealing it while a protected startup screen owns input.
pub(crate) fn redraw_if_visible(&mut self, tui: &mut Tui) -> io::Result<()> {
if self.initial_screen == StartupDraftInitialScreen::Composer {
self.show(tui)?;
}
@@ -311,113 +406,28 @@ impl StartupDraftPump {
self.draw(tui, tui.terminal.last_known_screen_size)
}
fn handle_event(&mut self, tui: &mut Tui, event: TuiEvent) -> io::Result<()> {
let screen_size = tui.screen_size_for_event(&event)?;
if let Some((started_at, mut newlines)) = self.pending_paste_newline.take() {
let continues_paste = match &event {
TuiEvent::Key(KeyEvent {
code: KeyCode::Char(_),
modifiers,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => !key_hint::has_ctrl_or_alt(*modifiers),
TuiEvent::Key(KeyEvent {
code: KeyCode::Enter,
modifiers,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) if modifiers.is_empty() => {
if started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT {
newlines.push('\n');
self.pending_paste_newline = Some((Instant::now(), newlines));
}
return Ok(());
}
TuiEvent::Paste(text) => !text.is_empty(),
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {
self.pending_paste_newline = Some((started_at, newlines));
if self.initial_screen == StartupDraftInitialScreen::Composer {
self.draw(tui, screen_size)?;
}
return Ok(());
}
TuiEvent::Mouse(_) | TuiEvent::FocusLost => {
self.pending_paste_newline = Some((started_at, newlines));
return Ok(());
}
TuiEvent::Key(_) => false,
};
if continues_paste && started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT {
self.bottom_pane.handle_paste(newlines);
}
}
match event {
TuiEvent::Key(key) => {
if self.initial_screen != StartupDraftInitialScreen::Composer
&& !key_hint::ctrl(KeyCode::Char('c')).is_press(key)
&& !key_hint::ctrl(KeyCode::Char('d')).is_press(key)
{
return Ok(());
}
if key.code == KeyCode::Enter
&& key.modifiers.is_empty()
&& self.bottom_pane.is_in_paste_burst()
{
let text_len = self.bottom_pane.composer_text().len();
self.bottom_pane.flush_composer_paste_burst();
if self
.bottom_pane
.composer_text()
.len()
.saturating_sub(text_len)
> 1
{
self.pending_paste_newline = Some((Instant::now(), "\n".to_string()));
}
}
match handle_startup_draft_key(&mut self.bottom_pane, key) {
Err(error) if StartupCancelled::matches(&error) => {
// In-app setup owns a live creation request. Keep pumping input
// instead of abandoning it or blocking on cancellation cleanup.
if self.session_action != StartupDraftSessionAction::NewFromCommandCenter {
if let Err(clear_error) = tui.terminal.clear() {
tracing::warn!(
error = %clear_error,
"failed to clear the cancelled startup composer"
);
}
return Err(error);
}
}
result => result?,
}
}
TuiEvent::Paste(text) => {
if self.initial_screen == StartupDraftInitialScreen::Composer {
self.bottom_pane.flush_composer_paste_burst();
self.bottom_pane.handle_paste(text);
}
}
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {}
TuiEvent::Mouse(_) | TuiEvent::FocusLost => return Ok(()),
}
if self.initial_screen == StartupDraftInitialScreen::Composer {
self.draw(tui, screen_size)?;
}
while self.app_event_rx.try_recv().is_ok() {}
Ok(())
}
fn draw(&mut self, tui: &mut Tui, screen_size: Size) -> io::Result<()> {
let _ = self.bottom_pane.flush_paste_burst_if_due();
if self.bottom_pane.flush_paste_burst_if_due() {
crate::startup_recovery::remember(self.bottom_pane.composer_recovery_snapshot());
}
if self.bottom_pane.is_in_paste_burst() {
tui.frame_requester()
.schedule_frame_in(ChatComposer::recommended_paste_flush_delay());
}
self.bottom_pane.pre_draw_tick();
let renderable =
startup_draft_renderable(&self.header, &self.bottom_pane, self.session_action);
let desired_height = renderable.desired_height(screen_size.width);
let owned = tui.is_owned_screen();
let owned_layout =
layout::OwnedStartupLayout::new(&self.header, &self.bottom_pane, self.session_action);
let renderable = if owned {
RenderableItem::Borrowed(&owned_layout)
} else {
startup_draft_renderable(&self.header, &self.bottom_pane, self.session_action)
};
let desired_height = if owned {
screen_size.height
} else {
renderable.desired_height(screen_size.width)
};
tui.draw_with_resize_reflow(desired_height, screen_size, |frame| {
let area = frame.area();
renderable.render(area, frame.buffer);
@@ -425,66 +435,11 @@ impl StartupDraftPump {
frame.set_cursor_style(renderable.cursor_style(area));
frame.set_cursor_position((x, y));
}
})
})?;
Ok(())
}
}
fn handle_startup_draft_key(bottom_pane: &mut BottomPane, key: KeyEvent) -> io::Result<()> {
let _ = bottom_pane.flush_paste_burst_if_due();
if key.kind == KeyEventKind::Release
|| key.code == KeyCode::Enter && key.modifiers.is_empty()
|| matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
|| bottom_pane.is_startup_composer_action(key)
{
return Ok(());
}
if let KeyCode::Char(_) = key.code {
let (code, modifiers) = key_hint::normalize_key_parts(key.code, key.modifiers);
if key_hint::has_ctrl_or_alt(modifiers) && matches!(code, KeyCode::Char('r' | 'v')) {
return Ok(());
}
let is_ctrl_c = key_hint::ctrl(KeyCode::Char('c')).is_press(key);
let is_ctrl_d = key_hint::ctrl(KeyCode::Char('d')).is_press(key);
if key.kind == KeyEventKind::Press && (is_ctrl_c || is_ctrl_d) {
bottom_pane.flush_composer_paste_burst();
if bottom_pane.composer_is_empty() {
return Err(io::Error::new(io::ErrorKind::Interrupted, StartupCancelled));
}
if is_ctrl_c {
bottom_pane.on_ctrl_c();
return Ok(());
}
}
}
if key_hint::has_ctrl_or_alt(key.modifiers) && !bottom_pane.is_safe_startup_editor_key(key)
|| key.code == KeyCode::Enter && !bottom_pane.is_safe_startup_editor_key(key)
|| key
.modifiers
.intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META)
|| !matches!(
key.code,
KeyCode::Char(_)
| KeyCode::Enter
| KeyCode::Esc
| KeyCode::Left
| KeyCode::Right
| KeyCode::Up
| KeyCode::Down
| KeyCode::Home
| KeyCode::End
| KeyCode::Backspace
| KeyCode::Delete
)
{
return Ok(());
}
let _ = bottom_pane.handle_key_event(key);
Ok(())
}
fn startup_session_header(config: Option<&Config>) -> Box<dyn HistoryCell> {
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
let directory = config.map_or_else(
@@ -525,7 +480,7 @@ fn startup_draft_renderable<'a>(
renderable.push(
/*flex*/ 0,
bottom_pane
.as_renderable_with_options(Default::default())
.as_renderable_with_options(crate::bottom_pane::ComposerRenderOptions::default())
.inset(Insets::tlbr(
/*top*/ u16::from(loading_message.is_none()),
/*left*/ 0,
@@ -561,3 +516,7 @@ fn startup_draft_bottom_pane(
#[cfg(test)]
#[path = "startup_draft_tests.rs"]
pub(crate) mod tests;
#[cfg(test)]
#[path = "startup_draft_submission_tests.rs"]
mod submission_tests;
+237
View File
@@ -0,0 +1,237 @@
//! Edit and confirm the provisional startup draft without executing actions before initialization.
//!
//! Terminal input stays with the original pump through startup. Paste-newline ambiguity is
//! preserved, protected screens do not consume draft input, and confirmation never removes text.
use std::io;
use std::time::Instant;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use super::STARTUP_PASTE_NEWLINE_TIMEOUT;
use super::StartupCancelled;
use super::StartupDraftInitialScreen;
use super::StartupDraftPump;
use crate::bottom_pane::BottomPane;
use crate::key_hint;
use crate::tui::Tui;
use crate::tui::TuiEvent;
impl StartupDraftPump {
pub(super) fn handle_event(&mut self, tui: &mut Tui, event: TuiEvent) -> io::Result<()> {
let screen_size = tui.screen_size_for_event(&event)?;
if let Some((started_at, mut newlines)) = self.pending_paste_newline.take() {
let continues_paste = match &event {
TuiEvent::Key(KeyEvent {
code: KeyCode::Char(_),
modifiers,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => !key_hint::has_ctrl_or_alt(*modifiers),
TuiEvent::Key(KeyEvent {
code: KeyCode::Enter,
modifiers,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) if modifiers.is_empty() => {
if started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT {
newlines.push('\n');
self.pending_paste_newline = Some((Instant::now(), newlines));
return Ok(());
}
false
}
TuiEvent::Paste(text) => !text.is_empty(),
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {
self.pending_paste_newline = Some((started_at, newlines));
if self.initial_screen == StartupDraftInitialScreen::Composer {
self.draw(tui, screen_size)?;
}
return Ok(());
}
TuiEvent::FocusLost | TuiEvent::Mouse(_) => {
self.pending_paste_newline = Some((started_at, newlines));
return Ok(());
}
TuiEvent::Key(_) => false,
};
if continues_paste && started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT {
self.bottom_pane.handle_paste(newlines);
}
}
match event {
TuiEvent::Key(key) => {
if self.initial_screen != StartupDraftInitialScreen::Composer
&& !key_hint::ctrl(KeyCode::Char('c')).is_press(key)
&& !key_hint::ctrl(KeyCode::Char('d')).is_press(key)
{
return Ok(());
}
let key = match self.key_chord_matcher.advance(
key,
&self.key_chords,
self.bottom_pane.keymap_contexts(),
) {
crate::keymap::KeyChordMatch::Completed(key) => key,
crate::keymap::KeyChordMatch::PassThrough => key,
crate::keymap::KeyChordMatch::Pending(_)
| crate::keymap::KeyChordMatch::Cancelled
| crate::keymap::KeyChordMatch::Ignored => return Ok(()),
};
if key.code == KeyCode::Enter
&& key.modifiers.is_empty()
&& self.bottom_pane.is_in_paste_burst()
{
let text_len = self.bottom_pane.composer_text().len();
self.bottom_pane.flush_composer_paste_burst();
if self
.bottom_pane
.composer_text()
.len()
.saturating_sub(text_len)
> 1
&& (self.bottom_pane.is_startup_composer_action(key)
|| !self.bottom_pane.is_safe_startup_editor_key(key))
{
self.pending_paste_newline = Some((Instant::now(), "\n".to_string()));
}
}
if self.initial_screen == StartupDraftInitialScreen::Composer
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& self.session_action != super::StartupDraftSessionAction::NewFromCommandCenter
&& self.bottom_pane.is_startup_submit_key(key)
{
self.bottom_pane.flush_composer_paste_burst();
if self.pending_paste_newline.is_none()
&& !self.bottom_pane.composer_text().trim().is_empty()
{
self.submission_pending = true;
self.bottom_pane.set_footer_hint_override(Some(vec![
("Waiting for startup".into(), String::new()),
("esc".into(), "cancel".into()),
]));
}
crate::startup_recovery::remember(
self.bottom_pane.composer_recovery_snapshot(),
);
self.draw(tui, screen_size)?;
return Ok(());
}
if self.submission_pending
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& key.code == KeyCode::Esc
{
self.cancel_submission();
self.draw(tui, screen_size)?;
return Ok(());
}
let previous_text = self.bottom_pane.composer_text();
if let Err(error) = handle_startup_draft_key(&mut self.bottom_pane, key)
&& !(self.session_action
== super::StartupDraftSessionAction::NewFromCommandCenter
&& StartupCancelled::matches(&error))
{
if StartupCancelled::matches(&error)
&& let Err(clear_error) = tui.terminal.clear()
{
tracing::warn!(
error = %clear_error,
"failed to clear the cancelled startup composer"
);
}
return Err(error);
}
if self.bottom_pane.composer_text() != previous_text
|| self.bottom_pane.is_in_paste_burst()
{
self.cancel_submission();
}
}
TuiEvent::Paste(text) => {
self.key_chord_matcher.cancel();
if self.initial_screen == StartupDraftInitialScreen::Composer {
self.cancel_submission();
self.bottom_pane.flush_composer_paste_burst();
self.bottom_pane.handle_paste(text);
}
}
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {}
TuiEvent::FocusLost => {
self.key_chord_matcher.cancel();
return Ok(());
}
TuiEvent::Mouse(_) => return Ok(()),
}
crate::startup_recovery::remember(self.bottom_pane.composer_recovery_snapshot());
if self.initial_screen == StartupDraftInitialScreen::Composer {
self.draw(tui, screen_size)?;
}
while self.app_event_rx.try_recv().is_ok() {}
Ok(())
}
}
pub(super) fn handle_startup_draft_key(
bottom_pane: &mut BottomPane,
key: KeyEvent,
) -> io::Result<()> {
let _ = bottom_pane.flush_paste_burst_if_due();
if key.kind == KeyEventKind::Release
|| key.code == KeyCode::Enter
&& key.modifiers.is_empty()
&& !bottom_pane.is_safe_startup_editor_key(key)
|| matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
|| bottom_pane.is_startup_composer_action(key)
{
return Ok(());
}
if let KeyCode::Char(_) = key.code {
let (code, modifiers) = key_hint::normalize_key_parts(key.code, key.modifiers);
if key_hint::has_ctrl_or_alt(modifiers) && matches!(code, KeyCode::Char('r' | 'v')) {
return Ok(());
}
let is_ctrl_c = key_hint::ctrl(KeyCode::Char('c')).is_press(key);
let is_ctrl_d = key_hint::ctrl(KeyCode::Char('d')).is_press(key);
if key.kind == KeyEventKind::Press && (is_ctrl_c || is_ctrl_d) {
bottom_pane.flush_composer_paste_burst();
if bottom_pane.composer_is_empty() {
return Err(io::Error::new(io::ErrorKind::Interrupted, StartupCancelled));
}
if is_ctrl_c {
bottom_pane.on_ctrl_c();
return Ok(());
}
}
}
if key_hint::has_ctrl_or_alt(key.modifiers) && !bottom_pane.is_safe_startup_editor_key(key)
|| key.code == KeyCode::Enter && !bottom_pane.is_safe_startup_editor_key(key)
|| key
.modifiers
.intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META)
|| !bottom_pane.is_safe_startup_editor_key(key)
&& !matches!(
key.code,
KeyCode::Char(_)
| KeyCode::Enter
| KeyCode::Esc
| KeyCode::Left
| KeyCode::Right
| KeyCode::Up
| KeyCode::Down
| KeyCode::Home
| KeyCode::End
| KeyCode::Backspace
| KeyCode::Delete
)
{
return Ok(());
}
let _ = bottom_pane.handle_key_event(key);
Ok(())
}
+99
View File
@@ -0,0 +1,99 @@
//! First-frame owned layout: the banner stays at the top and the normal composer at the bottom.
//! Measurement, paint, and cursor placement use the same bottom rectangle, including both footers.
use crossterm::cursor::SetCursorStyle;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use super::StartupDraftSessionAction;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::CommandPopupPlacement;
use crate::bottom_pane::ComposerRenderOptions;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
pub(super) struct OwnedStartupLayout<'a> {
header: &'a dyn Renderable,
bottom: RenderableItem<'a>,
session_action: StartupDraftSessionAction,
}
impl<'a> OwnedStartupLayout<'a> {
pub(super) fn new(
header: &'a dyn Renderable,
bottom_pane: &'a BottomPane,
session_action: StartupDraftSessionAction,
) -> Self {
Self {
header,
bottom: bottom_pane.as_renderable_with_options(ComposerRenderOptions {
separate_status_line: true,
command_popup_placement: CommandPopupPlacement::Overlay,
..ComposerRenderOptions::default()
}),
session_action,
}
}
pub(super) fn bottom_area(&self, area: Rect) -> Rect {
let height = self.bottom.desired_height(area.width).min(area.height);
Rect {
y: area.bottom().saturating_sub(height),
height,
..area
}
}
}
impl Renderable for OwnedStartupLayout<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
let bottom = self.bottom_area(area);
let header = Rect {
height: self
.header
.desired_height(area.width)
.min(bottom.y.saturating_sub(area.y)),
..area
};
self.header.render(header, buf);
let message = match self.session_action {
StartupDraftSessionAction::New | StartupDraftSessionAction::NewFromCommandCenter => {
None
}
StartupDraftSessionAction::Resume => Some(" Resuming session…"),
StartupDraftSessionAction::Fork => Some(" Forking session…"),
};
if let Some(message) = message
&& header.bottom() < bottom.y
{
message.dim().render(
Rect {
y: header.bottom(),
height: 1,
..area
},
buf,
);
}
self.bottom.render(bottom, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.header
.desired_height(width)
.saturating_add(self.bottom.desired_height(width))
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.bottom.cursor_pos(self.bottom_area(area))
}
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
self.bottom.cursor_style(self.bottom_area(area))
}
}
#[cfg(test)]
#[path = "startup_draft_layout_tests.rs"]
mod tests;
@@ -0,0 +1,38 @@
//! Verify provisional composer geometry while startup owns the terminal.
use super::*;
#[test]
fn owned_startup_keeps_the_live_bottom_geometry() {
let mut pump = crate::startup_draft::tests::quiet_startup_test_pump();
pump.bottom_pane.set_status_line_enabled(/*enabled*/ true);
pump.bottom_pane
.set_composer_text("first line\nsecond line".into(), Vec::new(), Vec::new());
pump.bottom_pane.set_footer_hint_override(Some(vec![
("Waiting for startup".into(), String::new()),
("esc".into(), "cancel".into()),
]));
let layout = OwnedStartupLayout::new(
&pump.header,
&pump.bottom_pane,
StartupDraftSessionAction::New,
);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 16,
);
let mut buffer = Buffer::empty(area);
layout.render(area, &mut buffer);
let frame = (area.y..area.bottom())
.map(|y| {
(area.x..area.right())
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(
"owned_startup_layout",
format!("cursor={:?}\n{frame}", layout.cursor_pos(area))
.replace(crate::version::CODEX_CLI_VERSION, "<VERSION>")
);
}
@@ -0,0 +1,160 @@
//! Verify confirmation follows the visible draft and its selected destination.
use super::*;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn startup_enter_confirms_one_visible_draft_and_escape_cancels() {
let mut pump = tests::quiet_startup_test_pump();
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
pump.bottom_pane.insert_str("keep this startup draft");
let draft = pump.bottom_pane.composer_draft_snapshot();
for _ in 0..3 {
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
.expect("confirm startup draft");
}
assert!(pump.submission_pending);
assert_eq!(pump.bottom_pane.composer_draft_snapshot(), draft);
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Esc)))
.expect("cancel startup submission");
assert!(!pump.take_submission_intent());
assert_eq!(pump.bottom_pane.composer_draft_snapshot(), draft);
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
.expect("confirm again");
pump.handle_event(&mut tui, TuiEvent::Paste(" with more text".into()))
.expect("edit confirmed startup draft");
assert!(!pump.take_submission_intent());
assert_eq!(
pump.bottom_pane.composer_text(),
"keep this startup draft with more text"
);
let draft = pump.bottom_pane.composer_draft_snapshot();
pump.session_action = StartupDraftSessionAction::NewFromCommandCenter;
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
.expect("command-center startup stays edit-only");
assert!(!pump.take_submission_intent());
assert_eq!(pump.bottom_pane.composer_draft_snapshot(), draft);
pump.bottom_pane
.set_composer_text(String::new(), Vec::new(), Vec::new());
pump.events = Box::pin(futures::stream::iter(['c', 'd'].map(|character| {
TuiEvent::Key(KeyEvent::new(
KeyCode::Char(character),
KeyModifiers::CONTROL,
))
})));
pump.flush_pending_events(&mut tui)
.await
.expect("command-center startup survives cancellation during the final input drain");
}
#[tokio::test]
async fn startup_submission_honors_configured_submit_binding() {
let mut pump = tests::quiet_startup_test_pump();
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
let config = toml::from_str(
"[composer]\nsubmit = \"ctrl-x s\"\n[editor]\ninsert_newline = \"enter\"\nmove_line_start = \"ctrl-x a\"\n"
)
.unwrap();
let keymap = crate::keymap::RuntimeKeymap::from_config(&config).unwrap();
pump.bottom_pane.set_keymap_bindings(&keymap);
pump.key_chords = keymap.chords;
for code in [
KeyCode::Char('a'),
KeyCode::Char('b'),
KeyCode::Enter,
KeyCode::Char('c'),
] {
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(code)))
.expect("rebound Enter inserts one newline after a paste burst");
}
pump.bottom_pane.flush_composer_paste_burst();
assert_eq!(pump.bottom_pane.composer_text(), "ab\nc");
pump.bottom_pane
.set_composer_text("draft".into(), Vec::new(), Vec::new());
for key in [
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
KeyEvent::from(KeyCode::Char('a')),
] {
pump.handle_event(&mut tui, TuiEvent::Key(key))
.expect("configured editor chord");
}
assert_eq!(pump.bottom_pane.composer_cursor(), 0);
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
.expect("Enter does not submit");
assert!(!pump.submission_pending);
let draft = pump.bottom_pane.composer_draft_snapshot();
for key in [
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
KeyEvent::from(KeyCode::Char('s')),
] {
pump.handle_event(&mut tui, TuiEvent::Key(key))
.expect("configured submit");
}
assert!(pump.take_submission_intent());
assert_eq!(pump.into_draft(), draft);
}
#[tokio::test]
async fn startup_submission_accepts_enter_after_paste_ambiguity_expires() {
let mut pump = tests::quiet_startup_test_pump();
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
pump.bottom_pane.insert_str("pasted draft");
pump.pending_paste_newline = Some((
Instant::now() - STARTUP_PASTE_NEWLINE_TIMEOUT - Duration::from_millis(/*millis*/ 1),
"\n".into(),
));
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
.expect("confirm after the ambiguous paste has finished");
assert!(pump.take_submission_intent());
assert_eq!(pump.into_draft().text, "pasted draft");
}
#[tokio::test]
async fn startup_submission_redirect_to_agents_preserves_only_editable_text() {
for selection in [SessionSelection::AgentsOverview, SessionSelection::Exit] {
let mut pump = tests::quiet_startup_test_pump();
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
pump.update_session_selection(&mut tui, &SessionSelection::StartFresh)
.expect("resolve initial destination");
pump.bottom_pane
.insert_str("keep this draft in its original context");
let draft = pump.bottom_pane.composer_draft_snapshot();
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
.expect("confirm startup draft");
pump.update_session_selection(&mut tui, &selection)
.expect("redirect away from startup destination");
assert!(!pump.take_submission_intent());
assert_eq!(pump.into_draft(), draft);
}
}
#[tokio::test]
async fn startup_submission_thread_change_preserves_only_editable_text() {
let mut pump = tests::quiet_startup_test_pump();
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
for destination in [
codex_protocol::ThreadId::new(),
codex_protocol::ThreadId::new(),
] {
let selection = SessionSelection::Resume(crate::resume_picker::SessionTarget {
path: None,
thread_id: destination,
cwd: None,
history_mode: None,
});
pump.update_session_selection(&mut tui, &selection)
.expect("select startup destination");
assert!(!pump.submission_pending);
if pump.bottom_pane.composer_is_empty() {
pump.bottom_pane.insert_str("retained draft");
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
.expect("confirm first destination");
assert!(pump.submission_pending);
}
}
assert!(!pump.take_submission_intent());
assert_eq!(pump.into_draft().text, "retained draft");
}
+29 -6
View File
@@ -38,7 +38,12 @@ where
app_event_rx: rx,
initial_screen: StartupDraftInitialScreen::Composer,
session_action: StartupDraftSessionAction::New,
resolved_selection: None,
configured_cwd: None,
pending_paste_newline: None,
submission_pending: false,
key_chord_matcher: Default::default(),
key_chords: crate::keymap::RuntimeKeymap::defaults().chords,
}
}
@@ -180,7 +185,7 @@ async fn startup_draft_clears_loading_status_when_starting_fresh() {
pump.bottom_pane.insert_str("draft while loading");
}
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
pump.show_initial_screen(&mut tui)
pump.redraw_if_visible(&mut tui)
.expect("respect the initial composer or picker screen");
let before = if tui.terminal.viewport_area.is_empty() {
"hidden while picker owns input".to_string()
@@ -374,6 +379,7 @@ async fn startup_draft_preserves_non_bracketed_multiline_pastes_without_submitti
pump.flush_pending_events(&mut tui)
.await
.expect("preserve multiline non-bracketed paste");
assert!(!pump.submission_pending);
assert_eq!(
pump.bottom_pane.composer_text(),
@@ -504,8 +510,15 @@ fn startup_draft_allows_local_editor_shortcuts_without_startup_actions() {
.expect("use a configured editor movement");
assert_eq!(pump.bottom_pane.composer_cursor(), 0);
for key in [
handle_startup_draft_key(
&mut pump.bottom_pane,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
)
.expect("honor Enter rebound to local newline editing");
assert_eq!(pump.bottom_pane.composer_cursor(), 1);
assert_eq!(pump.bottom_pane.composer_text(), "\nfirst ");
for key in [
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('\u{16}'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
@@ -528,8 +541,8 @@ fn startup_draft_allows_local_editor_shortcuts_without_startup_actions() {
)
.expect("ignore configured plain composer actions");
}
assert_eq!(pump.bottom_pane.composer_cursor(), 0);
assert_eq!(pump.bottom_pane.composer_text(), "first ");
assert_eq!(pump.bottom_pane.composer_cursor(), 1);
assert_eq!(pump.bottom_pane.composer_text(), "\nfirst ");
assert!(pump.app_event_rx.try_recv().is_err());
}
@@ -626,6 +639,16 @@ async fn startup_draft_applies_editor_keymap_without_enabling_vim() {
)
.expect("honor a configured safe editor shortcut");
assert_eq!(pump.bottom_pane.composer_cursor(), 0);
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
pump.handle_event(&mut tui, TuiEvent::Key(KeyEvent::from(KeyCode::Enter)))
.expect("confirm draft");
pump.apply_config(&config);
assert!(pump.submission_pending);
config.tui_keymap.composer.submit =
Some(codex_config::types::KeybindingsSpec::Many(Vec::new()));
pump.apply_config(&config);
assert!(!pump.submission_pending);
assert_eq!(pump.bottom_pane.composer_text(), "draftx");
}
#[tokio::test]
@@ -634,7 +657,7 @@ async fn startup_draft_waits_for_onboarding_before_accepting_input() {
let mut composer_tui =
crate::tui::test_support::make_test_tui().expect("create composer test terminal");
composer_pump
.show_initial_screen(&mut composer_tui)
.redraw_if_visible(&mut composer_tui)
.expect("draw the composer when no protected screen is expected");
assert!(!composer_tui.terminal.viewport_area.is_empty());
drop(composer_tui);
@@ -669,7 +692,7 @@ async fn startup_draft_waits_for_onboarding_before_accepting_input() {
StartupDraftInitialScreen::Composer
};
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
pump.show_initial_screen(&mut tui)
pump.redraw_if_visible(&mut tui)
.expect("keep the composer hidden until onboarding finishes");
pump.flush_pending_events(&mut tui)
+75 -44
View File
@@ -208,7 +208,79 @@ pub(super) async fn run_main_inner(
} else {
startup_draft::StartupDraftSessionAction::New
};
let mut startup_draft = startup_draft::StartupDraft::new(initial_screen, session_action)?;
// Local-daemon discovery does not change client config precedence. Resolve explicit
// remote selection and the environment without opening a server connection.
let presentation_target = app_server_target_for_launch(
explicit_remote_endpoint.clone(),
/*default_daemon_socket*/ None,
reuse_implicit_local_daemon,
workload_identity_selected,
std::env::var_os(codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR).as_deref(),
)?;
let prepared_environment_manager =
if should_load_configured_environments(&launch_loader_overrides, &presentation_target) {
EnvironmentManager::prepare_from_codex_home(&codex_home).await
} else {
EnvironmentManager::prepare_from_env().await
}
.map_err(std::io::Error::other)?;
if cli.shared.worktree
&& (presentation_target.uses_remote_workspace()
|| prepared_environment_manager.default_environment_is_remote())
{
return Err(std::io::Error::other(
"`--worktree` is only supported for local sessions",
));
}
let cwd = cli.cwd.clone();
let config_cwd = config_cwd_for_app_server_target(
cwd.as_deref(),
&presentation_target,
prepared_environment_manager.default_environment_is_remote(),
)?;
// Reuse the profile path resolved above; do not reconstruct profile precedence here.
let mut loader_overrides = launch_loader_overrides;
loader_overrides.ignore_login_requirements = presentation_target.uses_remote_workspace();
let presentation = startup_presentation::load(
&cli,
&codex_home,
loader_overrides.clone(),
cli_kv_overrides.clone(),
config_cwd,
)
.await
.map_err(|err| {
if let Some(config_error) = err
.get_ref()
.and_then(|inner| inner.downcast_ref::<ConfigLoadError>())
{
std::io::Error::other(format!(
"Error loading config.toml:\n{}",
format_config_error_with_source(config_error.config_error())
))
} else {
err
}
})?;
// Keep normal terminal signals available until local configuration is ready. Once raw
// mode begins, StartupDraft immediately takes ownership of input.
let (initialized_terminal, terminal_restore_guard) = tokio::task::spawn_blocking(|| {
tui::init().map(|terminal| (terminal, TerminalRestoreGuard::new()))
})
.await
.map_err(std::io::Error::other)??;
let startup_presentation::StartupPresentation {
bootstrap_config,
config_cwd,
screen,
} = presentation;
let mut startup_draft = startup_draft::StartupDraft::new(
initialized_terminal,
terminal_restore_guard,
initial_screen,
session_action,
screen,
)?;
let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon {
startup_draft
@@ -233,49 +305,8 @@ pub(super) async fn run_main_inner(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?;
let prepared_environment_manager =
if should_load_configured_environments(&loader_overrides, &app_server_target) {
startup_draft
.run_until(EnvironmentManager::prepare_from_codex_home(&codex_home))
.await?
} else {
startup_draft
.run_until(EnvironmentManager::prepare_from_env())
.await?
}
.map_err(std::io::Error::other)?;
if cli.shared.worktree
&& (app_server_target.uses_remote_workspace()
|| prepared_environment_manager.default_environment_is_remote())
{
return Err(std::io::Error::other(
"`--worktree` is only supported for local sessions",
));
}
let cwd = cli.cwd.clone();
let config_cwd = config_cwd_for_app_server_target(
cwd.as_deref(),
&app_server_target,
prepared_environment_manager.default_environment_is_remote(),
)?;
let mut loader_overrides = loader_overrides;
if let Some(profile_v2) = cli.config_profile_v2.as_ref() {
let user_config_path = resolve_profile_v2_config_path(&codex_home, profile_v2);
loader_overrides.user_config_path = Some(user_config_path);
loader_overrides.user_config_profile = Some(profile_v2.clone());
}
loader_overrides.ignore_login_requirements = app_server_target.uses_remote_workspace();
let bootstrap_config = startup_draft
.run_until(load_bootstrap_config_or_exit(
&codex_home,
config_cwd.as_ref(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
strict_config,
CloudConfigBundleLoader::default(),
))
.await?;
// The pre-paint bootstrap used these same local/remote config inputs. Reuse it here;
// implicit daemon discovery above changes transport, not the client configuration cwd.
let screen_reader_result = if !loader_overrides.ignore_user_config {
startup_draft
.run_until(screen_reader::initialize(
+115
View File
@@ -0,0 +1,115 @@
//! Resolve first-frame screen ownership from the existing client configuration loader.
//!
//! The caller supplies its resolved profile overrides and configuration cwd, so this path does
//! not introduce separate profile or project precedence. Loading excludes cloud fetches and
//! returns the bootstrap result for reuse by startup. Final cloud or selected-project settings
//! may update screen policy before handing the terminal to the conversation.
use std::io;
use std::path::Path;
use codex_config::CloudConfigBundleLoader;
use codex_config::ConfigLoadOptions;
use codex_config::LoaderOverrides;
use codex_config::TomlValue;
use codex_features::Feature;
use codex_features::FeatureConfigSource;
use codex_features::FeatureOverrides;
use codex_features::Features;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::Cli;
use crate::keymap::RuntimeKeymap;
use crate::legacy_core::config::ConfigTomlLoadResult;
use crate::legacy_core::config::ManagedFeatures;
use crate::legacy_core::config::load_config_toml_with_layer_stack;
use crate::startup_draft::StartupScreen;
/// Screen policy and the already-loaded bootstrap configuration for this exact loading context.
pub(super) struct StartupPresentation {
pub(super) bootstrap_config: ConfigTomlLoadResult,
pub(super) config_cwd: Option<AbsolutePathBuf>,
pub(super) screen: StartupScreen,
}
/// Load client presentation settings without session setup or remote app-server connections.
///
/// `loader_overrides` must already contain the selected profile and remote login-policy choice.
/// `config_cwd` must come from the existing target/environment-aware cwd resolver. Reuse the
/// returned bootstrap only when these loading inputs still match the later startup context.
pub(super) async fn load(
cli: &Cli,
codex_home: &Path,
loader_overrides: LoaderOverrides,
cli_kv_overrides: Vec<(String, TomlValue)>,
config_cwd: Option<AbsolutePathBuf>,
) -> io::Result<StartupPresentation> {
let bootstrap_config = load_config_toml_with_layer_stack(
codex_home,
config_cwd.as_ref(),
cli_kv_overrides,
ConfigLoadOptions {
loader_overrides,
strict_config: cli.strict_config,
cloud_config_bundle: CloudConfigBundleLoader::default(),
},
)
.await?;
let config_toml = &bootstrap_config.config_toml;
let configured_features = Features::from_sources(
FeatureConfigSource {
features: config_toml.features.as_ref(),
experimental_use_unified_exec_tool: config_toml.experimental_use_unified_exec_tool,
},
FeatureConfigSource::default(),
FeatureOverrides::default(),
);
// Full configuration construction will publish its normal managed-feature warnings once.
let mut startup_warnings = Vec::new();
let features = ManagedFeatures::from_configured_with_warnings(
configured_features,
bootstrap_config
.config_layer_stack
.requirements()
.feature_requirements
.clone(),
&mut startup_warnings,
)?;
let alternate_screen = config_toml
.tui
.as_ref()
.map(|tui| tui.alternate_screen)
.unwrap_or_default();
let use_alt_screen = crate::determine_alt_screen_mode(cli.no_alt_screen, alternate_screen);
let transcript_mode = crate::transcript_mode::TranscriptMode::resolve(
features.enabled(Feature::TranscriptV2),
use_alt_screen,
);
let status_line_enabled = config_toml
.tui
.as_ref()
.and_then(|tui| tui.status_line.as_ref())
.is_none_or(|items| !items.is_empty());
let default_tui_settings = codex_config::types::Tui::default();
let tui_settings = config_toml.tui.as_ref().unwrap_or(&default_tui_settings);
let keymap = RuntimeKeymap::from_config(&tui_settings.keymap).map_err(io::Error::other)?;
let disable_paste_burst = tui_settings
.disable_paste_burst
.or(config_toml.disable_paste_burst)
.unwrap_or(/*default*/ false);
Ok(StartupPresentation {
bootstrap_config,
config_cwd,
screen: StartupScreen {
use_alt_screen,
transcript_mode,
status_line_enabled,
keymap,
disable_paste_burst,
},
})
}
#[cfg(test)]
#[path = "startup_presentation_tests.rs"]
mod tests;
@@ -0,0 +1,97 @@
//! Check first-frame editor and screen settings using the real client configuration loader.
use super::*;
use clap::Parser;
use crossterm::event::KeyCode;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn selected_profile_controls_submit_before_the_first_frame() -> anyhow::Result<()> {
let home = tempfile::tempdir()?;
std::fs::write(
home.path().join("config.toml"),
"disable_paste_burst = false\n",
)?;
let selected = AbsolutePathBuf::from_absolute_path(home.path().join("work.config.toml"))?;
let cli = Cli::try_parse_from(["codex"])?;
for (submit, expected_bindings) in [
("[]", Vec::new()),
("\"f12\"", vec![crate::key_hint::plain(KeyCode::F(12))]),
] {
std::fs::write(
&selected,
format!("disable_paste_burst = true\n[tui.keymap.composer]\nsubmit = {submit}\n"),
)?;
let presentation = load(
&cli,
home.path(),
LoaderOverrides {
user_config_path: Some(selected.clone()),
user_config_profile: Some("work".parse()?),
ignore_project_config: true,
..LoaderOverrides::without_managed_config_for_tests()
},
Vec::new(),
/*config_cwd*/ None,
)
.await?;
assert_eq!(
(
presentation.screen.keymap.composer.submit,
presentation.screen.disable_paste_burst,
),
(expected_bindings, true),
);
}
Ok(())
}
#[tokio::test]
async fn first_frame_respects_screen_and_status_line_overrides() -> anyhow::Result<()> {
let home = tempfile::tempdir()?;
for (config, args, expected) in [
(
"[features]\ntranscript_v2 = true\n",
vec!["codex"],
(true, true, true),
),
(
"[features]\ntranscript_v2 = true\n",
vec!["codex", "--no-alt-screen"],
(false, false, true),
),
(
"[features]\ntranscript_v2 = false\n",
vec!["codex"],
(true, false, true),
),
(
"[tui]\nalternate_screen = \"never\"\nstatus_line = []\n",
vec!["codex"],
(false, false, false),
),
] {
std::fs::write(home.path().join("config.toml"), config)?;
let cli = Cli::try_parse_from(args)?;
let presentation = load(
&cli,
home.path(),
LoaderOverrides {
ignore_project_config: true,
..LoaderOverrides::without_managed_config_for_tests()
},
Vec::new(),
/*config_cwd*/ None,
)
.await?;
assert_eq!(
(
presentation.screen.use_alt_screen,
presentation.screen.transcript_mode.is_owned(),
presentation.screen.status_line_enabled
),
expected,
);
}
Ok(())
}
+179
View File
@@ -0,0 +1,179 @@
//! Retain one startup draft in memory until the intended session accepts the handoff.
//!
//! Recovery belongs to the startup task, never a process-global file, log, or session. After a
//! startup error the caller restores the terminal before showing the unsent text. Printing takes
//! the snapshot, so nested fatal/error paths cannot print it twice.
use std::cell::RefCell;
use std::future::Future;
use std::io::Write;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::ComposerDraftSnapshot;
use crate::bottom_pane::InputResult;
tokio::task_local! {
static DRAFT: RefCell<Option<RecoveryDraft>>;
}
struct RecoveryDraft {
snapshot: ComposerDraftSnapshot,
stage: RecoveryStage,
}
enum RecoveryStage {
Startup,
HandedOff,
Submitted,
Dispatched(String),
}
/// Keep draft recovery isolated to this startup, including futures polled during initialization.
pub(crate) async fn scope<F: Future>(future: F) -> F::Output {
DRAFT.scope(RefCell::new(/*value*/ None), future).await
}
/// Replace the single recovery copy after input changes or before handing the draft off.
pub(crate) fn remember(snapshot: ComposerDraftSnapshot) {
let _ = DRAFT.try_with(|draft| {
*draft.borrow_mut() = Some(RecoveryDraft {
snapshot,
stage: RecoveryStage::Startup,
});
});
}
/// Track the merged destination only once startup has transferred its draft into ChatWidget.
pub(crate) fn handed_off(snapshot: impl FnOnce() -> ComposerDraftSnapshot) {
let _ = DRAFT.try_with(|draft| {
if let Some(draft) = draft.borrow_mut().as_mut() {
draft.snapshot = snapshot();
draft.stage = RecoveryStage::HandedOff;
}
});
}
/// Refresh pending recovery after local edits without retaining any post-startup input.
pub(crate) fn refresh_after_handoff(snapshot: impl FnOnce() -> ComposerDraftSnapshot) {
let _ = DRAFT.try_with(|draft| {
if let Some(draft) = draft.borrow_mut().as_mut()
&& matches!(draft.stage, RecoveryStage::HandedOff)
{
draft.snapshot = snapshot();
}
});
}
/// Retain actual submission output, including burst text flushed while consuming the composer.
pub(crate) fn submitted(result: &InputResult) {
let submission = match result {
InputResult::Submitted {
text,
text_elements,
} => Some((text, text_elements, &[][..])),
InputResult::Queued {
text,
text_elements,
pending_pastes,
..
} => Some((text, text_elements, pending_pastes.as_slice())),
InputResult::CommandWithArgs(crate::slash_command::SlashCommand::Plan, ..) => None,
InputResult::None
| InputResult::ParentOwnedInputBlocked
| InputResult::Command(_)
| InputResult::ServiceTierCommand(_)
| InputResult::CommandWithArgs(..) => return,
};
let _ = DRAFT.try_with(|draft| {
if let Some(draft) = draft.borrow_mut().as_mut()
&& matches!(draft.stage, RecoveryStage::HandedOff)
{
if let Some((text, text_elements, pending_pastes)) = submission {
draft.snapshot.text.clone_from(text);
draft.snapshot.text_elements.clone_from(text_elements);
draft.snapshot.pending_pastes = pending_pastes.to_vec();
draft.snapshot.cursor = text.len();
}
// Later editor input is a different draft; preserve this one until acknowledgment.
draft.stage = RecoveryStage::Submitted;
}
});
}
/// Associate the recovered submission with its actual queued request, not an earlier CLI prompt.
pub(crate) fn bind_submission(text: &str, client_id: &str) {
let _ = DRAFT.try_with(|draft| {
if let Some(draft) = draft.borrow_mut().as_mut()
&& matches!(draft.stage, RecoveryStage::Submitted)
{
let expanded = ChatComposer::expand_pending_pastes(
&draft.snapshot.text,
draft.snapshot.text_elements.clone(),
&draft.snapshot.pending_pastes,
)
.0;
// /plan submits its arguments while recovery retains the original command.
if expanded == text
|| crate::bottom_pane::prompt_args::parse_slash_name(&expanded)
.is_some_and(|(name, args, _)| name == "plan" && args.trim() == text)
{
draft.stage = RecoveryStage::Dispatched(client_id.to_owned());
}
}
});
}
/// Only acceptance of the recovered message releases its unsent copy.
pub(crate) fn acknowledged(client_id: &str) {
let _ = DRAFT.try_with(|draft| {
let mut draft = draft.borrow_mut();
if draft.as_ref().is_some_and(
|draft| matches!(&draft.stage, RecoveryStage::Dispatched(id) if id == client_id),
) {
draft.take();
}
});
}
/// Forget recovery once startup safely transfers the draft or the user cancels the session.
pub(crate) fn clear() {
let _ = DRAFT.try_with(|draft| {
draft.borrow_mut().take();
});
}
fn take_unsent_text() -> Option<String> {
let snapshot = DRAFT
.try_with(|draft| draft.borrow_mut().take())
.ok()??
.snapshot;
let (text, _) = ChatComposer::expand_pending_pastes(
&snapshot.text,
snapshot.text_elements,
&snapshot.pending_pastes,
);
if text.is_empty() {
return None;
}
// Keep the typed content recoverable without letting pasted control bytes change the terminal.
let mut visible = String::with_capacity(text.len());
for character in text.chars() {
if character.is_control() && !matches!(character, '\n' | '\t') {
visible.extend(character.escape_default());
} else {
visible.push(character);
}
}
Some(visible)
}
/// Display unsent text only after the caller has restored normal terminal modes.
pub(crate) fn print_unsent_draft() {
if let Some(text) = take_unsent_text() {
let _ = writeln!(std::io::stderr().lock(), "\nUnsent draft:\n{text}\n");
}
}
#[cfg(test)]
#[path = "startup_recovery_tests.rs"]
mod tests;
+168
View File
@@ -0,0 +1,168 @@
//! Verify unsent startup text survives edits, buffered pastes, and handoff.
use super::*;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use pretty_assertions::assert_eq;
fn snapshot(text: &str) -> ComposerDraftSnapshot {
ComposerDraftSnapshot {
text: text.to_string(),
cursor: text.len(),
text_elements: Vec::new(),
local_images: Vec::new(),
remote_image_urls: Vec::new(),
mention_bindings: Vec::new(),
pending_pastes: Vec::new(),
startup_local_history: Vec::new(),
last_composer_activity_at: None,
sparkle_draft: Default::default(),
}
}
#[tokio::test]
async fn recovery_keeps_edits_and_pastes_after_chatwidget_handoff() {
for destination in ["", "existing draft\n"] {
scope(async {
let (mut chat, _sender, _rx, _op_rx) =
crate::chatwidget::tests::make_chatwidget_manual_with_sender().await;
let draft = snapshot("startup draft");
remember(draft.clone());
chat.handle_paste(destination.trim_end().to_string());
let mut pending = Some(draft);
let mut confirmed = true;
chat.restore_startup_input_when_ready(&mut pending, &mut confirmed);
assert!(pending.is_none());
chat.handle_key_event(KeyEvent::from(KeyCode::Backspace));
let pasted = format!(" edited {}", "x".repeat(/*n*/ 1100));
chat.handle_paste(pasted.clone());
// This is the exact expanded text printed if startup fails before session readiness.
assert_eq!(
take_unsent_text(),
Some(format!("{destination}startup draf{pasted}")),
);
})
.await;
}
}
#[tokio::test]
async fn recovery_refresh_stops_after_submission_or_startup_completion() {
scope(async {
remember(snapshot("before handoff"));
refresh_after_handoff(|| panic!("protected input must not replace the startup draft"));
handed_off(|| snapshot("confirmed submission"));
bind_submission("confirmed submission", "initial-prompt");
acknowledged("initial-prompt");
submitted(&InputResult::Submitted {
text: "confirmed submission".to_string(),
text_elements: Vec::new(),
});
bind_submission("other message", "unrelated");
bind_submission("confirmed submission", "recovered");
bind_submission("confirmed submission", "later-identical-message");
acknowledged("initial-prompt");
acknowledged("later-identical-message");
refresh_after_handoff(|| panic!("new edits must not replace a submitted draft"));
assert_eq!(take_unsent_text(), Some("confirmed submission".to_string()));
remember(snapshot("editable draft"));
handed_off(|| snapshot("merged draft"));
submitted(&InputResult::Submitted {
text: "merged draft".into(),
text_elements: Vec::new(),
});
bind_submission("merged draft", "accepted");
acknowledged("accepted");
assert_eq!(take_unsent_text(), None);
for submission in [
InputResult::CommandWithArgs(
crate::slash_command::SlashCommand::Plan,
"make a plan".into(),
Vec::new(),
),
InputResult::Queued {
text: "/plan make a plan".into(),
text_elements: Vec::new(),
action: crate::bottom_pane::QueuedInputAction::ParseSlash,
pending_pastes: Vec::new(),
},
] {
remember(snapshot("/plan make a plan"));
handed_off(|| snapshot("/plan make a plan"));
submitted(&submission);
bind_submission("make a plan", "plan-request");
acknowledged("plan-request");
assert_eq!(take_unsent_text(), None);
}
handed_off(|| panic!("completed startup must not retain input"));
refresh_after_handoff(|| panic!("completed startup must not copy edits"));
assert_eq!(take_unsent_text(), None);
})
.await;
}
#[tokio::test]
async fn recovery_keeps_buffered_input_on_exit_or_queued_submission() {
for queue in [false, true] {
scope(async {
let (mut chat, _sender, _rx, _op_rx) =
crate::chatwidget::tests::make_chatwidget_manual_with_sender().await;
let draft = snapshot("startup draft");
remember(draft.clone());
let mut pending = Some(draft);
let mut confirmed = false;
chat.restore_startup_input_when_ready(&mut pending, &mut confirmed);
chat.set_queue_submissions_until_session_configured(queue);
// Rapid raw keys remain buffered outside the textarea until the burst ends.
chat.handle_key_event(KeyEvent::from(KeyCode::Char('x')));
chat.handle_key_event(KeyEvent::from(KeyCode::Char('y')));
assert_eq!(chat.composer_text_with_pending(), "startup draft");
if !queue {
// A fatal exit must include keys still waiting for the paste timer.
assert_eq!(take_unsent_text(), Some("startup draftxy".to_string()));
return;
}
// Queueing must capture the buffer without waiting for a draw/tick flush.
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(
chat.queued_user_message_texts(),
vec!["startup draftxy".to_string()]
);
assert_eq!(take_unsent_text(), Some("startup draftxy".to_string()));
})
.await;
}
}
#[tokio::test]
async fn recovery_uses_pending_pastes_from_the_actual_queued_result() {
scope(async {
remember(snapshot("before burst"));
handed_off(|| snapshot("before burst"));
let placeholder = "[Pasted text]";
submitted(&InputResult::Queued {
text: placeholder.to_string(),
text_elements: vec![TextElement::new(
ByteRange {
start: 0,
end: placeholder.len(),
},
Some(placeholder.to_string()),
)],
action: crate::bottom_pane::QueuedInputAction::Literal,
pending_pastes: vec![(
placeholder.to_string(),
"complete queued paste\n\twith control\u{1b}[31m".to_string(),
)],
});
assert_eq!(
take_unsent_text(),
Some("complete queued paste\n\twith control\\u{1b}[31m".to_string())
);
assert_eq!(take_unsent_text(), None);
})
.await;
}
+11
View File
@@ -200,9 +200,20 @@ async fn startup_typeahead_pty_child() {
return;
};
let signals = PathBuf::from(signals);
let initialized_terminal = crate::tui::init().expect("initialize startup terminal");
let terminal_restore_guard = crate::TerminalRestoreGuard::new();
let mut startup_draft = crate::startup_draft::StartupDraft::new(
initialized_terminal,
terminal_restore_guard,
crate::startup_draft::StartupDraftInitialScreen::Composer,
crate::startup_draft::StartupDraftSessionAction::New,
crate::startup_draft::StartupScreen {
use_alt_screen: false,
transcript_mode: crate::transcript_mode::TranscriptMode::Terminal,
status_line_enabled: true,
keymap: crate::keymap::RuntimeKeymap::defaults(),
disable_paste_burst: false,
},
)
.expect("initialize the real startup composer");
+11 -4
View File
@@ -20,16 +20,22 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
// macOS's default temporary directory leaves too little room for the control socket path.
let codex_home = tempfile::tempdir_in("/tmp")?;
write_test_config(codex_home.path(), &repo_root)?;
let config_path = codex_home.path().join("config.toml");
let config = std::fs::read_to_string(&config_path)?;
std::fs::write(
config_path,
format!("{config}\n[tui]\nstatus_line = [\"thread-id\"]\n"),
)?;
let socket = codex_app_server_client::app_server_control_socket_path(codex_home.path())?;
std::fs::create_dir_all(socket.parent().unwrap())?;
let listener = UnixListener::bind(socket.as_path())?;
let (disconnect_tx, mut disconnect_rx) = tokio::sync::oneshot::channel();
let (restore_tx, restore_rx) = tokio::sync::oneshot::channel();
let server_cwd = repo_root.clone();
let id = "00000000-0000-0000-0000-000000000001";
let server = tokio::spawn(async move {
let mut methods = Vec::new();
let mut restore_rx = Some(restore_rx);
let id = "00000000-0000-0000-0000-000000000001";
let thread = json!({
"id": id, "sessionId": id, "preview": "", "ephemeral": false,
"modelProvider": "openai", "createdAt": 1, "updatedAt": 2,
@@ -91,7 +97,8 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
}
"model/list" => json!({"data": [], "nextCursor": null}),
"config/read" => {
json!({"config": {"model": "gpt-5.6-terra", "model_provider": "openai", "projects": {
json!({"config": {"model": "gpt-5.6-terra", "model_provider": "openai",
"tui": {"status_line": ["thread-id"]}, "projects": {
server_cwd.to_string_lossy(): {"trust_level": "trusted"}
}}, "origins": {}, "layers": []})
}
@@ -149,7 +156,7 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
let mut disconnect_tx = Some(disconnect_tx);
let mut restore_tx = Some(restore_tx);
for expected in [
"gpt-5.6-terra",
id, // The model label does not establish that the client has attached a thread.
"preserved-draft",
"Reconnecting",
"preserved-draft!",
@@ -165,7 +172,7 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
terminal.screen_contents()
);
match expected {
"gpt-5.6-terra" => terminal.write_input(b"preserved-draft")?,
ready if ready == id => terminal.write_input(b"preserved-draft")?,
"preserved-draft" => {
disconnect_tx.take().unwrap().send(()).unwrap();
}