mirror of
https://github.com/openai/codex.git
synced 2026-05-17 09:43:19 +00:00
fix(tui): keep Ctrl-C stashed drafts after /clear (#21351)
## Why When a user stashes a draft with Ctrl+C, then runs `/clear`, the fresh chat session loses the in-memory composer history that held the stashed draft. Pressing Up after `/clear` can then recall an older submitted prompt instead of the draft the user explicitly saved for later. ## What Changed - Record Ctrl+C-cleared composer text through the existing message history path, so it survives the fresh session created by `/clear`. - Keep `/clear` itself out of local slash-command recall so it does not sit ahead of the stashed draft. - Add regression coverage for the full flow: submit a prompt, stash a later draft with Ctrl+C, run `/clear`, then recall the stashed draft before the older prompt. ## How to Test 1. Start Codex with `just c`. 2. Submit a short prompt such as `ok` and wait for the turn to complete. 3. Type a new draft, press Ctrl+C, then run `/clear`. 4. Press Up and confirm the stashed draft is restored. 5. Press Up again and confirm the older submitted prompt is still reachable after the stashed draft. Targeted tests: - `cargo test -p codex-tui slash_clear_after_ctrl_c_keeps_stashed_draft_recallable` Manual verification: - Reproduced the issue in tmux with `RUST_LOG=trace just c -c log_dir=...`: before the fix, Up after `/clear` recalled the older submitted prompt. - Re-tested the same tmux flow after the fix: Up after `/clear` restored the Ctrl+C-stashed draft.
This commit is contained in:
@@ -2852,11 +2852,11 @@ impl ChatComposer {
|
||||
return None;
|
||||
}
|
||||
if self.reject_slash_command_if_unavailable(cmd) {
|
||||
self.stage_slash_command_history();
|
||||
self.stage_slash_command_history(cmd);
|
||||
self.record_pending_slash_command_history();
|
||||
return Some(InputResult::None);
|
||||
}
|
||||
self.stage_slash_command_history();
|
||||
self.stage_slash_command_history(cmd);
|
||||
self.textarea.set_text_clearing_elements("");
|
||||
self.is_bash_mode = false;
|
||||
Some(InputResult::Command(cmd))
|
||||
@@ -2884,12 +2884,12 @@ impl ChatComposer {
|
||||
return None;
|
||||
}
|
||||
if self.reject_slash_command_if_unavailable(cmd) {
|
||||
self.stage_slash_command_history();
|
||||
self.stage_slash_command_history(cmd);
|
||||
self.record_pending_slash_command_history();
|
||||
return Some(InputResult::None);
|
||||
}
|
||||
|
||||
self.stage_slash_command_history();
|
||||
self.stage_slash_command_history(cmd);
|
||||
|
||||
let mut args_elements =
|
||||
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
|
||||
@@ -2965,7 +2965,10 @@ impl ChatComposer {
|
||||
/// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget`
|
||||
/// commits the staged entry after dispatch so command recall follows the submitted text, not
|
||||
/// the command outcome.
|
||||
fn stage_slash_command_history(&mut self) {
|
||||
fn stage_slash_command_history(&mut self, cmd: SlashCommand) {
|
||||
if cmd == SlashCommand::Clear {
|
||||
return;
|
||||
}
|
||||
self.stage_slash_command_history_text(self.textarea.text().trim().to_string());
|
||||
}
|
||||
|
||||
@@ -2974,6 +2977,9 @@ impl ChatComposer {
|
||||
/// Popup filtering text can be partial, so recording the selected command avoids recalling
|
||||
/// `/di` after the user actually accepted `/diff`.
|
||||
fn stage_selected_slash_command_history(&mut self, cmd: SlashCommand) {
|
||||
if cmd == SlashCommand::Clear {
|
||||
return;
|
||||
}
|
||||
self.stage_slash_command_history_text(format!("/{}", cmd.command()));
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app::app_server_requests::ResolvedAppServerRequest;
|
||||
use crate::app_command::AppCommand;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::pending_input_preview::PendingInputPreview;
|
||||
@@ -777,7 +779,10 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
pub(crate) fn clear_composer_for_ctrl_c(&mut self) {
|
||||
self.composer.clear_for_ctrl_c();
|
||||
if let Some(text) = self.composer.clear_for_ctrl_c() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::CodexOp(AppCommand::add_to_history(text)));
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
|
||||
@@ -1581,6 +1581,34 @@ async fn slash_clear_requests_ui_clear_when_idle() {
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_clear_after_ctrl_c_keeps_stashed_draft_recallable() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
submit_composer_text(&mut chat, "ok");
|
||||
|
||||
let stashed_draft = "explain why history recall lost this draft";
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text(stashed_draft.to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "");
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::CodexOp(Op::AddToHistory { text })) if text == stashed_draft
|
||||
);
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/clear".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert_eq!(chat.bottom_pane.composer_text(), stashed_draft);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_clear_is_disabled_while_task_running() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
Reference in New Issue
Block a user