tui: split remaining composer draft and footer state (#22656)

## Why

[#22581](https://github.com/openai/codex/pull/22581) started separating
the chat composer’s responsibilities, but `ChatComposer` still owned the
remaining editable draft state alongside footer/status presentation
state. This follow-up makes those ownership lines explicit so future
composer changes have a smaller blast radius and `BottomPane` does not
need to keep exposing scattered draft getters.

This is just a refactor. No functional or behavioral changes are
intended.

## What changed

- Move the remaining editable composer state into
`bottom_pane/chat_composer/draft_state.rs`.
- Move footer and status-row presentation state into
`bottom_pane/chat_composer/footer_state.rs`.
- Add an internal `ComposerDraftSnapshot` for restore flows, replacing
several ad hoc `BottomPane` pass-through reads.
- Rewire the related history-search and thread-input restore paths to
use the extracted state.

## Verification

- `RUST_MIN_STACK=8388608 cargo test -p codex-tui`
- `cargo insta pending-snapshots`
This commit is contained in:
Eric Traut
2026-05-15 09:12:52 -07:00
committed by GitHub
parent 68ccfdc905
commit 7fa0007ea8
6 changed files with 1006 additions and 760 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
//! Editable composer draft state kept separate from composer control flow.
use std::cell::RefCell;
use std::collections::HashMap;
use crate::bottom_pane::MentionBinding;
use crate::bottom_pane::paste_burst::PasteBurst;
use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
pub(super) struct DraftState {
pub(super) textarea: TextArea,
pub(super) textarea_state: RefCell<TextAreaState>,
pub(super) is_bash_mode: bool,
pub(super) pending_pastes: Vec<(String, String)>,
pub(super) input_enabled: bool,
pub(super) input_disabled_placeholder: Option<String>,
pub(super) paste_burst: PasteBurst,
pub(super) disable_paste_burst: bool,
pub(super) mention_bindings: HashMap<u64, ComposerMentionBinding>,
pub(super) recent_submission_mention_bindings: Vec<MentionBinding>,
}
impl DraftState {
pub(super) fn new() -> Self {
Self {
textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
is_bash_mode: false,
pending_pastes: Vec::new(),
input_enabled: true,
input_disabled_placeholder: None,
paste_burst: PasteBurst::default(),
disable_paste_burst: false,
mention_bindings: HashMap::new(),
recent_submission_mention_bindings: Vec::new(),
}
}
}
#[derive(Clone, Debug)]
pub(super) struct ComposerMentionBinding {
pub(super) mention: String,
pub(super) path: String,
}

View File

@@ -0,0 +1,73 @@
//! Footer and status-row presentation state for the chat composer.
use std::time::Instant;
use ratatui::text::Line;
use crate::bottom_pane::footer::CollaborationModeIndicator;
use crate::bottom_pane::footer::FooterMode;
use crate::bottom_pane::footer::GoalStatusIndicator;
use crate::key_hint::KeyBinding;
#[cfg(test)]
use std::time::Duration;
pub(super) struct FooterState {
pub(super) quit_shortcut_expires_at: Option<Instant>,
pub(super) quit_shortcut_key: KeyBinding,
pub(super) esc_backtrack_hint: bool,
pub(super) use_shift_enter_hint: bool,
pub(super) mode: FooterMode,
pub(super) hint_override: Option<Vec<(String, String)>>,
pub(super) plan_mode_nudge_visible: bool,
pub(super) flash: Option<FooterFlash>,
pub(super) context_window_percent: Option<i64>,
pub(super) context_window_used_tokens: Option<i64>,
pub(super) collaboration_mode_indicator: Option<CollaborationModeIndicator>,
pub(super) goal_status_indicator: Option<GoalStatusIndicator>,
pub(super) ide_context_active: bool,
pub(super) status_line_value: Option<Line<'static>>,
pub(super) status_line_hyperlink_url: Option<String>,
pub(super) status_line_enabled: bool,
pub(super) side_conversation_context_label: Option<String>,
pub(super) active_agent_label: Option<String>,
pub(super) external_editor_key: Option<KeyBinding>,
pub(super) show_transcript_key: Option<KeyBinding>,
pub(super) insert_newline_key: Option<KeyBinding>,
pub(super) queue_key: Option<KeyBinding>,
pub(super) toggle_shortcuts_key: Option<KeyBinding>,
pub(super) history_search_key: Option<KeyBinding>,
pub(super) reasoning_down_key: Option<KeyBinding>,
pub(super) reasoning_up_key: Option<KeyBinding>,
}
#[derive(Clone, Debug)]
pub(super) struct FooterFlash {
pub(super) line: Line<'static>,
pub(super) expires_at: Instant,
}
impl FooterState {
pub(super) fn flash_visible(&self) -> bool {
self.flash
.as_ref()
.is_some_and(|flash| Instant::now() < flash.expires_at)
}
#[cfg(test)]
pub(super) fn show_flash(&mut self, line: Line<'static>, duration: Duration) {
let expires_at = Instant::now()
.checked_add(duration)
.unwrap_or_else(Instant::now);
self.flash = Some(FooterFlash { line, expires_at });
}
#[cfg(test)]
pub(super) fn status_line_text(&self) -> Option<String> {
self.status_line_value.as_ref().map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
}
}

View File

@@ -102,10 +102,10 @@ impl ChatComposer {
/// from replacing an empty composer with the latest prompt before the user has searched for
/// anything.
pub(super) fn begin_history_search(&mut self) -> (InputResult, bool) {
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
self.paste_burst.clear_window_after_non_char();
self.draft.paste_burst.clear_window_after_non_char();
if self.popups.current_file_query.is_some() {
self.app_event_tx
@@ -185,7 +185,7 @@ impl ChatComposer {
{
self.history_search = None;
self.history.reset_search();
self.footer_mode = reset_mode_after_activity(self.footer_mode);
self.footer.mode = reset_mode_after_activity(self.footer.mode);
self.move_cursor_to_end();
}
(InputResult::None, true)
@@ -296,7 +296,7 @@ impl ChatComposer {
return false;
};
self.history.reset_navigation();
self.footer_mode = reset_mode_after_activity(self.footer_mode);
self.footer.mode = reset_mode_after_activity(self.footer.mode);
self.restore_draft(search.original_draft);
true
}
@@ -385,7 +385,7 @@ impl ChatComposer {
if !matches!(search.status, HistorySearchStatus::Match) || search.query.is_empty() {
return Vec::new();
}
Self::case_insensitive_match_ranges(self.textarea.text(), &search.query)
Self::case_insensitive_match_ranges(self.draft.textarea.text(), &search.query)
}
fn case_insensitive_match_ranges(text: &str, query: &str) -> Vec<Range<usize>> {
@@ -520,7 +520,7 @@ mod tests {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
assert!(composer.history_search_active());
assert!(composer.textarea.is_empty());
assert!(composer.draft.textarea.is_empty());
assert_eq!(composer.footer_mode(), FooterMode::HistorySearch);
}
@@ -558,18 +558,21 @@ mod tests {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
assert!(composer.history_search_active());
assert_eq!(composer.textarea.text(), "draft");
assert_eq!(composer.draft.textarea.text(), "draft");
for ch in ['g', 'i', 't'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert_eq!(composer.textarea.text(), "git status");
assert_eq!(composer.draft.textarea.text(), "git status");
assert_eq!(composer.footer_mode(), FooterMode::HistorySearch);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(!composer.history_search_active());
assert_eq!(composer.textarea.text(), "git status");
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
assert_eq!(composer.draft.textarea.text(), "git status");
assert_eq!(
composer.draft.textarea.cursor(),
composer.draft.textarea.text().len()
);
}
#[test]
@@ -593,8 +596,8 @@ mod tests {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert_eq!(composer.textarea.text(), "git status");
assert_eq!(composer.textarea.cursor(), "git status".len() - 1);
assert_eq!(composer.draft.textarea.text(), "git status");
assert_eq!(composer.draft.textarea.cursor(), "git status".len() - 1);
assert_eq!(composer.footer_mode(), FooterMode::HistorySearch);
}
@@ -618,13 +621,19 @@ mod tests {
for ch in ['b', 'u', 'g'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename");
assert_eq!(
composer.draft.textarea.text(),
"Find and fix a bug in @filename"
);
for _ in 0..3 {
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
}
assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename");
assert_eq!(
composer.draft.textarea.text(),
"Find and fix a bug in @filename"
);
assert!(
composer
.history_search
@@ -635,7 +644,10 @@ mod tests {
for _ in 0..3 {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
}
assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename");
assert_eq!(
composer.draft.textarea.text(),
"Find and fix a bug in @filename"
);
assert!(
composer
.history_search
@@ -776,17 +788,17 @@ mod tests {
.history
.record_local_submission(HistoryEntry::new("remembered command".to_string()));
composer.set_text_content("draft".to_string(), Vec::new(), Vec::new());
composer.textarea.set_cursor(/*pos*/ 2);
composer.draft.textarea.set_cursor(/*pos*/ 2);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
assert_eq!(composer.textarea.text(), "draft");
assert_eq!(composer.draft.textarea.text(), "draft");
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
assert_eq!(composer.textarea.text(), "remembered command");
assert_eq!(composer.draft.textarea.text(), "remembered command");
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!composer.history_search_active());
assert_eq!(composer.textarea.text(), "draft");
assert_eq!(composer.textarea.cursor(), 2);
assert_eq!(composer.draft.textarea.text(), "draft");
assert_eq!(composer.draft.textarea.cursor(), 2);
}
#[test]
@@ -805,13 +817,13 @@ mod tests {
.history
.record_local_submission(HistoryEntry::new("remembered command".to_string()));
composer.set_text_content("draft".to_string(), Vec::new(), Vec::new());
composer.textarea.set_cursor(/*pos*/ 2);
composer.draft.textarea.set_cursor(/*pos*/ 2);
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
assert_eq!(composer.textarea.text(), "remembered command");
assert_eq!(composer.draft.textarea.text(), "remembered command");
composer
}
@@ -824,8 +836,8 @@ mod tests {
let _ = composer.handle_key_event(cancel_key);
assert!(!composer.history_search_active());
assert_eq!(composer.textarea.text(), "draft");
assert_eq!(composer.textarea.cursor(), 2);
assert_eq!(composer.draft.textarea.text(), "draft");
assert_eq!(composer.draft.textarea.cursor(), 2);
}
}
@@ -843,18 +855,18 @@ mod tests {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
assert!(composer.is_in_paste_burst());
assert_eq!(composer.textarea.text(), "");
assert_eq!(composer.draft.textarea.text(), "");
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
assert!(composer.history_search_active());
assert!(!composer.is_in_paste_burst());
assert_eq!(composer.textarea.text(), "h");
assert_eq!(composer.draft.textarea.text(), "h");
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!composer.history_search_active());
assert_eq!(composer.textarea.text(), "h");
assert_eq!(composer.draft.textarea.text(), "h");
}
#[test]
@@ -881,18 +893,18 @@ mod tests {
now += Duration::from_millis(1);
}
assert!(composer.is_in_paste_burst());
assert_eq!(composer.textarea.text(), "");
assert_eq!(composer.draft.textarea.text(), "");
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
assert!(composer.history_search_active());
assert!(!composer.is_in_paste_burst());
assert_eq!(composer.textarea.text(), "paste");
assert_eq!(composer.draft.textarea.text(), "paste");
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!composer.history_search_active());
assert_eq!(composer.textarea.text(), "paste");
assert_eq!(composer.draft.textarea.text(), "paste");
}
#[test]
@@ -918,14 +930,14 @@ mod tests {
for ch in ['m', 'a', 't', 'c', 'h'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert_eq!(composer.textarea.text(), "oldest matching entry");
assert_eq!(composer.draft.textarea.text(), "oldest matching entry");
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!composer.history_search_active());
assert!(composer.textarea.is_empty());
assert!(composer.draft.textarea.is_empty());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(composer.textarea.text(), "newest entry");
assert_eq!(composer.draft.textarea.text(), "newest entry");
}
#[test]
@@ -950,7 +962,7 @@ mod tests {
}
assert!(composer.history_search_active());
assert_eq!(composer.textarea.text(), "draft");
assert_eq!(composer.draft.textarea.text(), "draft");
assert_eq!(composer.footer_mode(), FooterMode::HistorySearch);
}
}

View File

@@ -808,18 +808,20 @@ impl BottomPane {
self.composer.current_text()
}
pub(crate) fn composer_draft_snapshot(&self) -> chat_composer::ComposerDraftSnapshot {
self.composer.draft_snapshot()
}
#[cfg(test)]
pub(crate) fn composer_text_elements(&self) -> Vec<TextElement> {
self.composer.text_elements()
}
#[cfg(test)]
pub(crate) fn composer_local_images(&self) -> Vec<LocalImageAttachment> {
self.composer.local_images()
}
pub(crate) fn composer_mention_bindings(&self) -> Vec<MentionBinding> {
self.composer.mention_bindings()
}
#[cfg(test)]
pub(crate) fn composer_local_image_paths(&self) -> Vec<PathBuf> {
self.composer.local_image_paths()
@@ -865,6 +867,7 @@ impl BottomPane {
self.request_redraw();
}
#[cfg(test)]
pub(crate) fn remote_image_urls(&self) -> Vec<String> {
self.composer.remote_image_urls()
}

View File

@@ -152,12 +152,13 @@ impl ChatWidget {
return None;
}
let composer = self.bottom_pane.composer_draft_snapshot();
let existing_message = UserMessage {
text: self.bottom_pane.composer_text(),
text_elements: self.bottom_pane.composer_text_elements(),
local_images: self.bottom_pane.composer_local_images(),
remote_image_urls: self.bottom_pane.remote_image_urls(),
mention_bindings: self.bottom_pane.composer_mention_bindings(),
text: composer.text,
text_elements: composer.text_elements,
local_images: composer.local_images,
remote_image_urls: composer.remote_image_urls,
mention_bindings: composer.mention_bindings,
};
let rejected_messages = self
@@ -236,13 +237,14 @@ impl ChatWidget {
}
pub(crate) fn capture_thread_input_state(&self) -> Option<ThreadInputState> {
let draft = self.bottom_pane.composer_draft_snapshot();
let composer = ThreadComposerState {
text: self.bottom_pane.composer_text(),
text_elements: self.bottom_pane.composer_text_elements(),
local_images: self.bottom_pane.composer_local_images(),
remote_image_urls: self.bottom_pane.remote_image_urls(),
mention_bindings: self.bottom_pane.composer_mention_bindings(),
pending_pastes: self.bottom_pane.composer_pending_pastes(),
text: draft.text,
text_elements: draft.text_elements,
local_images: draft.local_images,
remote_image_urls: draft.remote_image_urls,
mention_bindings: draft.mention_bindings,
pending_pastes: draft.pending_pastes,
};
Some(ThreadInputState {
composer: composer.has_content().then_some(composer),