mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
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:
File diff suppressed because it is too large
Load Diff
45
codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs
Normal file
45
codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs
Normal 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,
|
||||
}
|
||||
73
codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs
Normal file
73
codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs
Normal 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>()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user