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:
Felipe Coury
2026-05-06 14:46:18 -03:00
committed by GitHub
parent f2f5d6f6c7
commit e97610cf3b
3 changed files with 45 additions and 6 deletions

View File

@@ -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()));
}

View File

@@ -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();
}

View File

@@ -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;