diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4bfd5da17cb..e76b4c0b96a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -218,10 +218,15 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::TextElement; mod attachment_state; +mod draft_state; +mod footer_state; mod history_search; mod popup_state; use self::attachment_state::AttachmentState; +use self::draft_state::ComposerMentionBinding; +use self::draft_state::DraftState; +use self::footer_state::FooterState; use self::history_search::HistorySearchSession; use self::popup_state::ActivePopup; use self::popup_state::PopupState; @@ -231,7 +236,6 @@ use crate::app_event_sender::AppEventSender; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::MentionBinding; use crate::bottom_pane::textarea::TextArea; -use crate::bottom_pane::textarea::TextAreaState; use crate::clipboard_paste::normalize_pasted_path; use crate::clipboard_paste::pasted_image_format; use crate::history_cell; @@ -246,7 +250,6 @@ use codex_file_search::FileMatch; #[cfg(test)] use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; -use std::cell::RefCell; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; @@ -342,57 +345,29 @@ impl ChatComposerConfig { } pub(crate) struct ChatComposer { - textarea: TextArea, - textarea_state: RefCell, - is_bash_mode: bool, + draft: DraftState, popups: PopupState, app_event_tx: AppEventSender, history: ChatComposerHistory, - quit_shortcut_expires_at: Option, - quit_shortcut_key: KeyBinding, - esc_backtrack_hint: bool, - use_shift_enter_hint: bool, - pending_pastes: Vec<(String, String)>, + footer: FooterState, has_focus: bool, frame_requester: Option, attachments: AttachmentState, placeholder_text: String, is_task_running: bool, - /// When false, the composer is temporarily read-only (e.g. during sandbox setup). - input_enabled: bool, - input_disabled_placeholder: Option, - /// Non-bracketed paste burst tracker (see `bottom_pane/paste_burst.rs`). - paste_burst: PasteBurst, - // When true, disables paste-burst logic and inserts characters immediately. - disable_paste_burst: bool, - footer_mode: FooterMode, - footer_hint_override: Option>, - /// Whether the ambient footer row is currently replaced by the Plan-mode nudge. - /// - /// Eligibility is decided by `ChatWidget`; the composer only owns presentation so enabling - /// the nudge never changes layout height or reimplements mode-selection policy here. - plan_mode_nudge_visible: bool, /// Slash-command draft staged for local recall after application-level dispatch. /// /// This slot is intentionally separate from `ChatComposerHistory` so inline slash commands can /// prepare their argument text without also double-recording the full command invocation. pending_slash_command_history: Option, - footer_flash: Option, - context_window_percent: Option, // Monotonically increasing identifier for textarea elements we insert. #[cfg(not(target_os = "linux"))] next_element_id: u64, - context_window_used_tokens: Option, skills: Option>, plugins: Option>, connectors_snapshot: Option, - mention_bindings: HashMap, - recent_submission_mention_bindings: Vec, collaboration_modes_enabled: bool, config: ChatComposerConfig, - collaboration_mode_indicator: Option, - goal_status_indicator: Option, - ide_context_active: bool, connectors_enabled: bool, plugins_command_enabled: bool, service_tier_commands_enabled: bool, @@ -404,12 +379,6 @@ pub(crate) struct ChatComposer { audio_device_selection_enabled: bool, windows_degraded_sandbox_active: bool, side_conversation_active: bool, - status_line_value: Option>, - status_line_hyperlink_url: Option, - status_line_enabled: bool, - side_conversation_context_label: Option, - // Agent label injected into the footer's contextual row when multi-agent mode is active. - active_agent_label: Option, history_search: Option, submit_keys: Vec, queue_keys: Vec, @@ -418,20 +387,6 @@ pub(crate) struct ChatComposer { history_search_next_keys: Vec, editor_keymap: EditorKeymap, vim_normal_keymap: VimNormalKeymap, - footer_external_editor_key: Option, - footer_show_transcript_key: Option, - footer_insert_newline_key: Option, - footer_queue_key: Option, - footer_toggle_shortcuts_key: Option, - footer_history_search_key: Option, - footer_reasoning_down_key: Option, - footer_reasoning_up_key: Option, -} - -#[derive(Clone, Debug)] -struct FooterFlash { - line: Line<'static>, - expires_at: Instant, } #[derive(Clone, Debug)] @@ -446,9 +401,13 @@ struct ComposerDraft { } #[derive(Clone, Debug)] -struct ComposerMentionBinding { - mention: String, - path: String, +pub(crate) struct ComposerDraftSnapshot { + pub(crate) text: String, + pub(crate) text_elements: Vec, + pub(crate) local_images: Vec, + pub(crate) remote_image_urls: Vec, + pub(crate) mention_bindings: Vec, + pub(crate) pending_pastes: Vec<(String, String)>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -523,45 +482,56 @@ impl ChatComposer { let default_vim_normal_keymap = default_keymap.vim_normal.clone(); let mut this = Self { - textarea: TextArea::new(), - textarea_state: RefCell::new(TextAreaState::default()), - is_bash_mode: false, + draft: DraftState::new(), popups: PopupState::default(), app_event_tx, history: ChatComposerHistory::new(), - quit_shortcut_expires_at: None, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - esc_backtrack_hint: false, - use_shift_enter_hint, - pending_pastes: Vec::new(), + footer: FooterState { + quit_shortcut_expires_at: None, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + esc_backtrack_hint: false, + use_shift_enter_hint, + mode: FooterMode::ComposerEmpty, + hint_override: None, + plan_mode_nudge_visible: false, + flash: None, + context_window_percent: None, + context_window_used_tokens: None, + collaboration_mode_indicator: None, + goal_status_indicator: None, + ide_context_active: false, + status_line_value: None, + status_line_hyperlink_url: None, + status_line_enabled: false, + side_conversation_context_label: None, + active_agent_label: None, + external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))), + show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))), + insert_newline_key: footer_insert_newline_key( + &default_keymap.editor.insert_newline, + use_shift_enter_hint, + ), + queue_key: Some(key_hint::plain(KeyCode::Tab)), + toggle_shortcuts_key: Some(key_hint::plain(KeyCode::Char('?'))), + history_search_key: primary_binding( + &default_keymap.composer.history_search_previous, + ), + reasoning_down_key: primary_binding(&default_keymap.chat.decrease_reasoning_effort), + reasoning_up_key: primary_binding(&default_keymap.chat.increase_reasoning_effort), + }, has_focus: has_input_focus, frame_requester: None, attachments: AttachmentState::default(), placeholder_text, is_task_running: false, - input_enabled: true, - input_disabled_placeholder: None, - paste_burst: PasteBurst::default(), - disable_paste_burst: false, - footer_mode: FooterMode::ComposerEmpty, - footer_hint_override: None, - plan_mode_nudge_visible: false, pending_slash_command_history: None, - footer_flash: None, - context_window_percent: None, #[cfg(not(target_os = "linux"))] next_element_id: 0, - context_window_used_tokens: None, skills: None, plugins: None, connectors_snapshot: None, - mention_bindings: HashMap::new(), - recent_submission_mention_bindings: Vec::new(), collaboration_modes_enabled: false, config, - collaboration_mode_indicator: None, - goal_status_indicator: None, - ide_context_active: false, connectors_enabled: false, plugins_command_enabled: false, service_tier_commands_enabled: false, @@ -573,11 +543,6 @@ impl ChatComposer { audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, side_conversation_active: false, - status_line_value: None, - status_line_hyperlink_url: None, - status_line_enabled: false, - side_conversation_context_label: None, - active_agent_label: None, history_search: None, submit_keys: vec![key_hint::plain(KeyCode::Enter)], queue_keys: vec![key_hint::plain(KeyCode::Tab)], @@ -589,23 +554,6 @@ impl ChatComposer { history_search_next_keys: default_keymap.composer.history_search_next.clone(), editor_keymap: default_editor_keymap, vim_normal_keymap: default_vim_normal_keymap, - footer_external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))), - footer_show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))), - footer_insert_newline_key: footer_insert_newline_key( - &default_keymap.editor.insert_newline, - use_shift_enter_hint, - ), - footer_queue_key: Some(key_hint::plain(KeyCode::Tab)), - footer_toggle_shortcuts_key: Some(key_hint::plain(KeyCode::Char('?'))), - footer_history_search_key: primary_binding( - &default_keymap.composer.history_search_previous, - ), - footer_reasoning_down_key: primary_binding( - &default_keymap.chat.decrease_reasoning_effort, - ), - footer_reasoning_up_key: primary_binding( - &default_keymap.chat.increase_reasoning_effort, - ), }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -659,7 +607,7 @@ impl ChatComposer { let elements = self.current_mention_elements(); let mut ordered = Vec::new(); for (id, mention) in elements { - if let Some(binding) = self.mention_bindings.remove(&id) + if let Some(binding) = self.draft.mention_bindings.remove(&id) && binding.mention == mention { ordered.push(MentionBinding { @@ -668,7 +616,7 @@ impl ChatComposer { }); } } - self.mention_bindings.clear(); + self.draft.mention_bindings.clear(); ordered } @@ -707,31 +655,33 @@ impl ChatComposer { self.history_search_next_keys = keymap.composer.history_search_next.clone(); self.editor_keymap = keymap.editor.clone(); self.vim_normal_keymap = keymap.vim_normal.clone(); - self.textarea.set_keymap_bindings(keymap); - self.footer_external_editor_key = primary_binding(&keymap.app.open_external_editor); - self.footer_show_transcript_key = primary_binding(&keymap.app.open_transcript); - self.footer_insert_newline_key = - footer_insert_newline_key(&keymap.editor.insert_newline, self.use_shift_enter_hint); - self.footer_queue_key = primary_binding(&keymap.composer.queue); - self.footer_toggle_shortcuts_key = primary_binding(&keymap.composer.toggle_shortcuts); - self.footer_history_search_key = primary_binding(&keymap.composer.history_search_previous); - self.footer_reasoning_down_key = primary_binding(&keymap.chat.decrease_reasoning_effort); - self.footer_reasoning_up_key = primary_binding(&keymap.chat.increase_reasoning_effort); + self.draft.textarea.set_keymap_bindings(keymap); + self.footer.external_editor_key = primary_binding(&keymap.app.open_external_editor); + self.footer.show_transcript_key = primary_binding(&keymap.app.open_transcript); + self.footer.insert_newline_key = footer_insert_newline_key( + &keymap.editor.insert_newline, + self.footer.use_shift_enter_hint, + ); + self.footer.queue_key = primary_binding(&keymap.composer.queue); + self.footer.toggle_shortcuts_key = primary_binding(&keymap.composer.toggle_shortcuts); + self.footer.history_search_key = primary_binding(&keymap.composer.history_search_previous); + self.footer.reasoning_down_key = primary_binding(&keymap.chat.decrease_reasoning_effort); + self.footer.reasoning_up_key = primary_binding(&keymap.chat.increase_reasoning_effort); } pub fn set_collaboration_mode_indicator( &mut self, indicator: Option, ) { - self.collaboration_mode_indicator = indicator; + self.footer.collaboration_mode_indicator = indicator; } pub fn set_goal_status_indicator(&mut self, indicator: Option) { - self.goal_status_indicator = indicator; + self.footer.goal_status_indicator = indicator; } pub fn set_ide_context_active(&mut self, active: bool) { - self.ide_context_active = active; + self.footer.ide_context_active = active; } pub fn set_personality_command_enabled(&mut self, enabled: bool) { @@ -842,7 +792,7 @@ impl ChatComposer { area: Rect, textarea_right_reserve: u16, ) -> Option<(u16, u16)> { - if !self.input_enabled || self.attachments.selected_remote_image_index.is_some() { + if !self.draft.input_enabled || self.attachments.selected_remote_image_index.is_some() { return None; } @@ -852,12 +802,14 @@ impl ChatComposer { let [_, _, textarea_rect, _] = self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve); - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) + let state = *self.draft.textarea_state.borrow(); + self.draft + .textarea + .cursor_pos_with_state(textarea_rect, state) } /// Returns true if the composer currently contains no user-entered input. pub(crate) fn is_empty(&self) -> bool { - self.textarea.is_empty() && !self.is_bash_mode && self.attachments.is_empty() + self.draft.textarea.is_empty() && !self.draft.is_bash_mode && self.attachments.is_empty() } /// Record local persistent-history metadata so the composer can navigate @@ -924,17 +876,17 @@ impl ChatComposer { let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { let placeholder = self.next_large_paste_placeholder(char_count); - self.textarea.insert_element(&placeholder); - self.pending_pastes.push((placeholder, pasted)); + self.draft.textarea.insert_element(&placeholder); + self.draft.pending_pastes.push((placeholder, pasted)); } else if char_count > 1 && self.image_paste_enabled() && self.handle_paste_image_path(pasted.clone()) { - self.textarea.insert_str(" "); + self.draft.textarea.insert_str(" "); } else { self.insert_str(&pasted); } - self.paste_burst.clear_after_explicit_paste(); + self.draft.paste_burst.clear_after_explicit_paste(); self.sync_popups(); true } @@ -981,13 +933,13 @@ impl ChatComposer { /// without emitting any buffered text, which can leave a non-empty buffer unable to flush /// later (because `flush_if_due()` relies on `last_plain_char_time` to time out). pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { - let was_disabled = self.disable_paste_burst; - self.disable_paste_burst = disabled; + let was_disabled = self.draft.disable_paste_burst; + self.draft.disable_paste_burst = disabled; if disabled && !was_disabled { - 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_after_explicit_paste(); + self.draft.paste_burst.clear_after_explicit_paste(); } } @@ -997,7 +949,7 @@ impl ChatComposer { /// are renumbered to `[Image #M+1]..[Image #N]` (where `M` is the number of /// remote images). Cursor is placed at the end after rebuilding elements. pub(crate) fn apply_external_edit(&mut self, text: String) { - self.pending_pastes.clear(); + self.draft.pending_pastes.clear(); let (text, _) = self.imported_text_for_textarea(text, Vec::new()); // Count placeholder occurrences in the new text. @@ -1030,7 +982,7 @@ impl ChatComposer { self.attachments.local_images = kept_images; // Rebuild textarea so placeholders become elements again. - self.textarea.set_text_clearing_elements(""); + self.draft.textarea.set_text_clearing_elements(""); let mut remaining: HashMap<&str, usize> = HashMap::new(); for img in &self.attachments.local_images { *remaining.entry(img.placeholder.as_str()).or_insert(0) += 1; @@ -1053,20 +1005,23 @@ impl ChatComposer { continue; } if pos > idx { - self.textarea.insert_str(&text[idx..pos]); + self.draft.textarea.insert_str(&text[idx..pos]); } - self.textarea.insert_element(ph); + self.draft.textarea.insert_element(ph); *count -= 1; idx = pos + ph.len(); } if idx < text.len() { - self.textarea.insert_str(&text[idx..]); + self.draft.textarea.insert_str(&text[idx..]); } // Keep local image placeholders normalized in attachment order after the // remote-image prefix. - self.attachments.relabel_local_images(&mut self.textarea); - self.textarea.set_cursor(self.textarea.text().len()); + self.attachments + .relabel_local_images(&mut self.draft.textarea); + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); self.sync_popups(); } @@ -1077,9 +1032,9 @@ impl ChatComposer { /// commands, not as candidate literal paste text. It also resets transient /// footer mode so the visible hints match the new editing surface. pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { - self.textarea.set_vim_enabled(enabled); - self.paste_burst.clear_after_explicit_paste(); - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.draft.textarea.set_vim_enabled(enabled); + self.draft.paste_burst.clear_after_explicit_paste(); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } /// Toggle Vim editing and return the new enabled state. @@ -1088,7 +1043,7 @@ impl ChatComposer { /// keybinding; callers should use the returned value for status messages /// instead of rereading state after additional composer mutations. pub(crate) fn toggle_vim_enabled(&mut self) -> bool { - let enabled = !self.textarea.is_vim_enabled(); + let enabled = !self.draft.textarea.is_vim_enabled(); self.set_vim_enabled(enabled); enabled } @@ -1096,7 +1051,7 @@ impl ChatComposer { /// Return whether Vim editing is enabled for tests that assert mode transitions. #[cfg(test)] pub(crate) fn is_vim_enabled(&self) -> bool { - self.textarea.is_vim_enabled() + self.draft.textarea.is_vim_enabled() } /// Return whether Escape should be routed to the textarea before popups. @@ -1105,15 +1060,20 @@ impl ChatComposer { /// event layer asks this before running generic Escape behavior so the same /// key does not both leave insert mode and dismiss unrelated UI. pub(crate) fn should_handle_vim_insert_escape(&self, key_event: KeyEvent) -> bool { - self.textarea.should_handle_vim_insert_escape(key_event) + self.draft + .textarea + .should_handle_vim_insert_escape(key_event) } fn vim_mode_indicator_span(&self) -> Option> { - self.textarea.vim_mode_label().map(|label| match label { - "Normal" => "Vim: Normal".magenta(), - "Insert" => "Vim: Insert".green(), - _ => unreachable!(), - }) + self.draft + .textarea + .vim_mode_label() + .map(|label| match label { + "Normal" => "Vim: Normal".magenta(), + "Insert" => "Vim: Insert".green(), + _ => unreachable!(), + }) } fn mode_indicator_line(&self, show_cycle_hint: bool) -> Option> { @@ -1122,9 +1082,9 @@ impl ChatComposer { spans.push(vim_mode); } if let Some(indicators) = status_line_right_indicator_line( - self.collaboration_mode_indicator, - self.goal_status_indicator.as_ref(), - self.ide_context_active, + self.footer.collaboration_mode_indicator, + self.footer.goal_status_indicator.as_ref(), + self.footer.ide_context_active, show_cycle_hint, ) { if !spans.is_empty() { @@ -1140,8 +1100,10 @@ impl ChatComposer { } fn right_footer_line_with_context(&self) -> Line<'static> { - let mut line = - context_window_line(self.context_window_percent, self.context_window_used_tokens); + let mut line = context_window_line( + self.footer.context_window_percent, + self.footer.context_window_used_tokens, + ); if let Some(vim_mode) = self.vim_mode_indicator_span() { line.spans.push(" | ".dim()); line.spans.push(vim_mode); @@ -1151,27 +1113,30 @@ impl ChatComposer { pub(crate) fn current_text_with_pending(&self) -> String { let text = self.current_text(); - if self.pending_pastes.is_empty() { + if self.draft.pending_pastes.is_empty() { return text; } - let (text, _) = - Self::expand_pending_pastes(&text, self.current_text_elements(), &self.pending_pastes); + let (text, _) = Self::expand_pending_pastes( + &text, + self.current_text_elements(), + &self.draft.pending_pastes, + ); text } /// Returns whether the composer currently accepts interactive draft edits. pub(crate) fn input_enabled(&self) -> bool { - self.input_enabled + self.draft.input_enabled } pub(crate) fn pending_pastes(&self) -> Vec<(String, String)> { - self.pending_pastes.clone() + self.draft.pending_pastes.clone() } pub(crate) fn set_pending_pastes(&mut self, pending_pastes: Vec<(String, String)>) { let text = self.current_text(); - self.pending_pastes = pending_pastes + self.draft.pending_pastes = pending_pastes .into_iter() .filter(|(placeholder, _)| text.contains(placeholder)) .collect(); @@ -1180,7 +1145,7 @@ impl ChatComposer { /// Override the footer hint items displayed beneath the composer. Passing /// `None` restores the default shortcut footer. pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { - self.footer_hint_override = items; + self.footer.hint_override = items; } /// Updates whether the Plan-mode nudge replaces the ambient footer row. @@ -1188,21 +1153,21 @@ impl ChatComposer { /// Returns `true` only when the rendered footer can change so callers can avoid scheduling /// redundant redraws while reevaluating nudge policy on routine composer updates. pub(crate) fn set_plan_mode_nudge_visible(&mut self, visible: bool) -> bool { - if self.plan_mode_nudge_visible == visible { + if self.footer.plan_mode_nudge_visible == visible { return false; } - self.plan_mode_nudge_visible = visible; + self.footer.plan_mode_nudge_visible = visible; true } #[cfg(test)] pub(crate) fn plan_mode_nudge_visible(&self) -> bool { - self.plan_mode_nudge_visible + self.footer.plan_mode_nudge_visible } pub(crate) fn set_remote_image_urls(&mut self, urls: Vec) { self.attachments - .set_remote_image_urls(urls, &mut self.textarea); + .set_remote_image_urls(urls, &mut self.draft.textarea); self.sync_popups(); } @@ -1211,23 +1176,16 @@ impl ChatComposer { } pub(crate) fn take_remote_image_urls(&mut self) -> Vec { - let urls = self.attachments.take_remote_image_urls(&mut self.textarea); + let urls = self + .attachments + .take_remote_image_urls(&mut self.draft.textarea); self.sync_popups(); urls } #[cfg(test)] pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { - let expires_at = Instant::now() - .checked_add(duration) - .unwrap_or_else(Instant::now); - self.footer_flash = Some(FooterFlash { line, expires_at }); - } - - pub(crate) fn footer_flash_visible(&self) -> bool { - self.footer_flash - .as_ref() - .is_some_and(|flash| Instant::now() < flash.expires_at) + self.footer.show_flash(line, duration); } /// Replace the entire composer content with `text` and reset cursor. @@ -1270,31 +1228,33 @@ impl ChatComposer { mention_bindings: Vec, ) { // Clear any existing content, placeholders, and attachments first. - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; - self.pending_pastes.clear(); - self.mention_bindings.clear(); + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; + self.draft.pending_pastes.clear(); + self.draft.mention_bindings.clear(); let (text, text_elements) = self.imported_text_for_textarea(text, text_elements); - self.textarea.set_text_with_elements(&text, &text_elements); + self.draft + .textarea + .set_text_with_elements(&text, &text_elements); self.attachments - .reset_local_images(local_image_paths, &mut self.textarea); + .reset_local_images(local_image_paths, &mut self.draft.textarea); self.bind_mentions_from_snapshot(mention_bindings); - self.textarea.set_cursor(/*pos*/ 0); + self.draft.textarea.set_cursor(/*pos*/ 0); self.sync_popups(); } fn current_cursor(&self) -> usize { - self.textarea.cursor() + if self.is_bash_mode { 1 } else { 0 } + self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 } } fn history_navigation_cursor(&self) -> usize { - if self.is_bash_mode && self.textarea.cursor() == 0 { + if self.draft.is_bash_mode && self.draft.textarea.cursor() == 0 { 0 - } else if self.textarea.is_vim_normal_mode() - && !self.textarea.text().is_empty() - && self.textarea.cursor() == self.textarea.vim_normal_end_cursor() + } else if self.draft.textarea.is_vim_normal_mode() + && !self.draft.textarea.text().is_empty() + && self.draft.textarea.cursor() == self.draft.textarea.vim_normal_end_cursor() { self.current_text().len() } else { @@ -1303,18 +1263,20 @@ impl ChatComposer { } fn set_current_cursor(&mut self, cursor: usize) { - let visible_cursor = if self.is_bash_mode { + let visible_cursor = if self.draft.is_bash_mode { cursor.saturating_sub(1) } else { cursor }; - self.textarea - .set_cursor(visible_cursor.min(self.textarea.text().len())); + self.draft + .textarea + .set_cursor(visible_cursor.min(self.draft.textarea.text().len())); } fn current_text_elements(&self) -> Vec { - let shift = if self.is_bash_mode { 1 } else { 0 }; - self.textarea + let shift = if self.draft.is_bash_mode { 1 } else { 0 }; + self.draft + .textarea .text_elements() .into_iter() .filter_map(|element| Self::shift_text_element(element, shift)) @@ -1338,7 +1300,7 @@ impl ChatComposer { local_image_paths: self.attachments.local_image_paths(), remote_image_urls: self.attachments.remote_image_urls(), mention_bindings: self.snapshot_mention_bindings(), - pending_pastes: self.pending_pastes.clone(), + pending_pastes: self.draft.pending_pastes.clone(), cursor: self.current_cursor(), } } @@ -1372,17 +1334,19 @@ impl ChatComposer { /// Move the cursor to the end of the current text buffer. pub(crate) fn move_cursor_to_end(&mut self) { - self.textarea.set_cursor(self.textarea.text().len()); + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); self.sync_popups(); } fn move_cursor_to_history_entry_end(&mut self) { - let cursor = if self.textarea.is_vim_normal_mode() { - self.textarea.vim_normal_end_cursor() + let cursor = if self.draft.textarea.is_vim_normal_mode() { + self.draft.textarea.vim_normal_end_cursor() } else { - self.textarea.text().len() + self.draft.textarea.text().len() }; - self.textarea.set_cursor(cursor); + self.draft.textarea.set_cursor(cursor); self.sync_popups(); } @@ -1396,7 +1360,7 @@ impl ChatComposer { text_elements: Vec, ) -> (String, Vec) { if let Some(stripped) = text.strip_prefix('!') { - self.is_bash_mode = true; + self.draft.is_bash_mode = true; ( stripped.to_string(), text_elements @@ -1405,7 +1369,7 @@ impl ChatComposer { .collect(), ) } else { - self.is_bash_mode = false; + self.draft.is_bash_mode = false; (text, text_elements) } } @@ -1417,7 +1381,7 @@ impl ChatComposer { let previous = self.current_text(); let text_elements = self.current_text_elements(); let local_image_paths = self.attachments.local_image_paths(); - let pending_pastes = std::mem::take(&mut self.pending_pastes); + let pending_pastes = std::mem::take(&mut self.draft.pending_pastes); let remote_image_urls = self.attachments.remote_image_urls(); let mention_bindings = self.snapshot_mention_bindings(); self.set_text_content(String::new(), Vec::new(), Vec::new()); @@ -1436,10 +1400,10 @@ impl ChatComposer { /// Get the current composer text. pub(crate) fn current_text(&self) -> String { - if self.is_bash_mode { - format!("!{}", self.textarea.text()) + if self.draft.is_bash_mode { + format!("!{}", self.draft.textarea.text()) } else { - self.textarea.text().to_string() + self.draft.textarea.text().to_string() } } @@ -1474,6 +1438,17 @@ impl ChatComposer { self.current_text_elements() } + pub(crate) fn draft_snapshot(&self) -> ComposerDraftSnapshot { + ComposerDraftSnapshot { + text: self.current_text(), + text_elements: self.text_elements(), + local_images: self.local_images(), + remote_image_urls: self.remote_image_urls(), + mention_bindings: self.mention_bindings(), + pending_pastes: self.pending_pastes(), + } + } + #[cfg(test)] pub(crate) fn local_image_paths(&self) -> Vec { self.attachments.local_image_paths() @@ -1481,12 +1456,7 @@ impl ChatComposer { #[cfg(test)] pub(crate) fn status_line_text(&self) -> Option { - self.status_line_value.as_ref().map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) + self.footer.status_line_text() } pub(crate) fn local_images(&self) -> Vec { @@ -1498,7 +1468,7 @@ impl ChatComposer { } pub(crate) fn take_recent_submission_mention_bindings(&mut self) -> Vec { - std::mem::take(&mut self.recent_submission_mention_bindings) + std::mem::take(&mut self.draft.recent_submission_mention_bindings) } /// Commit the staged slash-command draft to local Up-arrow recall. @@ -1513,7 +1483,8 @@ impl ChatComposer { /// Insert an attachment placeholder and track it for the next submission. pub fn attach_image(&mut self, path: PathBuf) { - self.attachments.attach_image(&mut self.textarea, path); + self.attachments + .attach_image(&mut self.draft.textarea, path); } #[cfg(test)] @@ -1545,7 +1516,7 @@ impl ChatComposer { /// This includes actively buffering, having a non-empty burst buffer, or holding the first /// ASCII char for flicker suppression. pub(crate) fn is_in_paste_burst(&self) -> bool { - self.paste_burst.is_active() + self.draft.paste_burst.is_active() } /// Returns a delay that reliably exceeds the paste-burst timing threshold. @@ -1561,7 +1532,7 @@ impl ChatComposer { let current_opt = if self.mentions_v2_enabled { self.current_mentions_v2_token() } else { - Self::current_at_token(&self.textarea) + Self::current_at_token(&self.draft.textarea) }; let Some(current_token) = current_opt else { return; @@ -1588,18 +1559,18 @@ impl ChatComposer { /// redraw after [`super::QUIT_SHORTCUT_TIMEOUT`] so the hint can disappear /// even when the UI is otherwise idle. pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) { - self.quit_shortcut_expires_at = Instant::now() + self.footer.quit_shortcut_expires_at = Instant::now() .checked_add(super::QUIT_SHORTCUT_TIMEOUT) .or_else(|| Some(Instant::now())); - self.quit_shortcut_key = key; - self.footer_mode = FooterMode::QuitShortcutReminder; + self.footer.quit_shortcut_key = key; + self.footer.mode = FooterMode::QuitShortcutReminder; self.set_has_focus(has_focus); } /// Clear the "press again to quit" hint immediately. pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) { - self.quit_shortcut_expires_at = None; - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.quit_shortcut_expires_at = None; + self.footer.mode = reset_mode_after_activity(self.footer.mode); self.set_has_focus(has_focus); } @@ -1609,7 +1580,8 @@ impl ChatComposer { /// any additional user input, so the UI schedules a redraw when the hint /// expires. pub(crate) fn quit_shortcut_hint_visible(&self) -> bool { - self.quit_shortcut_expires_at + self.footer + .quit_shortcut_expires_at .is_some_and(|expires_at| Instant::now() < expires_at) } @@ -1618,7 +1590,7 @@ impl ChatComposer { let prefix = format!("{base} #"); let mut max_suffix = 0usize; - for (placeholder, _) in &self.pending_pastes { + for (placeholder, _) in &self.draft.pending_pastes { if placeholder == &base { max_suffix = max_suffix.max(1); continue; @@ -1638,14 +1610,14 @@ impl ChatComposer { } pub(crate) fn insert_str(&mut self, text: &str) { - self.textarea.insert_str(text); + self.draft.textarea.insert_str(text); self.sync_bash_mode_from_text(); self.sync_popups(); } /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if !self.input_enabled { + if !self.draft.input_enabled { return (InputResult::None, false); } @@ -1685,13 +1657,13 @@ impl ChatComposer { return (InputResult::None, true); } if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; + let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running); + if next_mode != self.footer.mode { + self.footer.mode = next_mode; return (InputResult::None, true); } } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } let ActivePopup::Command(popup) = &mut self.popups.active else { unreachable!(); @@ -1733,7 +1705,7 @@ impl ChatComposer { } => { // Ensure popup filtering/selection reflects the latest composer text // before applying completion. - let first_line = self.textarea.text().lines().next().unwrap_or(""); + let first_line = self.draft.textarea.text().lines().next().unwrap_or(""); popup.on_composer_text_change(first_line.to_string()); if let Some(selected_cmd) = popup.selected_item() { let selected_command_text = format!("/{}", selected_cmd.command()); @@ -1741,18 +1713,21 @@ impl ChatComposer { && cmd == SlashCommand::Skills { self.stage_selected_slash_command_history(&CommandItem::Builtin(cmd)); - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; return (InputResult::Command(cmd), true); } let starts_with_cmd = first_line.trim_start().starts_with(&selected_command_text); if !starts_with_cmd { - self.textarea + self.draft + .textarea .set_text_clearing_elements(&format!("{selected_command_text} ")); - if !self.textarea.text().is_empty() { - self.textarea.set_cursor(self.textarea.text().len()); + if !self.draft.textarea.text().is_empty() { + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); } return (InputResult::None, true); } @@ -1769,19 +1744,22 @@ impl ChatComposer { } => { // Treat "/" as accepting the highlighted command as text completion // while the slash-command popup is active. - let first_line = self.textarea.text().lines().next().unwrap_or(""); + let first_line = self.draft.textarea.text().lines().next().unwrap_or(""); popup.on_composer_text_change(first_line.to_string()); if let Some(selected_cmd) = popup.selected_item() { let selected_command_text = format!("/{}", selected_cmd.command()); let starts_with_cmd = first_line.trim_start().starts_with(&selected_command_text); if !starts_with_cmd { - self.textarea + self.draft + .textarea .set_text_clearing_elements(&format!("{selected_command_text} ")); - self.is_bash_mode = false; + self.draft.is_bash_mode = false; } - if !self.textarea.text().is_empty() { - self.textarea.set_cursor(self.textarea.text().len()); + if !self.draft.textarea.text().is_empty() { + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); } } (InputResult::None, true) @@ -1793,8 +1771,8 @@ impl ChatComposer { } => { if let Some(sel) = popup.selected_item() { self.stage_selected_slash_command_history(&sel); - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; return ( match sel { CommandItem::Builtin(cmd) => InputResult::Command(cmd), @@ -1842,12 +1820,13 @@ impl ChatComposer { /// the cursor to a UTF-8 char boundary before slicing `textarea.text()`. #[inline] fn handle_non_ascii_char(&mut self, input: KeyEvent, now: Instant) -> (InputResult, bool) { - if self.disable_paste_burst { + if self.draft.disable_paste_burst { // When burst detection is disabled, treat IME/non-ASCII input as normal typing. // In particular, do not retro-capture or buffer already-inserted prefix text. - self.textarea.input(input); - let text_after = self.textarea.text(); - self.pending_pastes + self.draft.textarea.input(input); + let text_after = self.draft.textarea.text(); + self.draft + .pending_pastes .retain(|(placeholder, _)| text_after.contains(placeholder)); return (InputResult::None, true); } @@ -1856,7 +1835,7 @@ impl ChatComposer { .. } = input { - if self.paste_burst.try_append_char_if_active(ch, now) { + if self.draft.paste_burst.try_append_char_if_active(ch, now) { return (InputResult::None, true); } // Non-ASCII input often comes from IMEs and can arrive in quick bursts. @@ -1864,32 +1843,35 @@ impl ChatComposer { // still want to detect paste-like bursts. Before applying any non-ASCII input, flush // any existing burst buffer (including a pending first char from the ASCII path) so // we don't carry that transient state forward. - 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); } - if let Some(decision) = self.paste_burst.on_plain_char_no_hold(now) { + if let Some(decision) = self.draft.paste_burst.on_plain_char_no_hold(now) { match decision { CharDecision::BufferAppend => { - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } CharDecision::BeginBuffer { retro_chars } => { // For non-ASCII we inserted prior chars immediately, so if this turns out // to be paste-like we need to retroactively grab & remove the already- // inserted prefix from the textarea before buffering the burst. - let cur = self.textarea.cursor(); - let txt = self.textarea.text(); + let cur = self.draft.textarea.cursor(); + let txt = self.draft.textarea.text(); let safe_cur = Self::clamp_to_char_boundary(txt, cur); let before = &txt[..safe_cur]; - if let Some(grab) = - self.paste_burst - .decide_begin_buffer(now, before, retro_chars as usize) - { + if let Some(grab) = self.draft.paste_burst.decide_begin_buffer( + now, + before, + retro_chars as usize, + ) { if !grab.grabbed.is_empty() { - self.textarea.replace_range(grab.start_byte..safe_cur, ""); + self.draft + .textarea + .replace_range(grab.start_byte..safe_cur, ""); } // seed the paste burst buffer with everything (grabbed + new) - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } // If decide_begin_buffer opted not to start buffering, @@ -1899,13 +1881,14 @@ impl ChatComposer { } } } - 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.textarea.input(input); + self.draft.textarea.input(input); - let text_after = self.textarea.text(); - self.pending_pastes + let text_after = self.draft.textarea.text(); + self.draft + .pending_pastes .retain(|(placeholder, _)| text_after.contains(placeholder)); (InputResult::None, true) } @@ -1916,13 +1899,13 @@ impl ChatComposer { return (InputResult::None, true); } if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; + let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running); + if next_mode != self.footer.mode { + self.footer.mode = next_mode; return (InputResult::None, true); } } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } let ActivePopup::File(popup) = &mut self.popups.active else { unreachable!(); @@ -1956,7 +1939,7 @@ impl ChatComposer { code: KeyCode::Esc, .. } => { // Hide popup without modifying text, remember token to avoid immediate reopen. - if let Some(tok) = Self::current_at_token(&self.textarea) { + if let Some(tok) = Self::current_at_token(&self.draft.textarea) { self.popups.dismissed_file_token = Some(tok); } self.popups.active = ActivePopup::None; @@ -1990,8 +1973,8 @@ impl ChatComposer { tracing::debug!("selected image dimensions={}x{}", width, height); // Remove the current @token (mirror logic from insert_selected_path without inserting text) // using the flat text and byte-offset cursor API. - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); + let cursor_offset = self.draft.textarea.cursor(); + let text = self.draft.textarea.text(); // Clamp to a valid char boundary to avoid panics when slicing. let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); let before_cursor = &text[..safe_cursor]; @@ -2010,12 +1993,12 @@ impl ChatComposer { .unwrap_or(after_cursor.len()); let end_idx = safe_cursor + end_rel_idx; - self.textarea.replace_range(start_idx..end_idx, ""); - self.textarea.set_cursor(start_idx); + self.draft.textarea.replace_range(start_idx..end_idx, ""); + self.draft.textarea.set_cursor(start_idx); self.attach_image(path_buf); // Add a trailing space to keep typing fluid. - self.textarea.insert_str(" "); + self.draft.textarea.insert_str(" "); } Err(err) => { tracing::trace!("image dimensions lookup failed: {err}"); @@ -2038,7 +2021,7 @@ impl ChatComposer { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); } - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); let ActivePopup::Skill(popup) = &mut self.popups.active else { unreachable!(); @@ -2114,7 +2097,7 @@ impl ChatComposer { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); } - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); let ActivePopup::MentionV2(popup) = &mut self.popups.active else { unreachable!(); @@ -2219,8 +2202,8 @@ impl ChatComposer { match image::image_dimensions(&path_buf) { Ok((width, height)) => { tracing::debug!("selected image dimensions={}x{}", width, height); - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); + let cursor_offset = self.draft.textarea.cursor(); + let text = self.draft.textarea.text(); let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); let before_cursor = &text[..safe_cursor]; let after_cursor = &text[safe_cursor..]; @@ -2237,10 +2220,10 @@ impl ChatComposer { .unwrap_or(after_cursor.len()); let end_idx = safe_cursor + end_rel_idx; - self.textarea.replace_range(start_idx..end_idx, ""); - self.textarea.set_cursor(start_idx); + self.draft.textarea.replace_range(start_idx..end_idx, ""); + self.draft.textarea.set_cursor(start_idx); self.attach_image(path_buf); - self.textarea.insert_str(" "); + self.draft.textarea.insert_str(" "); } Err(err) => { tracing::trace!("image dimensions lookup failed: {err}"); @@ -2511,14 +2494,14 @@ impl ChatComposer { if !self.mentions_v2_enabled { return None; } - Self::current_prefixed_token(&self.textarea, '@', /*allow_empty*/ true) + Self::current_prefixed_token(&self.draft.textarea, '@', /*allow_empty*/ true) } fn current_mention_token(&self) -> Option { if !self.mentions_enabled() { return None; } - Self::current_prefixed_token(&self.textarea, '$', /*allow_empty*/ true) + Self::current_prefixed_token(&self.draft.textarea, '$', /*allow_empty*/ true) } /// Replace the active `@token` (the one under the cursor) with `path`. @@ -2527,8 +2510,8 @@ impl ChatComposer { /// where the cursor is within the token and regardless of how many /// `@tokens` exist in the line. fn insert_selected_path(&mut self, path: &str) { - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); + let cursor_offset = self.draft.textarea.cursor(); + let text = self.draft.textarea.text(); // Clamp to a valid char boundary to avoid panics when slicing. let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); @@ -2561,15 +2544,16 @@ impl ChatComposer { // Replace just the active `@token` so unrelated text elements, such as // large-paste placeholders, remain atomic and can still expand on submit. - self.textarea + self.draft + .textarea .replace_range(start_idx..end_idx, &format!("{inserted} ")); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); - self.textarea.set_cursor(new_cursor); + self.draft.textarea.set_cursor(new_cursor); } fn insert_selected_mention(&mut self, insert_text: &str, path: Option<&str>) { - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); + let cursor_offset = self.draft.textarea.cursor(); + let text = self.draft.textarea.text(); let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); let before_cursor = &text[..safe_cursor]; @@ -2589,14 +2573,14 @@ impl ChatComposer { let end_idx = safe_cursor + end_rel_idx; // Remove the active token and insert the selected mention as an atomic element. - self.textarea.replace_range(start_idx..end_idx, ""); - self.textarea.set_cursor(start_idx); - let id = self.textarea.insert_element(insert_text); + self.draft.textarea.replace_range(start_idx..end_idx, ""); + self.draft.textarea.set_cursor(start_idx); + let id = self.draft.textarea.insert_element(insert_text); if let (Some(path), Some(mention)) = (path, Self::mention_name_from_insert_text(insert_text)) { - self.mention_bindings.insert( + self.draft.mention_bindings.insert( id, ComposerMentionBinding { mention, @@ -2605,11 +2589,11 @@ impl ChatComposer { ); } - self.textarea.insert_str(" "); + self.draft.textarea.insert_str(" "); let new_cursor = start_idx .saturating_add(insert_text.len()) .saturating_add(1); - self.textarea.set_cursor(new_cursor); + self.draft.textarea.set_cursor(new_cursor); } fn mention_name_from_insert_text(insert_text: &str) -> Option { @@ -2629,7 +2613,8 @@ impl ChatComposer { } fn current_mention_elements(&self) -> Vec<(u64, String)> { - self.textarea + self.draft + .textarea .text_element_snapshots() .into_iter() .filter_map(|snapshot| { @@ -2642,7 +2627,7 @@ impl ChatComposer { fn snapshot_mention_bindings(&self) -> Vec { let mut ordered = Vec::new(); for (id, mention) in self.current_mention_elements() { - if let Some(binding) = self.mention_bindings.get(&id) + if let Some(binding) = self.draft.mention_bindings.get(&id) && binding.mention == mention { ordered.push(MentionBinding { @@ -2655,12 +2640,12 @@ impl ChatComposer { } fn bind_mentions_from_snapshot(&mut self, mention_bindings: Vec) { - self.mention_bindings.clear(); + self.draft.mention_bindings.clear(); if mention_bindings.is_empty() { return; } - let text = self.textarea.text().to_string(); + let text = self.draft.textarea.text().to_string(); let mut scan_from = 0usize; for binding in mention_bindings { let token = format!("${}", binding.mention); @@ -2670,14 +2655,16 @@ impl ChatComposer { continue; }; - let id = if let Some(id) = self.textarea.add_element_range(range.clone()) { + let id = if let Some(id) = self.draft.textarea.add_element_range(range.clone()) { Some(id) } else { - self.textarea.element_id_for_exact_range(range.clone()) + self.draft + .textarea + .element_id_for_exact_range(range.clone()) }; if let Some(id) = id { - self.mention_bindings.insert( + self.draft.mention_bindings.insert( id, ComposerMentionBinding { mention: binding.mention, @@ -2710,17 +2697,17 @@ impl ChatComposer { let original_text_elements = self.current_text_elements(); let original_mention_bindings = self.snapshot_mention_bindings(); let original_local_image_paths = self.attachments.local_image_paths(); - let original_pending_pastes = self.pending_pastes.clone(); + let original_pending_pastes = self.draft.pending_pastes.clone(); let mut text_elements = original_text_elements.clone(); let input_starts_with_space = original_input.starts_with(' '); - self.recent_submission_mention_bindings.clear(); - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; + self.draft.recent_submission_mention_bindings.clear(); + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; - if !self.pending_pastes.is_empty() { + if !self.draft.pending_pastes.is_empty() { // Expand placeholders so element byte ranges stay aligned. let (expanded, expanded_elements) = - Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); + Self::expand_pending_pastes(&text, text_elements, &self.draft.pending_pastes); text = expanded; text_elements = expanded_elements; } @@ -2756,8 +2743,10 @@ impl ChatComposer { original_local_image_paths, original_mention_bindings, ); - self.pending_pastes.clone_from(&original_pending_pastes); - self.textarea.set_cursor(original_input.len()); + self.draft + .pending_pastes + .clone_from(&original_pending_pastes); + self.draft.textarea.set_cursor(original_input.len()); return None; } } @@ -2775,8 +2764,10 @@ impl ChatComposer { original_local_image_paths, original_mention_bindings, ); - self.pending_pastes.clone_from(&original_pending_pastes); - self.textarea.set_cursor(original_input.len()); + self.draft + .pending_pastes + .clone_from(&original_pending_pastes); + self.draft.textarea.set_cursor(original_input.len()); return None; } self.attachments @@ -2784,7 +2775,7 @@ impl ChatComposer { if text.is_empty() && self.attachments.is_empty() { return None; } - self.recent_submission_mention_bindings = original_mention_bindings.clone(); + self.draft.recent_submission_mention_bindings = original_mention_bindings.clone(); if record_history && (!text.is_empty() || !self.attachments.is_empty()) { self.history.record_local_submission(HistoryEntry { text: text.clone(), @@ -2795,7 +2786,7 @@ impl ChatComposer { pending_pastes: Vec::new(), }); } - self.pending_pastes.clear(); + self.draft.pending_pastes.clear(); Some((text, text_elements)) } @@ -2816,7 +2807,7 @@ impl ChatComposer { | InputResult::ServiceTierCommand(_) | InputResult::CommandWithArgs(_, _, _) ) { - self.textarea.enter_vim_normal_mode(); + self.draft.textarea.enter_vim_normal_mode(); } } @@ -2826,7 +2817,7 @@ impl ChatComposer { now: Instant, ) -> (InputResult, bool) { if should_queue { - let raw_text = self.textarea.text(); + let raw_text = self.draft.textarea.text(); let defer_slash_validation = self.should_parse_as_slash_on_dequeue_from_raw_text(raw_text); if let Some((text, text_elements)) = self.prepare_submission_text_with_options( @@ -2865,32 +2856,34 @@ impl ChatComposer { // and accumulate it rather than submitting or inserting immediately. // Do not treat as paste inside a slash-command context. let in_slash_context = self.slash_commands_enabled() - && !self.is_bash_mode + && !self.draft.is_bash_mode && (matches!(self.popups.active, ActivePopup::Command(_)) || self + .draft .textarea .text() .lines() .next() .unwrap_or("") .starts_with('/')); - if !self.disable_paste_burst - && self.paste_burst.is_active() + if !self.draft.disable_paste_burst + && self.draft.paste_burst.is_active() && !in_slash_context - && self.paste_burst.append_newline_if_active(now) + && self.draft.paste_burst.append_newline_if_active(now) { return (InputResult::None, true); } // During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit. if !in_slash_context - && !self.disable_paste_burst + && !self.draft.disable_paste_burst && self + .draft .paste_burst .newline_should_insert_instead_of_submit(now) { - self.textarea.insert_str("\n"); - self.paste_burst.extend_window(now); + self.draft.textarea.insert_str("\n"); + self.draft.paste_burst.extend_window(now); return (InputResult::None, true); } @@ -2898,7 +2891,7 @@ impl ChatComposer { let original_text_elements = self.current_text_elements(); let original_mention_bindings = self.snapshot_mention_bindings(); let original_local_image_paths = self.attachments.local_image_paths(); - let original_pending_pastes = self.pending_pastes.clone(); + let original_pending_pastes = self.draft.pending_pastes.clone(); if let Some(result) = self.try_dispatch_slash_command_with_args() { return (result, true); } @@ -2934,7 +2927,7 @@ impl ChatComposer { original_local_image_paths, original_mention_bindings, ); - self.pending_pastes = original_pending_pastes; + self.draft.pending_pastes = original_pending_pastes; (InputResult::None, true) } } @@ -2942,10 +2935,10 @@ impl ChatComposer { /// Check if the first line is a bare slash command (no args) and dispatch it. /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_bare_slash_command(&mut self) -> Option { - if !self.slash_commands_enabled() || self.is_bash_mode { + if !self.slash_commands_enabled() || self.draft.is_bash_mode { return None; } - let text = self.textarea.text(); + let text = self.draft.textarea.text(); let first_line = text.lines().next().unwrap_or(""); let (name, rest, _rest_offset) = parse_slash_name(first_line)?; if !rest.is_empty() { @@ -2967,8 +2960,8 @@ impl ChatComposer { return Some(InputResult::None); } self.stage_slash_command_history(&command); - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; Some(match command { SlashCommandItem::Builtin(cmd) => InputResult::Command(cmd), SlashCommandItem::ServiceTier(command) => InputResult::ServiceTierCommand(command), @@ -2978,10 +2971,10 @@ impl ChatComposer { /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_slash_command_with_args(&mut self) -> Option { - if !self.slash_commands_enabled() || self.is_bash_mode { + if !self.slash_commands_enabled() || self.draft.is_bash_mode { return None; } - let text = self.textarea.text().to_string(); + let text = self.draft.textarea.text().to_string(); if text.starts_with(' ') { return None; } @@ -3008,8 +3001,11 @@ impl ChatComposer { self.stage_slash_command_history(&command); - let mut args_elements = - Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements()); + let mut args_elements = Self::slash_command_args_elements( + rest, + rest_offset, + &self.draft.textarea.text_elements(), + ); let trimmed_rest = rest.trim(); args_elements = Self::trim_text_elements(rest, trimmed_rest, args_elements); let SlashCommandItem::Builtin(cmd) = command else { @@ -3089,7 +3085,7 @@ impl ChatComposer { if matches!(command, SlashCommandItem::Builtin(SlashCommand::Clear)) { return; } - self.stage_slash_command_history_text(self.textarea.text().trim().to_string()); + self.stage_slash_command_history_text(self.draft.textarea.text().trim().to_string()); } /// Stage a popup-selected command using its canonical command text. @@ -3111,11 +3107,11 @@ impl ChatComposer { fn stage_slash_command_history_text(&mut self, text: String) { self.pending_slash_command_history = Some(HistoryEntry { text, - text_elements: self.textarea.text_elements(), + text_elements: self.draft.textarea.text_elements(), local_image_paths: self.attachments.local_image_paths(), remote_image_urls: self.attachments.remote_image_urls(), mention_bindings: self.snapshot_mention_bindings(), - pending_pastes: self.pending_pastes.clone(), + pending_pastes: self.draft.pending_pastes.clone(), }); } @@ -3152,7 +3148,7 @@ impl ChatComposer { key_event: &KeyEvent, ) -> Option<(InputResult, bool)> { self.attachments - .handle_remote_image_selection_key(key_event, &mut self.textarea) + .handle_remote_image_selection_key(key_event, &mut self.draft.textarea) } /// Handle key event when no popup is visible. @@ -3166,22 +3162,23 @@ impl ChatComposer { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); } - if self.is_bash_mode && key_event.code == KeyCode::Esc { - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + if self.draft.is_bash_mode && key_event.code == KeyCode::Esc { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } - if self.textarea.is_empty() { - self.is_bash_mode = false; + if self.draft.textarea.is_empty() { + self.draft.is_bash_mode = false; return (InputResult::None, true); } } if self.should_handle_vim_insert_escape(key_event) { return self.handle_input_basic(key_event); } - if self.textarea.is_vim_normal_mode() && self.textarea.is_vim_operator_pending() { + if self.draft.textarea.is_vim_normal_mode() && self.draft.textarea.is_vim_operator_pending() + { return self.handle_input_basic(key_event); } - if self.textarea.is_vim_normal_mode() + if self.draft.textarea.is_vim_normal_mode() && self.is_empty() && matches!( key_event, @@ -3193,13 +3190,15 @@ impl ChatComposer { } ) { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - self.textarea.set_text_clearing_elements("/"); - self.textarea.set_cursor(self.textarea.text().len()); - self.textarea.enter_vim_insert_mode(); + self.footer.mode = reset_mode_after_activity(self.footer.mode); + self.draft.textarea.set_text_clearing_elements("/"); + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); + self.draft.textarea.enter_vim_insert_mode(); return (InputResult::None, true); } - if self.textarea.is_vim_normal_mode() + if self.draft.textarea.is_vim_normal_mode() && self.is_empty() && matches!( key_event, @@ -3211,21 +3210,21 @@ impl ChatComposer { } ) { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - self.is_bash_mode = true; - self.textarea.enter_vim_insert_mode(); + self.footer.mode = reset_mode_after_activity(self.footer.mode); + self.draft.is_bash_mode = true; + self.draft.textarea.enter_vim_insert_mode(); return (InputResult::None, true); } if key_event.code == KeyCode::Esc { if self.is_empty() { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; + let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running); + if next_mode != self.footer.mode { + self.footer.mode = next_mode; return (InputResult::None, true); } } } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } if self.queue_keys.is_pressed(key_event) && (self.is_task_running || !self.is_bang_shell_command()) @@ -3248,8 +3247,9 @@ impl ChatComposer { return (InputResult::None, false); } - let (history_up_pressed, history_down_pressed) = if self.textarea.is_vim_normal_mode() { - if self.textarea.is_vim_operator_pending() { + let (history_up_pressed, history_down_pressed) = if self.draft.textarea.is_vim_normal_mode() + { + if self.draft.textarea.is_vim_operator_pending() { (false, false) } else { ( @@ -3303,7 +3303,7 @@ impl ChatComposer { /// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render. /// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag. fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { - match self.paste_burst.flush_if_due(now) { + match self.draft.paste_burst.flush_if_due(now) { FlushResult::Paste(pasted) => { self.handle_paste(pasted); true @@ -3350,14 +3350,14 @@ impl ChatComposer { self.handle_paste_burst_flush(now); if !matches!(input.code, KeyCode::Esc) { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } // If we're capturing a burst and receive Enter, accumulate it instead of inserting. if matches!(input.code, KeyCode::Enter) - && !self.disable_paste_burst - && self.paste_burst.is_active() - && self.paste_burst.append_newline_if_active(now) + && !self.draft.disable_paste_burst + && self.draft.paste_burst.is_active() + && self.draft.paste_burst.append_newline_if_active(now) { return (InputResult::None, true); } @@ -3374,31 +3374,37 @@ impl ChatComposer { } = input { let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); - if !has_ctrl_or_alt && !self.disable_paste_burst && self.textarea.allows_paste_burst() { + if !has_ctrl_or_alt + && !self.draft.disable_paste_burst + && self.draft.textarea.allows_paste_burst() + { // Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid // holding the first char while still allowing burst detection for paste input. if !ch.is_ascii() { return self.handle_non_ascii_char(input, now); } - match self.paste_burst.on_plain_char(ch, now) { + match self.draft.paste_burst.on_plain_char(ch, now) { CharDecision::BufferAppend => { - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } CharDecision::BeginBuffer { retro_chars } => { - let cur = self.textarea.cursor(); - let txt = self.textarea.text(); + let cur = self.draft.textarea.cursor(); + let txt = self.draft.textarea.text(); let safe_cur = Self::clamp_to_char_boundary(txt, cur); let before = &txt[..safe_cur]; - if let Some(grab) = - self.paste_burst - .decide_begin_buffer(now, before, retro_chars as usize) - { + if let Some(grab) = self.draft.paste_burst.decide_begin_buffer( + now, + before, + retro_chars as usize, + ) { if !grab.grabbed.is_empty() { - self.textarea.replace_range(grab.start_byte..safe_cur, ""); + self.draft + .textarea + .replace_range(grab.start_byte..safe_cur, ""); } - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } // If decide_begin_buffer opted not to start buffering, @@ -3406,7 +3412,7 @@ impl ChatComposer { } CharDecision::BeginBufferFromPending => { // First char was held; now append the current one. - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } CharDecision::RetainFirstChar => { @@ -3415,7 +3421,7 @@ impl ChatComposer { } } } - 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); } } @@ -3427,28 +3433,29 @@ impl ChatComposer { // time out against, and the buffered paste could remain stuck until another plain char // arrives. if !matches!(input.code, KeyCode::Char(_) | KeyCode::Enter) - && let Some(pasted) = self.paste_burst.flush_before_modified_input() + && let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } // For non-char inputs (or after flushing), handle normally. // Track element removals so we can drop any corresponding placeholders without scanning // the full text. (Placeholders are atomic elements; when deleted, the element disappears.) - let elements_before = if self.pending_pastes.is_empty() && self.attachments.is_empty() { + let elements_before = if self.draft.pending_pastes.is_empty() && self.attachments.is_empty() + { None } else { - Some(self.textarea.element_payloads()) + Some(self.draft.textarea.element_payloads()) }; - if self.is_bash_mode + if self.draft.is_bash_mode && matches!(input.code, KeyCode::Backspace) - && self.textarea.cursor() == 0 + && self.draft.textarea.cursor() == 0 { - self.is_bash_mode = false; + self.draft.is_bash_mode = false; return (InputResult::None, true); } - self.textarea.input(input); + self.draft.textarea.input(input); self.sync_bash_mode_from_text(); if let Some(elements_before) = elements_before { @@ -3463,7 +3470,7 @@ impl ChatComposer { KeyCode::Char(_) => { let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); if has_ctrl_or_alt { - self.paste_burst.clear_window_after_non_char(); + self.draft.paste_burst.clear_window_after_non_char(); } } KeyCode::Enter => { @@ -3471,7 +3478,7 @@ impl ChatComposer { } _ => { // Other keys: clear burst window (buffer should have been flushed above if needed). - self.paste_burst.clear_window_after_non_char(); + self.draft.paste_burst.clear_window_after_non_char(); } } @@ -3479,25 +3486,25 @@ impl ChatComposer { } fn sync_bash_mode_from_text(&mut self) { - if !self.is_bash_mode && self.textarea.text().starts_with('!') { - self.textarea.replace_range(0..1, ""); - self.is_bash_mode = true; + if !self.draft.is_bash_mode && self.draft.textarea.text().starts_with('!') { + self.draft.textarea.replace_range(0..1, ""); + self.draft.is_bash_mode = true; } } fn reconcile_deleted_elements(&mut self, elements_before: Vec) { let elements_after: HashSet = - self.textarea.element_payloads().into_iter().collect(); + self.draft.textarea.element_payloads().into_iter().collect(); let removed_payloads = elements_before .into_iter() .filter(|payload| !elements_after.contains(payload)) .collect::>(); for removed in &removed_payloads { - self.pending_pastes.retain(|(ph, _)| ph != removed); + self.draft.pending_pastes.retain(|(ph, _)| ph != removed); } self.attachments - .remove_deleted_local_placeholders(&removed_payloads, &mut self.textarea); + .remove_deleted_local_placeholders(&removed_payloads, &mut self.draft.textarea); } /// Handle the dedicated shortcut-overlay toggle key(s). @@ -3520,12 +3527,12 @@ impl ChatComposer { } let next = toggle_shortcut_mode( - self.footer_mode, + self.footer.mode, self.quit_shortcut_hint_visible(), self.is_empty(), ); - let changed = next != self.footer_mode; - self.footer_mode = next; + let changed = next != self.footer.mode; + self.footer.mode = next; changed } @@ -3544,26 +3551,26 @@ impl ChatComposer { FooterProps { mode, - esc_backtrack_hint: self.esc_backtrack_hint, - use_shift_enter_hint: self.use_shift_enter_hint, + esc_backtrack_hint: self.footer.esc_backtrack_hint, + use_shift_enter_hint: self.footer.use_shift_enter_hint, is_task_running: self.is_task_running, - quit_shortcut_key: self.quit_shortcut_key, + quit_shortcut_key: self.footer.quit_shortcut_key, collaboration_modes_enabled: self.collaboration_modes_enabled, is_wsl, - status_line_value: self.status_line_value.clone(), - status_line_enabled: self.status_line_enabled, + status_line_value: self.footer.status_line_value.clone(), + status_line_enabled: self.footer.status_line_enabled, key_hints: FooterKeyHints { - toggle_shortcuts: self.footer_toggle_shortcuts_key, - queue: self.footer_queue_key, - insert_newline: self.footer_insert_newline_key, - external_editor: self.footer_external_editor_key, + toggle_shortcuts: self.footer.toggle_shortcuts_key, + queue: self.footer.queue_key, + insert_newline: self.footer.insert_newline_key, + external_editor: self.footer.external_editor_key, edit_previous: Some(key_hint::plain(KeyCode::Esc)), - show_transcript: self.footer_show_transcript_key, - history_search: self.footer_history_search_key, - reasoning_down: self.footer_reasoning_down_key, - reasoning_up: self.footer_reasoning_up_key, + show_transcript: self.footer.show_transcript_key, + history_search: self.footer.history_search_key, + reasoning_down: self.footer.reasoning_down_key, + reasoning_up: self.footer.reasoning_up_key, }, - active_agent_label: self.active_agent_label.clone(), + active_agent_label: self.footer.active_agent_label.clone(), } } @@ -3584,7 +3591,7 @@ impl ChatComposer { FooterMode::ComposerHasDraft }; - match self.footer_mode { + match self.footer.mode { FooterMode::HistorySearch => FooterMode::HistorySearch, FooterMode::EscHint => FooterMode::EscHint, FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, @@ -3602,10 +3609,11 @@ impl ChatComposer { } fn custom_footer_height(&self) -> Option { - if self.footer_flash_visible() { + if self.footer.flash_visible() { return Some(1); } - self.footer_hint_override + self.footer + .hint_override .as_ref() .map(|items| if items.is_empty() { 0 } else { 1 }) } @@ -3631,7 +3639,7 @@ impl ChatComposer { let file_token = if self.mentions_v2_enabled { None } else { - Self::current_at_token(&self.textarea) + Self::current_at_token(&self.draft.textarea) }; let browsing_history = self .history @@ -3650,7 +3658,7 @@ impl ChatComposer { let mention_token = self.current_mention_token(); let allow_command_popup = self.slash_commands_enabled() - && !self.is_bash_mode + && !self.draft.is_bash_mode && file_token.is_none() && mentions_v2_token.is_none() && mention_token.is_none(); @@ -3707,7 +3715,7 @@ impl ChatComposer { if !self.slash_commands_enabled() { return; } - let text = self.textarea.text(); + let text = self.draft.textarea.text(); let first_line_end = text.find('\n').unwrap_or(text.len()); let first_line = &text[..first_line_end]; let desired_range = self.slash_command_element_range(first_line); @@ -3715,7 +3723,7 @@ impl ChatComposer { // Any slash-shaped element not matching the current desired prefix is stale. let mut has_desired = false; let mut stale_ranges = Vec::new(); - for elem in self.textarea.text_elements() { + for elem in self.draft.textarea.text_elements() { let Some(payload) = elem.placeholder(text) else { continue; }; @@ -3731,18 +3739,18 @@ impl ChatComposer { } for range in stale_ranges { - self.textarea.remove_element_range(range); + self.draft.textarea.remove_element_range(range); } if let Some(range) = desired_range && !has_desired { - self.textarea.add_element_range(range); + self.draft.textarea.add_element_range(range); } } fn slash_command_element_range(&self, first_line: &str) -> Option> { - if self.is_bash_mode { + if self.draft.is_bash_mode { return None; } let (name, _rest, _rest_offset) = parse_slash_name(first_line)?; @@ -3830,10 +3838,10 @@ impl ChatComposer { return; } // Determine whether the caret is inside the initial '/name' token on the first line. - let text = self.textarea.text(); + let text = self.draft.textarea.text(); let first_line_end = text.find('\n').unwrap_or(text.len()); let first_line = &text[..first_line_end]; - let cursor = self.textarea.cursor(); + let cursor = self.draft.textarea.cursor(); let caret_on_first_line = cursor <= first_line_end; let is_editing_slash_command_name = caret_on_first_line @@ -3843,7 +3851,7 @@ impl ChatComposer { // If the cursor is currently positioned within an `@token`, prefer the // file-search popup over the slash popup so users can insert a file path // as an argument to the command (e.g., "/review @docs/..."). - if Self::current_at_token(&self.textarea).is_some() { + if Self::current_at_token(&self.draft.textarea).is_some() { if matches!(self.popups.active, ActivePopup::Command(_)) { self.popups.active = ActivePopup::None; } @@ -4113,8 +4121,8 @@ impl ChatComposer { #[allow(dead_code)] pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { - self.input_enabled = enabled; - self.input_disabled_placeholder = if enabled { None } else { placeholder }; + self.draft.input_enabled = enabled; + self.draft.input_disabled_placeholder = if enabled { None } else { placeholder }; // Avoid leaving interactive popups open while input is blocked. if !enabled && self.popups.active() { @@ -4127,52 +4135,53 @@ impl ChatComposer { } pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { - if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens + if self.footer.context_window_percent == percent + && self.footer.context_window_used_tokens == used_tokens { return; } - self.context_window_percent = percent; - self.context_window_used_tokens = used_tokens; + self.footer.context_window_percent = percent; + self.footer.context_window_used_tokens = used_tokens; } pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { - self.esc_backtrack_hint = show; + self.footer.esc_backtrack_hint = show; if show { - self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + self.footer.mode = esc_hint_mode(self.footer.mode, self.is_task_running); } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } } pub(crate) fn set_status_line(&mut self, status_line: Option>) -> bool { - if self.status_line_value == status_line { + if self.footer.status_line_value == status_line { return false; } - self.status_line_value = status_line; + self.footer.status_line_value = status_line; true } pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) -> bool { - if self.status_line_hyperlink_url == url { + if self.footer.status_line_hyperlink_url == url { return false; } - self.status_line_hyperlink_url = url; + self.footer.status_line_hyperlink_url = url; true } pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) -> bool { - if self.status_line_enabled == enabled { + if self.footer.status_line_enabled == enabled { return false; } - self.status_line_enabled = enabled; + self.footer.status_line_enabled = enabled; true } pub(crate) fn set_side_conversation_context_label(&mut self, label: Option) -> bool { - if self.side_conversation_context_label == label { + if self.footer.side_conversation_context_label == label { return false; } - self.side_conversation_context_label = label; + self.footer.side_conversation_context_label = label; true } @@ -4182,10 +4191,10 @@ impl ChatComposer { /// field is intentionally just cached presentation state; `ChatComposer` does not infer which /// thread is active on its own. pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option) -> bool { - if self.active_agent_label == active_agent_label { + if self.footer.active_agent_label == active_agent_label { return false; } - self.active_agent_label = active_agent_label; + self.footer.active_agent_label = active_agent_label; true } } @@ -4210,17 +4219,17 @@ fn footer_insert_newline_key( #[cfg(not(target_os = "linux"))] impl ChatComposer { pub fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool { - self.textarea.update_named_element_by_id(id, text) + self.draft.textarea.update_named_element_by_id(id, text) } pub fn insert_recording_meter_placeholder(&mut self, text: &str) -> String { let id = self.next_id(); - self.textarea.insert_named_element(text, id.clone()); + self.draft.textarea.insert_named_element(text, id.clone()); id } pub fn remove_recording_meter_placeholder(&mut self, id: &str) { - let _ = self.textarea.replace_element_by_id(id, ""); + let _ = self.draft.textarea.replace_element_by_id(id, ""); } } @@ -4281,7 +4290,7 @@ impl Renderable for ChatComposer { } fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle { - if self.textarea.uses_vim_insert_cursor() { + if self.draft.textarea.uses_vim_insert_cursor() { crossterm::cursor::SetCursorStyle::SteadyBar } else { crossterm::cursor::SetCursorStyle::DefaultUserShape @@ -4319,7 +4328,7 @@ impl ChatComposer { .try_into() .unwrap_or(u16::MAX); let remote_images_separator = u16::from(remote_images_height > 0); - self.textarea.desired_height(inner_width) + self.draft.textarea.desired_height(inner_width) + remote_images_height + remote_images_separator + 2 @@ -4364,8 +4373,8 @@ impl ChatComposer { } ActivePopup::None => { let footer_props = self.footer_props(); - let show_cycle_hint = - !footer_props.is_task_running && self.collaboration_mode_indicator.is_some(); + let show_cycle_hint = !footer_props.is_task_running + && self.footer.collaboration_mode_indicator.is_some(); let show_shortcuts_hint = match footer_props.mode { FooterMode::ComposerEmpty => !self.is_in_paste_burst(), FooterMode::ComposerHasDraft => false, @@ -4398,7 +4407,7 @@ impl ChatComposer { }; if let Some(line) = self.history_search_footer_line() { render_footer_line(hint_rect, buf, line); - } else if self.plan_mode_nudge_visible { + } else if self.footer.plan_mode_nudge_visible { let available_width = hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize; render_footer_line( @@ -4428,11 +4437,12 @@ impl ChatComposer { let left_mode_indicator = if status_line_active { None } else { - self.collaboration_mode_indicator + self.footer.collaboration_mode_indicator }; - let active_footer_hint_override = self.footer_hint_override.as_ref(); - let mut left_width = if self.footer_flash_visible() { - self.footer_flash + let active_footer_hint_override = self.footer.hint_override.as_ref(); + let mut left_width = if self.footer.flash_visible() { + self.footer + .flash .as_ref() .map(|flash| flash.line.width() as u16) .unwrap_or(0) @@ -4453,7 +4463,7 @@ impl ChatComposer { ) }; let right_line = - if let Some(label) = self.side_conversation_context_label.as_ref() { + if let Some(label) = self.footer.side_conversation_context_label.as_ref() { Some(side_conversation_context_line(label)) } else if let Some(line) = self.shell_mode_footer_line() { Some(line) @@ -4483,7 +4493,7 @@ impl ChatComposer { let can_show_left_and_context = can_show_left_with_context(hint_rect, left_width, right_width); let has_override = - self.footer_flash_visible() || active_footer_hint_override.is_some(); + self.footer.flash_visible() || active_footer_hint_override.is_some(); let single_line_layout = if has_override || status_line_active { None } else { @@ -4558,8 +4568,8 @@ impl ChatComposer { } SummaryLeft::None => {} } - } else if self.footer_flash_visible() { - if let Some(flash) = self.footer_flash.as_ref() { + } else if self.footer.flash_visible() { + if let Some(flash) = self.footer.flash.as_ref() { flash.line.render(inset_footer_hint_area(hint_rect), buf); } } else if let Some(items) = active_footer_hint_override { @@ -4573,7 +4583,7 @@ impl ChatComposer { hint_rect, buf, &footer_props, - self.collaboration_mode_indicator, + self.footer.collaboration_mode_indicator, show_cycle_hint, show_shortcuts_hint, show_queue_hint, @@ -4583,7 +4593,7 @@ impl ChatComposer { render_context_right(hint_rect, buf, line); } if status_line_active - && let Some(url) = self.status_line_hyperlink_url.as_deref() + && let Some(url) = self.footer.status_line_hyperlink_url.as_deref() { mark_underlined_hyperlink(buf, hint_rect, url); } @@ -4598,8 +4608,8 @@ impl ChatComposer { .render_ref(remote_images_rect, buf); } if !textarea_rect.is_empty() { - let prompt = if self.input_enabled { - if self.is_bash_mode { + let prompt = if self.draft.input_enabled { + if self.draft.is_bash_mode { Span::from("!").light_red().bold() } else { "›".bold() @@ -4615,15 +4625,21 @@ impl ChatComposer { ); } - let mut state = self.textarea_state.borrow_mut(); - let textarea_is_empty = self.textarea.text().is_empty() && !self.is_bash_mode; + let mut state = self.draft.textarea_state.borrow_mut(); + let textarea_is_empty = self.draft.textarea.text().is_empty() && !self.draft.is_bash_mode; if let Some(mask_char) = mask_char { - self.textarea + self.draft + .textarea .render_ref_masked(textarea_rect, buf, &mut state, mask_char); } else { let highlight_ranges = self.history_search_highlight_ranges(); if highlight_ranges.is_empty() { - StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + StatefulWidgetRef::render_ref( + &(&self.draft.textarea), + textarea_rect, + buf, + &mut state, + ); } else { let highlight_style = Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); @@ -4631,7 +4647,7 @@ impl ChatComposer { .into_iter() .map(|range| (range, highlight_style)) .collect::>(); - self.textarea.render_ref_styled_with_highlights( + self.draft.textarea.render_ref_styled_with_highlights( textarea_rect, buf, &mut state, @@ -4641,10 +4657,11 @@ impl ChatComposer { } } if textarea_is_empty { - let text = if self.input_enabled { + let text = if self.draft.input_enabled { self.placeholder_text.as_str().to_string() } else { - self.input_disabled_placeholder + self.draft + .input_disabled_placeholder .as_deref() .unwrap_or("Input disabled.") .to_string() @@ -4788,8 +4805,8 @@ mod tests { let id = composer.insert_recording_meter_placeholder("⠤⠤⠤⠤"); composer.remove_recording_meter_placeholder(&id); - assert_eq!(composer.textarea.text(), ""); - assert!(composer.textarea.named_element_range(&id).is_none()); + assert_eq!(composer.draft.textarea.text(), ""); + assert!(composer.draft.textarea.named_element_range(&id).is_none()); } #[test] @@ -4805,7 +4822,7 @@ mod tests { ); composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); - composer.footer_flash.as_mut().unwrap().expires_at = + composer.footer.flash.as_mut().unwrap().expires_at = Instant::now() - Duration::from_secs(1); let area = Rect::new(0, 0, 60, 6); @@ -5114,7 +5131,7 @@ mod tests { ); type_chars_humanlike(&mut composer, &['!']); - assert!(composer.is_bash_mode); + assert!(composer.draft.is_bash_mode); assert_eq!(composer.current_text(), "!"); let (result, needs_redraw) = @@ -5122,7 +5139,7 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert!(!composer.is_bash_mode); + assert!(!composer.draft.is_bash_mode); assert_eq!(composer.current_text(), ""); } @@ -5152,7 +5169,7 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert!(composer.is_bash_mode); + assert!(composer.draft.is_bash_mode); assert_eq!(composer.current_text(), "!g"); } @@ -5415,13 +5432,13 @@ mod tests { assert!(!composer.is_empty()); assert_eq!(composer.current_text(), "d"); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); assert!(matches!(composer.popups.active, ActivePopup::None)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); - assert!(!composer.esc_backtrack_hint); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); + assert!(!composer.footer.esc_backtrack_hint); } #[test] @@ -5458,8 +5475,8 @@ mod tests { composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) ); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); - assert!(!composer.esc_backtrack_hint); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); + assert!(!composer.footer.esc_backtrack_hint); } #[test] @@ -5484,8 +5501,8 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert_eq!(composer.textarea.text(), "/"); - assert_eq!(composer.textarea.cursor(), "/".len()); + assert_eq!(composer.draft.textarea.text(), "/"); + assert_eq!(composer.draft.textarea.cursor(), "/".len()); assert!(matches!(composer.popups.active, ActivePopup::Command(_))); assert_eq!( composer.vim_mode_indicator_span(), @@ -5513,7 +5530,7 @@ mod tests { for ch in ['/', 'd', 'i', 'f', 'f'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert_eq!(composer.textarea.text(), "/diff"); + assert_eq!(composer.draft.textarea.text(), "/diff"); assert!(matches!(composer.popups.active, ActivePopup::Command(_))); let (result, needs_redraw) = @@ -5589,9 +5606,9 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert!(composer.is_bash_mode); + assert!(composer.draft.is_bash_mode); assert_eq!(composer.current_text(), "!"); - assert_eq!(composer.textarea.text(), ""); + assert_eq!(composer.draft.textarea.text(), ""); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Insert".green()) @@ -5619,9 +5636,9 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert!(composer.is_bash_mode); + assert!(composer.draft.is_bash_mode); assert_eq!(composer.current_text(), "!echo"); - assert_eq!(composer.textarea.text(), "echo"); + assert_eq!(composer.draft.textarea.text(), "echo"); assert!(matches!(composer.popups.active, ActivePopup::None)); } @@ -5642,7 +5659,7 @@ mod tests { type_chars_humanlike(&mut composer, &['d']); composer .show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), /*has_focus*/ true); - composer.quit_shortcut_expires_at = + composer.footer.quit_shortcut_expires_at = Some(Instant::now() - std::time::Duration::from_secs(1)); assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft); @@ -5693,9 +5710,9 @@ mod tests { composer.handle_paste(large.clone()); let char_count = large.chars().count(); let placeholder = format!("[Pasted Content {char_count} chars]"); - assert_eq!(composer.textarea.text(), placeholder); + assert_eq!(composer.draft.textarea.text(), placeholder); assert_eq!( - composer.pending_pastes, + composer.draft.pending_pastes, vec![(placeholder.clone(), large.clone())] ); @@ -5721,9 +5738,15 @@ mod tests { ); composer.apply_history_entry(history_entry); - assert_eq!(composer.textarea.text(), placeholder); - assert_eq!(composer.pending_pastes, vec![(placeholder.clone(), large)]); - assert_eq!(composer.textarea.element_payloads(), vec![placeholder]); + assert_eq!(composer.draft.textarea.text(), placeholder); + assert_eq!( + composer.draft.pending_pastes, + vec![(placeholder.clone(), large)] + ); + assert_eq!( + composer.draft.textarea.element_payloads(), + vec![placeholder] + ); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -5755,17 +5778,17 @@ mod tests { let base = format!("[Pasted Content {} chars]", paste.chars().count()); composer.handle_paste(paste.clone()); - assert_eq!(composer.textarea.text(), base); - assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); assert_eq!(composer.clear_for_ctrl_c(), Some(base.clone())); - assert!(composer.textarea.text().is_empty()); - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.textarea.text().is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); composer.handle_paste(paste); - assert_eq!(composer.textarea.text(), base); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, base); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, base); } #[test] @@ -5786,7 +5809,7 @@ mod tests { composer.set_steer_enabled(/*enabled*/ true); composer.set_vim_enabled(/*enabled*/ true); - assert!(composer.textarea.is_vim_enabled()); + assert!(composer.draft.textarea.is_vim_enabled()); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) @@ -5797,7 +5820,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(composer.textarea.is_vim_enabled()); + assert!(composer.draft.textarea.is_vim_enabled()); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) @@ -5867,7 +5890,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::None)); - assert_eq!(composer.textarea.text(), "/not-a-command"); + assert_eq!(composer.draft.textarea.text(), "/not-a-command"); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Insert".green()) @@ -5893,19 +5916,22 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); composer.set_text_content("hey".to_string(), Vec::new(), Vec::new()); - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Insert".green()) ); - assert_eq!(composer.textarea.cursor(), "hey".len()); + assert_eq!(composer.draft.textarea.cursor(), "hey".len()); composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) ); - assert_eq!(composer.textarea.cursor(), "he".len()); + assert_eq!(composer.draft.textarea.cursor(), "he".len()); } #[test] @@ -5986,9 +6012,12 @@ mod tests { ); composer.apply_history_entry(history_entry); - assert_eq!(composer.textarea.text(), placeholder); + assert_eq!(composer.draft.textarea.text(), placeholder); assert_eq!(composer.local_image_paths(), vec![path]); - assert_eq!(composer.textarea.element_payloads(), vec![placeholder]); + assert_eq!( + composer.draft.textarea.element_payloads(), + vec![placeholder] + ); } #[test] @@ -6092,14 +6121,14 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); assert_eq!(result, InputResult::None); assert!(needs_redraw, "toggling overlay should request redraw"); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); // Toggle back to prompt mode so subsequent typing captures characters. let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); type_chars_humanlike(&mut composer, &['h']); - assert_eq!(composer.textarea.text(), "h"); + assert_eq!(composer.draft.textarea.text(), "h"); assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft); let (result, needs_redraw) = @@ -6107,8 +6136,8 @@ mod tests { assert_eq!(result, InputResult::None); assert!(needs_redraw, "typing should still mark the view dirty"); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "h?"); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); + assert_eq!(composer.draft.textarea.text(), "h?"); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft); } @@ -6133,7 +6162,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)); assert_eq!(result, InputResult::None); assert!(needs_redraw, "toggling overlay should request redraw"); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); } /// Behavior: while a paste-like burst is being captured, `?` must not toggle the shortcut @@ -6156,6 +6185,7 @@ mod tests { // Force an active paste burst so this test doesn't depend on tight timing. composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -6163,12 +6193,12 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } assert!(composer.is_in_paste_burst()); - assert_eq!(composer.textarea.text(), ""); + assert_eq!(composer.draft.textarea.text(), ""); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "hi?there"); - assert_ne!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.draft.textarea.text(), "hi?there"); + assert_ne!(composer.footer.mode, FooterMode::ShortcutOverlay); } #[test] @@ -6528,11 +6558,11 @@ mod tests { ); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); composer.set_task_running(/*running*/ true); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); assert_eq!(composer.footer_mode(), FooterMode::ShortcutOverlay); } @@ -6778,8 +6808,8 @@ mod tests { ); let input = "npx -y @kaeawc/auto-mobile@latest"; - composer.textarea.insert_str(input); - composer.textarea.set_cursor(input.len()); + composer.draft.textarea.insert_str(input); + composer.draft.textarea.set_cursor(input.len()); composer.sync_popups(); assert!(matches!(composer.popups.active, ActivePopup::File(_))); @@ -6845,7 +6875,7 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "あ"); + assert_eq!(composer.draft.textarea.text(), "あ"); assert!(!composer.is_in_paste_burst()); } @@ -6868,6 +6898,7 @@ mod tests { ); composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -6877,9 +6908,9 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "你好\nhi"); + assert_eq!(composer.draft.textarea.text(), "你好\nhi"); } /// Behavior: a paste-like burst may include a full-width/ideographic space (U+3000). It should @@ -6901,6 +6932,7 @@ mod tests { ); composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -6912,9 +6944,9 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "你 好\nhi"); + assert_eq!(composer.draft.textarea.text(), "你 好\nhi"); } /// Behavior: a large multi-line payload containing both non-ASCII and ASCII (e.g. "UTF-8", @@ -6950,6 +6982,7 @@ mod tests { // Force an active burst so the test doesn't depend on timing heuristics. composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -6962,9 +6995,9 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); } - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), LARGE_MIXED_PAYLOAD); + assert_eq!(composer.draft.textarea.text(), LARGE_MIXED_PAYLOAD); } /// Behavior: while a paste-like burst is active, Enter should not submit; it should insert a @@ -7013,11 +7046,11 @@ mod tests { ); } - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; let flushed = composer.handle_paste_burst_flush(flush_time); assert!(flushed, "expected paste burst to flush"); - assert_eq!(composer.textarea.text(), "hi\nthere"); + assert_eq!(composer.draft.textarea.text(), "hi\nthere"); } /// Behavior: even if Enter suppression would normally be active for a burst, Enter should @@ -7039,9 +7072,10 @@ mod tests { /*disable_paste_burst*/ false, ); - composer.textarea.set_text_clearing_elements("/diff"); - composer.textarea.set_cursor("/diff".len()); + composer.draft.textarea.set_text_clearing_elements("/diff"); + composer.draft.textarea.set_cursor("/diff".len()); composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -7071,17 +7105,18 @@ mod tests { // Force an active burst so we can deterministically buffer characters without relying on // timing. composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); assert!(composer.is_in_paste_burst()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "hi"); - assert_eq!(composer.textarea.cursor(), 1); + assert_eq!(composer.draft.textarea.text(), "hi"); + assert_eq!(composer.draft.textarea.cursor(), 1); assert!(!composer.is_in_paste_burst()); } @@ -7107,14 +7142,14 @@ mod tests { // held char is not dropped. let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); assert!(composer.is_in_paste_burst()); - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); composer.set_disable_paste_burst(/*disabled*/ true); - assert_eq!(composer.textarea.text(), "a"); + assert_eq!(composer.draft.textarea.text(), "a"); assert!(!composer.is_in_paste_burst()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "ab"); + assert_eq!(composer.draft.textarea.text(), "ab"); assert!(!composer.is_in_paste_burst()); } @@ -7138,8 +7173,8 @@ mod tests { let needs_redraw = composer.handle_paste("hello".to_string()); assert!(needs_redraw); - assert_eq!(composer.textarea.text(), "hello"); - assert!(composer.pending_pastes.is_empty()); + assert_eq!(composer.draft.textarea.text(), "hello"); + assert!(composer.draft.pending_pastes.is_empty()); let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -7166,7 +7201,7 @@ mod tests { ); // Ensure composer is empty and press Enter. - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -7198,10 +7233,10 @@ mod tests { let needs_redraw = composer.handle_paste(large.clone()); assert!(needs_redraw); let placeholder = format!("[Pasted Content {} chars]", large.chars().count()); - assert_eq!(composer.textarea.text(), placeholder); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, placeholder); - assert_eq!(composer.pending_pastes[0].1, large); + assert_eq!(composer.draft.textarea.text(), placeholder); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, placeholder); + assert_eq!(composer.draft.pending_pastes[0].1, large); let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -7209,7 +7244,7 @@ mod tests { InputResult::Submitted { text, .. } => assert_eq!(text, large), _ => panic!("expected Submitted"), } - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); } #[test] @@ -7229,7 +7264,7 @@ mod tests { ); composer.set_steer_enabled(true); let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS); - composer.textarea.set_text_clearing_elements(&input); + composer.draft.textarea.set_text_clearing_elements(&input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -7257,13 +7292,13 @@ mod tests { ); composer.set_steer_enabled(true); let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); - composer.textarea.set_text_clearing_elements(&input); + composer.draft.textarea.set_text_clearing_elements(&input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!(composer.textarea.text(), input); + assert_eq!(composer.draft.textarea.text(), input); let mut found_error = false; while let Ok(event) = rx.try_recv() { @@ -7299,13 +7334,13 @@ mod tests { ); composer.set_steer_enabled(false); let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); - composer.textarea.set_text_clearing_elements(&input); + composer.draft.textarea.set_text_clearing_elements(&input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!(composer.textarea.text(), input); + assert_eq!(composer.draft.textarea.text(), input); let mut found_error = false; while let Ok(event) = rx.try_recv() { @@ -7344,11 +7379,11 @@ mod tests { ); composer.handle_paste(large); - assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes.len(), 1); // Any edit that removes the placeholder should clear pending_paste composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); } #[test] @@ -7399,7 +7434,10 @@ mod tests { composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4)); composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6)); // Move cursor to end and press backspace - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); } @@ -7458,7 +7496,7 @@ mod tests { "https://example.com/two.png".to_string(), ]); composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new()); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); }, ); @@ -7472,7 +7510,7 @@ mod tests { "https://example.com/two.png".to_string(), ]); composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new()); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); let _ = @@ -7750,7 +7788,10 @@ mod tests { } InputResult::None => panic!("expected Command result for '/init'"), } - assert!(composer.textarea.is_empty(), "composer should be cleared"); + assert!( + composer.draft.textarea.is_empty(), + "composer should be cleared" + ); } #[test] @@ -7769,22 +7810,22 @@ mod tests { /*disable_paste_burst*/ false, ); composer.set_steer_enabled(true); - composer.textarea.insert_str("restore me"); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.insert_str("restore me"); + composer.draft.textarea.set_cursor(/*pos*/ 0); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); - composer.textarea.insert_str("hello"); + composer.draft.textarea.insert_str("hello"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::Submitted { .. })); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); - assert_eq!(composer.textarea.text(), "restore me"); + assert_eq!(composer.draft.textarea.text(), "restore me"); } #[test] @@ -7802,14 +7843,14 @@ mod tests { "Ask Codex to do anything".to_string(), /*disable_paste_burst*/ false, ); - composer.textarea.insert_str("restore me"); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.insert_str("restore me"); + composer.draft.textarea.set_cursor(/*pos*/ 0); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); - composer.textarea.insert_str("/diff"); + composer.draft.textarea.insert_str("/diff"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { @@ -7818,11 +7859,11 @@ mod tests { } _ => panic!("expected Command result for '/diff'"), } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); - assert_eq!(composer.textarea.text(), "restore me"); + assert_eq!(composer.draft.textarea.text(), "restore me"); } #[test] @@ -7842,6 +7883,7 @@ mod tests { ); composer.set_task_running(/*running*/ true); composer + .draft .textarea .set_text_clearing_elements("/review these changes"); @@ -7849,7 +7891,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!("/review these changes", composer.textarea.text()); + assert_eq!("/review these changes", composer.draft.textarea.text()); let mut found_error = false; while let Ok(event) = rx.try_recv() { @@ -7885,7 +7927,7 @@ mod tests { /*disable_paste_burst*/ false, ); composer.set_task_running(/*running*/ true); - composer.textarea.set_text_clearing_elements(input); + composer.draft.textarea.set_text_clearing_elements(input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); @@ -7902,7 +7944,7 @@ mod tests { } other => panic!("expected slash-led input to queue, got {other:?}"), } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); assert!( rx.try_recv().is_err(), "queueing should not report slash errors" @@ -7933,9 +7975,13 @@ mod tests { /*disable_paste_burst*/ false, ); composer + .draft .textarea .set_text_clearing_elements("explain the change"); - composer.textarea.set_cursor(composer.textarea.text().len()); + 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); @@ -7944,7 +7990,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!("explain the change\n", composer.textarea.text()); + assert_eq!("explain the change\n", composer.draft.textarea.text()); } #[test] @@ -7965,7 +8011,10 @@ mod tests { /*disable_paste_burst*/ false, ); composer.set_task_running(/*running*/ true); - composer.textarea.set_text_clearing_elements("queue me"); + composer + .draft + .textarea + .set_text_clearing_elements("queue me"); let mut keymap = RuntimeKeymap::defaults(); keymap.composer.queue = vec![key_hint::ctrl(KeyCode::Char('q'))]; composer.set_keymap_bindings(&keymap); @@ -7974,7 +8023,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!("queue me", composer.textarea.text()); + assert_eq!("queue me", composer.draft.textarea.text()); } #[test] @@ -8022,6 +8071,7 @@ mod tests { ); composer.set_task_running(/*running*/ true); composer + .draft .textarea .set_text_clearing_elements(" /does-not-exist"); @@ -8054,7 +8104,7 @@ mod tests { /*disable_paste_burst*/ false, ); composer.set_task_running(/*running*/ true); - composer.textarea.set_text_clearing_elements(input); + composer.draft.textarea.set_text_clearing_elements(input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); @@ -8071,7 +8121,7 @@ mod tests { } other => panic!("expected bang shell input to queue, got {other:?}"), } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); assert!( rx.try_recv().is_err(), "queueing should not show shell help immediately" @@ -8104,8 +8154,11 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "/compact "); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "/compact "); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -8131,8 +8184,11 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(result, InputResult::None); - assert_eq!(composer.textarea.text(), "/model "); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "/model "); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -8157,8 +8213,11 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); assert_eq!(result, InputResult::None); - assert_eq!(composer.textarea.text(), "/model "); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "/model "); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -8178,7 +8237,7 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'd', 'i']); let (_res, _redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "/diff "); + assert_eq!(composer.draft.textarea.text(), "/diff "); // Press Enter: should dispatch the command, not submit literal text. let (result, _needs_redraw) = @@ -8199,7 +8258,7 @@ mod tests { } InputResult::None => panic!("expected Command result for '/diff'"), } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); } #[test] @@ -8217,8 +8276,8 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']); - let text = composer.textarea.text().to_string(); - let elements = composer.textarea.text_elements(); + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(text, "/plan "); assert_eq!(elements.len(), 1); assert_eq!(elements[0].placeholder(&text), Some("/plan")); @@ -8239,8 +8298,8 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'U', 's', 'e', 'r', 's', ' ']); - let text = composer.textarea.text().to_string(); - let elements = composer.textarea.text_elements(); + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(text, "/Users "); assert!(elements.is_empty()); } @@ -8259,16 +8318,16 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']); - let text = composer.textarea.text().to_string(); - let elements = composer.textarea.text_elements(); + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(text, "/review "); assert_eq!(elements.len(), 1); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); type_chars_humanlike(&mut composer, &['x']); - let text = composer.textarea.text().to_string(); - let elements = composer.textarea.text_elements(); + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(text, "x/review "); assert!(elements.is_empty()); } @@ -8298,7 +8357,7 @@ mod tests { result, InputResult::Submitted { ref text, .. } if text == "hi" )); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); } #[test] @@ -8396,9 +8455,12 @@ mod tests { } InputResult::None => panic!("expected Command result for '/mention'"), } - assert!(composer.textarea.is_empty(), "composer should be cleared"); + assert!( + composer.draft.textarea.is_empty(), + "composer should be cleared" + ); composer.insert_str("@"); - assert_eq!(composer.textarea.text(), "@"); + assert_eq!(composer.draft.textarea.text(), "@"); } #[test] @@ -8474,9 +8536,9 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - let text = composer.textarea.text().to_string(); + let text = composer.draft.textarea.text().to_string(); assert_eq!(text, format!("{placeholder} src/main.rs ")); - let elements = composer.textarea.text_elements(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(elements.len(), 1); assert_eq!(elements[0].placeholder(&text), Some(placeholder.as_str())); @@ -8612,8 +8674,8 @@ mod tests { current_pos += content.len(); } ( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), + composer.draft.textarea.text().to_string(), + composer.draft.pending_pastes.len(), current_pos, ) }) @@ -8623,19 +8685,22 @@ mod tests { let mut deletion_states = vec![]; // First deletion - composer.textarea.set_cursor(states[0].2); + composer.draft.textarea.set_cursor(states[0].2); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); deletion_states.push(( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), + composer.draft.textarea.text().to_string(), + composer.draft.pending_pastes.len(), )); // Second deletion - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); deletion_states.push(( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), + composer.draft.textarea.text().to_string(), + composer.draft.pending_pastes.len(), )); // Verify all states @@ -8673,18 +8738,21 @@ mod tests { composer.handle_paste(paste.clone()); composer.handle_paste(paste.clone()); assert_eq!( - composer.textarea.text(), + composer.draft.textarea.text(), format!("{placeholder_base}{placeholder_second}") ); - assert_eq!(composer.pending_pastes.len(), 2); + assert_eq!(composer.draft.pending_pastes.len(), 2); - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), placeholder_base); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, placeholder_base); - assert_eq!(composer.pending_pastes[0].1, paste); + assert_eq!(composer.draft.textarea.text(), placeholder_base); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, placeholder_base); + assert_eq!(composer.draft.pending_pastes[0].1, paste); } /// Behavior: large-paste placeholder numbering continues when another placeholder of the @@ -8712,21 +8780,24 @@ mod tests { composer.handle_paste(paste.clone()); composer.handle_paste(paste.clone()); - assert_eq!(composer.textarea.text(), format!("{base}{second}")); + assert_eq!(composer.draft.textarea.text(), format!("{base}{second}")); - composer.textarea.set_cursor(base.len()); + composer.draft.textarea.set_cursor(base.len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), second); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, second); + assert_eq!(composer.draft.textarea.text(), second); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, second); - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_paste(paste); - assert_eq!(composer.textarea.text(), format!("{second}{third}")); - assert_eq!(composer.pending_pastes.len(), 2); - assert_eq!(composer.pending_pastes[0].0, second); - assert_eq!(composer.pending_pastes[1].0, third); + assert_eq!(composer.draft.textarea.text(), format!("{second}{third}")); + assert_eq!(composer.draft.pending_pastes.len(), 2); + assert_eq!(composer.draft.pending_pastes[0].0, second); + assert_eq!(composer.draft.pending_pastes[1].0, third); } /// Behavior: if all placeholders of a given length are removed, numbering resets to the @@ -8751,18 +8822,21 @@ mod tests { let base = format!("[Pasted Content {} chars]", paste.chars().count()); composer.handle_paste(paste.clone()); - assert_eq!(composer.textarea.text(), base); - assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(composer.textarea.text().is_empty()); - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.textarea.text().is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); composer.handle_paste(paste); - assert_eq!(composer.textarea.text(), base); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, base); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, base); } #[test] @@ -8795,14 +8869,15 @@ mod tests { .map(|pos_from_end| { composer.handle_paste(paste.clone()); composer + .draft .textarea .set_cursor(placeholder.len() - pos_from_end); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); let result = ( - composer.textarea.text().contains(&placeholder), - composer.pending_pastes.len(), + composer.draft.textarea.text().contains(&placeholder), + composer.draft.pending_pastes.len(), ); - composer.textarea.set_text_clearing_elements(""); + composer.draft.textarea.set_text_clearing_elements(""); result }) .collect(); @@ -8978,23 +9053,35 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "first"); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "first"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert!(composer.draft.textarea.is_empty()); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -9023,23 +9110,26 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); - assert_eq!(composer.textarea.cursor(), "second".len() - 1); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!(composer.draft.textarea.cursor(), "second".len() - 1); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "first"); - assert_eq!(composer.textarea.cursor(), "first".len() - 1); + assert_eq!(composer.draft.textarea.text(), "first"); + assert_eq!(composer.draft.textarea.cursor(), "first".len() - 1); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); - assert_eq!(composer.textarea.cursor(), "second".len() - 1); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!(composer.draft.textarea.cursor(), "second".len() - 1); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert!(composer.draft.textarea.is_empty()); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -9069,13 +9159,13 @@ mod tests { composer.set_vim_enabled(/*enabled*/ true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "first"); + assert_eq!(composer.draft.textarea.text(), "first"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); } #[test] @@ -9089,15 +9179,18 @@ mod tests { "Ask Codex to do anything".to_string(), /*disable_paste_burst*/ false, ); - composer.textarea.set_text_clearing_elements("one\ntwo"); - composer.textarea.set_cursor(/*pos*/ 0); + composer + .draft + .textarea + .set_text_clearing_elements("one\ntwo"); + composer.draft.textarea.set_cursor(/*pos*/ 0); composer.set_vim_enabled(/*enabled*/ true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.cursor(), "one\n".len()); + assert_eq!(composer.draft.textarea.cursor(), "one\n".len()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.cursor(), 0); + assert_eq!(composer.draft.textarea.cursor(), 0); } #[test] @@ -9125,11 +9218,11 @@ mod tests { composer.set_vim_enabled(/*enabled*/ true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); + assert_eq!(composer.draft.textarea.text(), "second"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); assert_eq!(composer.current_text(), ""); } @@ -9148,18 +9241,18 @@ mod tests { composer.set_vim_enabled(/*enabled*/ true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); - assert!(composer.textarea.is_vim_operator_pending()); + assert!(composer.draft.textarea.is_vim_operator_pending()); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::None)); - assert_eq!(composer.textarea.text(), "hello"); + assert_eq!(composer.draft.textarea.text(), "hello"); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) ); - assert!(!composer.textarea.is_vim_operator_pending()); + assert!(!composer.draft.textarea.is_vim_operator_pending()); } #[test] @@ -9187,10 +9280,10 @@ mod tests { composer.set_keymap_bindings(&keymap); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "first"); + assert_eq!(composer.draft.textarea.text(), "first"); } #[test] @@ -9223,7 +9316,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(composer.current_text(), "!git"); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(composer.current_text(), "first"); @@ -9256,12 +9349,12 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); assert_eq!(composer.current_text(), "!git"); - assert_eq!(composer.textarea.cursor(), "git".len() - 1); + assert_eq!(composer.draft.textarea.cursor(), "git".len() - 1); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); assert_eq!(composer.current_text(), "first"); - assert_eq!(composer.textarea.cursor(), "first".len() - 1); + assert_eq!(composer.draft.textarea.cursor(), "first".len() - 1); } #[test] @@ -9430,11 +9523,15 @@ mod tests { /*disable_paste_burst*/ false, ); - composer.textarea.set_text_clearing_elements("/unknown "); - composer.textarea.set_cursor("/unknown ".len()); + composer + .draft + .textarea + .set_text_clearing_elements("/unknown "); + composer.draft.textarea.set_cursor("/unknown ".len()); let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); composer.handle_paste(large_content.clone()); let placeholder = composer + .draft .pending_pastes .first() .expect("expected pending paste") @@ -9444,11 +9541,14 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::None)); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.textarea.text(), format!("/unknown {placeholder}")); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!( + composer.draft.textarea.text(), + format!("/unknown {placeholder}") + ); - composer.textarea.set_cursor(/*pos*/ 0); - composer.textarea.insert_str(" "); + composer.draft.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.insert_str(" "); let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { @@ -9461,7 +9561,7 @@ mod tests { } _ => panic!("expected Submitted"), } - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); } #[test] @@ -9519,7 +9619,7 @@ mod tests { composer.handle_paste(" ".into()); composer.attach_image(path); - let text = composer.textarea.text().to_string(); + let text = composer.draft.textarea.text().to_string(); assert!(text.contains("[Image #1]")); assert!(text.contains("[Image #2]")); assert_eq!( @@ -9549,21 +9649,22 @@ mod tests { // Case 1: backspace at end composer + .draft .textarea .move_cursor_to_end_of_line(/*move_down_at_eol*/ false); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(!composer.textarea.text().contains(&placeholder)); + assert!(!composer.draft.textarea.text().contains(&placeholder)); assert!(composer.attachments.local_images.is_empty()); // Re-add and ensure backspace at element start does not delete the placeholder. composer.attach_image(path); let placeholder2 = composer.attachments.local_images[0].placeholder.clone(); // Move cursor to roughly middle of placeholder - if let Some(start_pos) = composer.textarea.text().find(&placeholder2) { + if let Some(start_pos) = composer.draft.textarea.text().find(&placeholder2) { let mid_pos = start_pos + (placeholder2.len() / 2); - composer.textarea.set_cursor(mid_pos); + composer.draft.textarea.set_cursor(mid_pos); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(composer.textarea.text().contains(&placeholder2)); + assert!(composer.draft.textarea.text().contains(&placeholder2)); assert_eq!(composer.attachments.local_images.len(), 1); } else { panic!("Placeholder not found in textarea"); @@ -9590,14 +9691,14 @@ mod tests { let path = PathBuf::from("/tmp/image_multibyte.png"); composer.attach_image(path); // Add multibyte text after the placeholder - composer.textarea.insert_str("日本語"); + composer.draft.textarea.insert_str("日本語"); // Cursor is at end; pressing backspace should delete the last character // without panicking and leave the placeholder intact. composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); assert_eq!(composer.attachments.local_images.len(), 1); - assert!(composer.textarea.text().starts_with("[Image #1]")); + assert!(composer.draft.textarea.text().starts_with("[Image #1]")); } #[test] @@ -9622,15 +9723,15 @@ mod tests { let placeholder1 = composer.attachments.local_images[0].placeholder.clone(); let placeholder2 = composer.attachments.local_images[1].placeholder.clone(); - let text = composer.textarea.text().to_string(); + let text = composer.draft.textarea.text().to_string(); let start1 = text.find(&placeholder1).expect("first placeholder present"); let end1 = start1 + placeholder1.len(); - composer.textarea.set_cursor(end1); + composer.draft.textarea.set_cursor(end1); // Backspace should delete the first placeholder and its mapping. composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - let new_text = composer.textarea.text().to_string(); + let new_text = composer.draft.textarea.text().to_string(); assert_eq!( 1, new_text.matches(&placeholder1).count(), @@ -9701,12 +9802,12 @@ mod tests { composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); let end1 = start1 + placeholder1.len(); - composer.textarea.set_cursor(end1); + composer.draft.textarea.set_cursor(end1); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); assert_eq!( - composer.textarea.text(), + composer.draft.textarea.text(), format!("Test {placeholder1} test ") ); assert_eq!( @@ -9741,11 +9842,11 @@ mod tests { // Insert two adjacent atomic elements. composer.attach_image(path1); composer.attach_image(path2.clone()); - assert_eq!(composer.textarea.text(), "[Image #1][Image #2]"); + assert_eq!(composer.draft.textarea.text(), "[Image #1][Image #2]"); assert_eq!(composer.attachments.local_images.len(), 2); // Delete the first element using normal textarea editing (forward Delete at cursor start). - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); // Remaining image should be renumbered and the textarea element updated. @@ -9755,7 +9856,7 @@ mod tests { composer.attachments.local_images[0].placeholder, "[Image #1]" ); - assert_eq!(composer.textarea.text(), "[Image #1]"); + assert_eq!(composer.draft.textarea.text(), "[Image #1]"); } #[test] @@ -9778,7 +9879,7 @@ mod tests { let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string()); assert!(needs_redraw); - assert!(composer.textarea.text().starts_with("[Image #1] ")); + assert!(composer.draft.textarea.text().starts_with("[Image #1] ")); let imgs = composer.take_recent_submission_images(); assert_eq!(imgs, vec![tmp_path]); @@ -9801,6 +9902,7 @@ mod tests { ); composer + .draft .textarea .set_text_clearing_elements("/Users/example/project/src/main.rs"); @@ -9812,7 +9914,7 @@ mod tests { } else { panic!("expected Submitted"); } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); match rx.try_recv() { Ok(event) => panic!("unexpected event: {event:?}"), Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} @@ -9837,6 +9939,7 @@ mod tests { ); composer + .draft .textarea .set_text_clearing_elements(" /this-looks-like-a-command"); @@ -9848,7 +9951,7 @@ mod tests { } else { panic!("expected Submitted"); } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); match rx.try_recv() { Ok(event) => panic!("unexpected event: {event:?}"), Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} @@ -9876,12 +9979,12 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); assert!(composer.is_in_paste_burst()); - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); let flushed = composer.flush_paste_burst_if_due(); assert!(flushed, "expected pending first char to flush"); - assert_eq!(composer.textarea.text(), "h"); + assert_eq!(composer.draft.textarea.text(), "h"); assert!(!composer.is_in_paste_burst()); } @@ -9916,22 +10019,22 @@ mod tests { "expected active paste burst during fast typing" ); assert!( - composer.textarea.text().is_empty(), + composer.draft.textarea.text().is_empty(), "text should not appear during burst" ); now += step; } assert!( - composer.textarea.text().is_empty(), + composer.draft.textarea.text().is_empty(), "text should remain empty until flush" ); let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; let flushed = composer.handle_paste_burst_flush(flush_time); assert!(flushed, "expected buffered text to flush after stop"); - assert_eq!(composer.textarea.text(), "a".repeat(count)); + assert_eq!(composer.draft.textarea.text(), "a".repeat(count)); assert!( - composer.pending_pastes.is_empty(), + composer.draft.pending_pastes.is_empty(), "no placeholder for small burst" ); } @@ -9962,17 +10065,17 @@ mod tests { } // Nothing should appear until we stop and flush - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; let flushed = composer.handle_paste_burst_flush(flush_time); assert!(flushed, "expected flush after stopping fast input"); let expected_placeholder = format!("[Pasted Content {count} chars]"); - assert_eq!(composer.textarea.text(), expected_placeholder); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, expected_placeholder); - assert_eq!(composer.pending_pastes[0].1.len(), count); - assert!(composer.pending_pastes[0].1.chars().all(|c| c == 'x')); + assert_eq!(composer.draft.textarea.text(), expected_placeholder); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, expected_placeholder); + assert_eq!(composer.draft.pending_pastes[0].1.len(), count); + assert!(composer.draft.pending_pastes[0].1.chars().all(|c| c == 'x')); } /// Behavior: human-like typing (with delays between chars) should not be classified as a paste @@ -9993,8 +10096,8 @@ mod tests { let chars: Vec = vec!['z'; count]; type_chars_humanlike(&mut composer, &chars); - assert_eq!(composer.textarea.text(), "z".repeat(count)); - assert!(composer.pending_pastes.is_empty()); + assert_eq!(composer.draft.textarea.text(), "z".repeat(count)); + assert!(composer.draft.pending_pastes.is_empty()); } #[test] @@ -10172,12 +10275,13 @@ mod tests { ); let placeholder = local_image_label_text(/*label_number*/ 1); - composer.textarea.insert_element(&placeholder); + composer.draft.textarea.insert_element(&placeholder); composer.attachments.local_images.push(AttachedImage { placeholder: placeholder.clone(), path: PathBuf::from("img.png"), }); composer + .draft .pending_pastes .push(("[Pasted]".to_string(), "data".to_string())); @@ -10187,13 +10291,16 @@ mod tests { composer.current_text(), format!("Edited {placeholder} text") ); - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); assert_eq!(composer.attachments.local_images.len(), 1); assert_eq!( composer.attachments.local_images[0].placeholder, placeholder ); - assert_eq!(composer.textarea.cursor(), composer.current_text().len()); + assert_eq!( + composer.draft.textarea.cursor(), + composer.current_text().len() + ); } #[test] @@ -10211,8 +10318,8 @@ mod tests { composer.apply_external_edit("!git status".to_string()); - assert!(composer.is_bash_mode); - assert_eq!(composer.textarea.text(), "git status"); + assert!(composer.draft.is_bash_mode); + assert_eq!(composer.draft.textarea.text(), "git status"); assert_eq!(composer.current_text(), "!git status"); } @@ -10231,8 +10338,8 @@ mod tests { composer.apply_external_edit("git status".to_string()); - assert!(!composer.is_bash_mode); - assert_eq!(composer.textarea.text(), "git status"); + assert!(!composer.draft.is_bash_mode); + assert_eq!(composer.draft.textarea.text(), "git status"); assert_eq!(composer.current_text(), "git status"); } @@ -10251,8 +10358,8 @@ mod tests { composer.apply_external_edit("!git status".to_string()); - assert!(composer.is_bash_mode); - assert_eq!(composer.textarea.text(), "git status"); + assert!(composer.draft.is_bash_mode); + assert_eq!(composer.draft.textarea.text(), "git status"); assert_eq!(composer.current_text(), "!git status"); } @@ -10269,7 +10376,7 @@ mod tests { ); let placeholder = local_image_label_text(/*label_number*/ 1); - composer.textarea.insert_element(&placeholder); + composer.draft.textarea.insert_element(&placeholder); composer.attachments.local_images.push(AttachedImage { placeholder: placeholder.clone(), path: PathBuf::from("img.png"), @@ -10309,7 +10416,10 @@ mod tests { placeholder1 ); assert_eq!(composer.local_image_paths(), vec![second_path]); - assert_eq!(composer.textarea.element_payloads(), vec![placeholder1]); + assert_eq!( + composer.draft.textarea.element_payloads(), + vec![placeholder1] + ); } #[test] @@ -10325,8 +10435,9 @@ mod tests { ); let placeholder = "[Pasted Content 5 chars]".to_string(); - composer.textarea.insert_element(&placeholder); + composer.draft.textarea.insert_element(&placeholder); composer + .draft .pending_pastes .push((placeholder.clone(), "hello".to_string())); @@ -10377,7 +10488,7 @@ mod tests { ); let placeholder = local_image_label_text(/*label_number*/ 1); - composer.textarea.insert_element(&placeholder); + composer.draft.textarea.insert_element(&placeholder); composer.attachments.local_images.push(AttachedImage { placeholder: placeholder.clone(), path: PathBuf::from("img.png"), @@ -10512,7 +10623,7 @@ mod tests { "https://example.com/two.png".to_string(), ]); composer.attach_image(PathBuf::from("/tmp/local.png")); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs new file mode 100644 index 00000000000..f739f10ec99 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs @@ -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, + pub(super) is_bash_mode: bool, + pub(super) pending_pastes: Vec<(String, String)>, + pub(super) input_enabled: bool, + pub(super) input_disabled_placeholder: Option, + pub(super) paste_burst: PasteBurst, + pub(super) disable_paste_burst: bool, + pub(super) mention_bindings: HashMap, + pub(super) recent_submission_mention_bindings: Vec, +} + +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, +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs new file mode 100644 index 00000000000..113385ff720 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -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, + 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>, + pub(super) plan_mode_nudge_visible: bool, + pub(super) flash: Option, + pub(super) context_window_percent: Option, + pub(super) context_window_used_tokens: Option, + pub(super) collaboration_mode_indicator: Option, + pub(super) goal_status_indicator: Option, + pub(super) ide_context_active: bool, + pub(super) status_line_value: Option>, + pub(super) status_line_hyperlink_url: Option, + pub(super) status_line_enabled: bool, + pub(super) side_conversation_context_label: Option, + pub(super) active_agent_label: Option, + pub(super) external_editor_key: Option, + pub(super) show_transcript_key: Option, + pub(super) insert_newline_key: Option, + pub(super) queue_key: Option, + pub(super) toggle_shortcuts_key: Option, + pub(super) history_search_key: Option, + pub(super) reasoning_down_key: Option, + pub(super) reasoning_up_key: Option, +} + +#[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 { + self.status_line_value.as_ref().map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index d5c1ade2a5b..3c2663a659f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -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> { @@ -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); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 6d99c0bce61..165169c456b 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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 { self.composer.text_elements() } + #[cfg(test)] pub(crate) fn composer_local_images(&self) -> Vec { self.composer.local_images() } - pub(crate) fn composer_mention_bindings(&self) -> Vec { - self.composer.mention_bindings() - } - #[cfg(test)] pub(crate) fn composer_local_image_paths(&self) -> Vec { self.composer.local_image_paths() @@ -865,6 +867,7 @@ impl BottomPane { self.request_redraw(); } + #[cfg(test)] pub(crate) fn remote_image_urls(&self) -> Vec { self.composer.remote_image_urls() } diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index ca15f1cdf74..b7e8ee72e0c 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -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 { + 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),