Protect restored startup drafts at submission

This commit is contained in:
Charlie Marsh
2026-07-05 18:41:42 -04:00
parent 32cff93387
commit 30db592c7a
14 changed files with 180 additions and 100 deletions
+10 -7
View File
@@ -574,7 +574,6 @@ pub(crate) struct App {
primary_thread_id: Option<ThreadId>,
last_subagent_backfill_attempt: Option<ThreadId>,
primary_session_configured: Option<ThreadSessionState>,
startup_draft_protected: bool,
pending_primary_events: VecDeque<ThreadBufferedEvent>,
pending_app_server_requests: PendingAppServerRequests,
pending_startup_thread_start: bool,
@@ -1065,7 +1064,6 @@ See the Codex keymap documentation for supported actions and examples."
primary_thread_id: None,
last_subagent_backfill_attempt: None,
primary_session_configured: None,
startup_draft_protected: false,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_startup_thread_start,
@@ -1117,11 +1115,16 @@ See the Codex keymap documentation for supported actions and examples."
}
}
if allow_startup_text {
if let Some(startup_text) = tui.take_startup_text()? {
app.chat_widget.insert_str(&startup_text);
}
app.startup_draft_protected = true;
if allow_startup_text && let Some(startup_text) = tui.take_startup_text()? {
let expected_mcp_servers = app
.config
.mcp_servers
.get()
.iter()
.filter_map(|(name, server)| server.enabled.then_some(name.clone()))
.collect::<Vec<_>>();
app.chat_widget
.restore_startup_draft(&startup_text, expected_mcp_servers);
}
let event_stream_started_at = Instant::now();
@@ -41,9 +41,6 @@ impl App {
);
self.refresh_mcp_startup_expected_servers_from_config();
self.chat_widget.finish_mcp_startup_after_lag();
if self.startup_draft_protected {
self.clear_startup_draft_protection_if_mcp_settled();
}
}
AppServerEvent::ServerNotification(notification) => {
self.handle_server_notification_event(app_server_client, notification)
-52
View File
@@ -95,10 +95,6 @@ impl App {
app_server: &mut AppServerSession,
key_event: KeyEvent,
) {
if self.startup_draft_blocks_key(key_event) {
return;
}
// Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless
// enhanced keyboard reporting is available. We only treat those word-motion fallbacks as
// agent-switch shortcuts when the composer is empty so we never steal the expected
@@ -272,26 +268,6 @@ impl App {
&& !self.chat_widget.should_handle_vim_insert_escape(key_event)
}
pub(super) fn startup_draft_blocks_key(&self, key_event: KeyEvent) -> bool {
self.startup_draft_protected
&& (self.primary_session_configured.is_none() || self.chat_widget.mcp_startup_active())
&& matches!(
key_event,
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}
)
}
pub(super) fn clear_startup_draft_protection_if_mcp_settled(&mut self) {
if !self.chat_widget.mcp_startup_active() {
self.startup_draft_protected = false;
}
}
pub(super) fn reject_side_backtrack_esc(&mut self) {
self.reset_backtrack_state();
self.chat_widget
@@ -310,11 +286,6 @@ impl App {
#[cfg(test)]
mod tests {
use super::super::test_support::make_test_app;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyEventState;
use crossterm::event::KeyModifiers;
#[tokio::test]
async fn app_keymap_shortcuts_are_disabled_while_keymap_view_is_active() {
@@ -326,27 +297,4 @@ mod tests {
assert!(!app.app_keymap_shortcuts_available());
}
#[tokio::test]
async fn startup_draft_only_blocks_unmodified_enter_presses() {
let mut app = make_test_app().await;
app.startup_draft_protected = true;
assert!(app.startup_draft_blocks_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
assert!(app.startup_draft_blocks_key(KeyEvent::new_with_kind(
KeyCode::Enter,
KeyModifiers::NONE,
KeyEventKind::Repeat,
)));
assert!(!app.startup_draft_blocks_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)));
assert!(
!app.startup_draft_blocks_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE))
);
assert!(!app.startup_draft_blocks_key(KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
}));
}
}
-1
View File
@@ -61,7 +61,6 @@ pub(super) async fn make_test_app() -> App {
primary_thread_id: None,
last_subagent_backfill_attempt: None,
primary_session_configured: None,
startup_draft_protected: false,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_startup_thread_start: false,
-2
View File
@@ -4098,7 +4098,6 @@ async fn make_test_app() -> App {
primary_thread_id: None,
last_subagent_backfill_attempt: None,
primary_session_configured: None,
startup_draft_protected: false,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_startup_thread_start: false,
@@ -4164,7 +4163,6 @@ async fn make_test_app_with_channels() -> (
primary_thread_id: None,
last_subagent_backfill_attempt: None,
primary_session_configured: None,
startup_draft_protected: false,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_startup_thread_start: false,
+48 -23
View File
@@ -138,20 +138,20 @@ fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() {
}
#[tokio::test]
async fn startup_thread_started_submits_queued_startup_input() {
async fn restored_startup_draft_waits_for_initial_mcp_settlement() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
app.pending_startup_thread_start = true;
app.startup_draft_protected = true;
app.chat_widget
.set_queue_submissions_until_session_configured(/*queue*/ true);
app.chat_widget
.apply_external_edit("queued before startup completes".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
.restore_startup_draft("captured during startup", ["test".to_string()]);
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
app.chat_widget.handle_key_event(enter);
assert!(app.chat_widget.queued_user_message_texts().is_empty());
assert_eq!(
app.chat_widget.queued_user_message_texts(),
vec!["queued before startup completes".to_string()]
app.chat_widget.composer_text_with_pending(),
"captured during startup"
);
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
@@ -169,8 +169,12 @@ async fn startup_thread_started_submits_queued_startup_input() {
)
.await
.expect("startup thread should attach");
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
assert!(!app.startup_draft_blocks_key(enter));
app.chat_widget.handle_key_event(enter);
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"captured during startup"
);
app.chat_widget
.set_mcp_startup_expected_servers(["test".to_string()]);
@@ -183,7 +187,11 @@ async fn startup_thread_started_submits_queued_startup_input() {
failure_reason: None,
}),
));
assert!(app.startup_draft_blocks_key(enter));
app.chat_widget.handle_key_event(enter);
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"captured during startup"
);
app.handle_thread_event_now(ThreadBufferedEvent::Notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
@@ -194,8 +202,18 @@ async fn startup_thread_started_submits_queued_startup_input() {
failure_reason: None,
}),
));
assert!(!app.startup_draft_blocks_key(enter));
assert!(!app.startup_draft_protected);
app.chat_widget.handle_key_event(enter);
match next_user_turn_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "captured during startup".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected restored startup input submission, got {other:?}"),
}
app.handle_thread_event_now(ThreadBufferedEvent::Notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
@@ -206,18 +224,25 @@ async fn startup_thread_started_submits_queued_startup_input() {
failure_reason: None,
}),
));
assert!(!app.startup_draft_blocks_key(enter));
app.chat_widget.insert_str("later round");
app.chat_widget.handle_key_event(enter);
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
}
match next_user_turn_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "queued before startup completes".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued startup input submission, got {other:?}"),
}
#[tokio::test]
async fn startup_without_restored_draft_uses_normal_queued_submission() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.chat_widget
.set_queue_submissions_until_session_configured(/*queue*/ true);
app.chat_widget.insert_str("typed after the first frame");
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
app.chat_widget.queued_user_message_texts(),
vec!["typed after the first frame".to_string()]
);
}
#[tokio::test]
-8
View File
@@ -1465,17 +1465,9 @@ impl App {
);
match event {
ThreadBufferedEvent::Notification(notification) => {
let starts_turn = matches!(&notification, ServerNotification::TurnStarted(_));
let updates_mcp_startup =
matches!(&notification, ServerNotification::McpServerStatusUpdated(_));
self.cache_collab_receiver_threads_for_notification(&notification);
self.chat_widget
.handle_server_notification(notification, /*replay_kind*/ None);
if self.startup_draft_protected && starts_turn {
self.startup_draft_protected = false;
} else if self.startup_draft_protected && updates_mcp_startup {
self.clear_startup_draft_protection_if_mcp_settled();
}
}
ThreadBufferedEvent::Request(request) => {
if self
@@ -361,6 +361,7 @@ pub(crate) struct ChatComposer {
placeholder_text: String,
is_task_running: bool,
queue_submissions: bool,
startup_draft_submission_blocked: bool,
/// Slash-command draft staged for local recall after application-level dispatch.
///
/// This slot is intentionally separate from `ChatComposerHistory` so inline slash commands can
@@ -529,6 +530,7 @@ impl ChatComposer {
placeholder_text,
is_task_running: false,
queue_submissions: false,
startup_draft_submission_blocked: false,
pending_slash_command_history: None,
skills: None,
plugins: None,
@@ -2759,6 +2761,9 @@ impl ChatComposer {
should_queue: bool,
now: Instant,
) -> (InputResult, bool) {
if self.startup_draft_submission_blocked {
return (InputResult::None, false);
}
if should_queue {
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
@@ -3891,6 +3896,10 @@ impl ChatComposer {
self.queue_submissions = queue_submissions;
}
pub(crate) fn set_startup_draft_submission_blocked(&mut self, blocked: bool) {
self.startup_draft_submission_blocked = blocked;
}
pub(crate) fn set_context_window(&mut self, percent: Option<i64>, used_tokens: Option<i64>) {
if self.footer.context_window_percent == percent
&& self.footer.context_window_used_tokens == used_tokens
@@ -8470,6 +8479,46 @@ mod tests {
assert_eq!("explain the change\n", composer.draft.textarea.text());
}
#[test]
fn startup_draft_protection_respects_remapped_submit() {
use crate::key_hint;
use crate::keymap::RuntimeKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer
.draft
.textarea
.set_text_clearing_elements("captured during startup");
composer
.draft
.textarea
.set_cursor(composer.draft.textarea.text().len());
let mut keymap = RuntimeKeymap::defaults();
keymap.composer.submit = vec![key_hint::ctrl(KeyCode::Char('j'))];
composer.set_keymap_bindings(&keymap);
composer.set_startup_draft_submission_blocked(/*blocked*/ true);
let (enter_result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let (submit_result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
assert_eq!(InputResult::None, enter_result);
assert_eq!(InputResult::None, submit_result);
assert_eq!("captured during startup\n", composer.draft.textarea.text());
}
#[test]
fn remapped_queue_does_not_fall_back_to_tab() {
use crate::key_hint;
+32
View File
@@ -1020,6 +1020,10 @@ impl BottomPane {
self.composer.set_queue_submissions(queue_submissions);
}
pub(crate) fn set_startup_draft_submission_blocked(&mut self, blocked: bool) {
self.composer.set_startup_draft_submission_blocked(blocked);
}
/// Hide the status indicator while leaving task-running state untouched.
pub(crate) fn hide_status_indicator(&mut self) {
if self.status.take().is_some() {
@@ -2864,6 +2868,34 @@ mod tests {
assert!(matches!(rx.try_recv(), Ok(AppEvent::OpenApprovalsPopup)));
}
#[test]
fn startup_draft_protection_does_not_block_active_view_enter() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = test_pane(tx);
pane.insert_str("captured during startup");
pane.set_startup_draft_submission_blocked(/*blocked*/ true);
pane.show_selection_view(SelectionViewParams {
title: Some("Confirm".to_string()),
items: vec![SelectionItem {
name: "Continue".to_string(),
actions: vec![Box::new(|tx: &_| {
tx.send(AppEvent::OpenApprovalsPopup);
})],
dismiss_on_select: true,
..Default::default()
}],
..Default::default()
});
let result = pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert!(pane.no_modal_or_popup_active());
assert!(matches!(rx.try_recv(), Ok(AppEvent::OpenApprovalsPopup)));
assert_eq!(pane.composer_text(), "captured during startup");
}
#[test]
fn esc_routes_to_handle_key_event_when_requested() {
#[derive(Default)]
+5
View File
@@ -598,6 +598,11 @@ pub(crate) struct ChatWidget {
mcp_startup_status: Option<HashMap<String, McpStartupStatus>>,
/// Expected MCP servers for the current startup round, seeded from enabled local config.
mcp_startup_expected_servers: Option<HashSet<String>>,
/// Initial MCP servers that must settle before a restored startup draft can be submitted.
///
/// `Some(_)` also records that startup text was actually restored. The set may become empty
/// before session configuration arrives, but protection remains until both conditions settle.
startup_draft_pending_mcp_servers: Option<HashSet<String>>,
/// After startup settles, ignore stale updates until enough notifications confirm a new round.
mcp_startup_ignore_updates_until_next_start: bool,
/// A lag signal for the next round means terminal-only updates are enough to settle it.
@@ -159,6 +159,7 @@ impl ChatWidget {
unified_exec_processes: Vec::new(),
mcp_startup_status: None,
mcp_startup_expected_servers: None,
startup_draft_pending_mcp_servers: None,
mcp_startup_ignore_updates_until_next_start: false,
mcp_startup_allow_terminal_only_next_round: false,
mcp_startup_pending_next_round: HashMap::new(),
+24
View File
@@ -7,6 +7,30 @@
use super::*;
impl ChatWidget {
pub(crate) fn restore_startup_draft<I>(&mut self, text: &str, expected_mcp_servers: I)
where
I: IntoIterator<Item = String>,
{
self.insert_str(text);
self.startup_draft_pending_mcp_servers = Some(expected_mcp_servers.into_iter().collect());
self.bottom_pane
.set_startup_draft_submission_blocked(/*blocked*/ true);
self.finish_startup_draft_protection_if_ready();
}
pub(super) fn finish_startup_draft_protection_if_ready(&mut self) {
let startup_ready = self.is_session_configured()
&& self
.startup_draft_pending_mcp_servers
.as_ref()
.is_some_and(HashSet::is_empty);
if startup_ready {
self.startup_draft_pending_mcp_servers = None;
self.bottom_pane
.set_startup_draft_submission_blocked(/*blocked*/ false);
}
}
pub(super) fn handle_composer_input_result(
&mut self,
input_result: InputResult,
+10 -4
View File
@@ -181,10 +181,6 @@ impl ChatWidget {
self.mcp_startup_expected_servers = Some(server_names.into_iter().collect());
}
pub(crate) fn mcp_startup_active(&self) -> bool {
self.mcp_startup_status.is_some()
}
pub(super) fn finish_mcp_startup(&mut self, failed: Vec<String>, cancelled: Vec<String>) {
if !cancelled.is_empty() {
self.on_warning(format!(
@@ -215,6 +211,10 @@ impl ChatWidget {
}
pub(crate) fn finish_mcp_startup_after_lag(&mut self) {
if let Some(pending) = &mut self.startup_draft_pending_mcp_servers {
pending.clear();
}
self.finish_startup_draft_protection_if_ready();
if self.mcp_startup_ignore_updates_until_next_start {
if self.mcp_startup_pending_next_round.is_empty() {
self.mcp_startup_pending_next_round_saw_starting = false;
@@ -267,6 +267,12 @@ impl ChatWidget {
&mut self,
notification: McpServerStatusUpdatedNotification,
) {
if notification.status != McpServerStartupState::Starting
&& let Some(pending) = &mut self.startup_draft_pending_mcp_servers
{
pending.remove(&notification.name);
}
self.finish_startup_draft_protection_if_ready();
let status = match notification.status {
McpServerStartupState::Starting => McpStartupStatus::Starting,
McpServerStartupState::Ready => McpStartupStatus::Ready,
@@ -20,6 +20,7 @@ impl ChatWidget {
self.session_network_proxy = session.network_proxy.clone();
let previous_thread_id = self.thread_id;
self.thread_id = Some(session.thread_id);
self.finish_startup_draft_protection_if_ready();
self.bottom_pane
.set_queue_submissions(/*queue_submissions*/ false);
if previous_thread_id != self.thread_id {