From ccba737d26ea58532f33b4bbf14af87944c5dc1a Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Wed, 7 Jan 2026 20:56:48 -0800 Subject: [PATCH] add ability to disable input temporarily in the TUI. (#8876) We will disable input while the elevated sandbox setup is running. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 75 ++++++++++++++++++- codex-rs/tui/src/bottom_pane/mod.rs | 10 +++ .../tui2/src/bottom_pane/chat_composer.rs | 75 ++++++++++++++++++- codex-rs/tui2/src/bottom_pane/mod.rs | 10 +++ 4 files changed, 166 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6d47739c1b..f0ac9ca47f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -110,6 +110,9 @@ pub(crate) struct ChatComposer { attached_images: Vec, 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. paste_burst: PasteBurst, // When true, disables paste-burst logic and inserts characters immediately. @@ -160,6 +163,8 @@ impl ChatComposer { attached_images: Vec::new(), placeholder_text, is_task_running: false, + input_enabled: true, + input_disabled_placeholder: None, paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), @@ -488,6 +493,10 @@ impl ChatComposer { /// 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 { + return (InputResult::None, false); + } + let result = match &mut self.active_popup { ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), @@ -1877,6 +1886,17 @@ impl ChatComposer { self.has_focus = has_focus; } + #[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 }; + + // Avoid leaving interactive popups open while input is blocked. + if !enabled && !matches!(self.active_popup, ActivePopup::None) { + self.active_popup = ActivePopup::None; + } + } + pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; } @@ -1902,6 +1922,10 @@ impl ChatComposer { impl Renderable for ChatComposer { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if !self.input_enabled { + return None; + } + let [_, textarea_rect, _] = self.layout_areas(area); let state = *self.textarea_state.borrow(); self.textarea.cursor_pos_with_state(textarea_rect, state) @@ -1980,10 +2004,15 @@ impl Renderable for ChatComposer { let style = user_message_style(); Block::default().style(style).render_ref(composer_rect, buf); if !textarea_rect.is_empty() { + let prompt = if self.input_enabled { + "›".bold() + } else { + "›".dim() + }; buf.set_span( textarea_rect.x - LIVE_PREFIX_COLS, textarea_rect.y, - &"›".bold(), + &prompt, textarea_rect.width, ); } @@ -1991,7 +2020,15 @@ impl Renderable for ChatComposer { let mut state = self.textarea_state.borrow_mut(); StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); if self.textarea.text().is_empty() { - let placeholder = Span::from(self.placeholder_text.as_str()).dim(); + let text = if self.input_enabled { + self.placeholder_text.as_str().to_string() + } else { + self.input_disabled_placeholder + .as_deref() + .unwrap_or("Input disabled.") + .to_string() + }; + let placeholder = Span::from(text).dim().italic(); Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf); } } @@ -4389,4 +4426,38 @@ mod tests { ); assert_eq!(composer.attached_images.len(), 1); } + #[test] + fn input_disabled_ignores_keypresses_and_hides_cursor() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_text_content("hello".to_string()); + composer.set_input_enabled(false, Some("Input disabled for test.".to_string())); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + + assert_eq!(result, InputResult::None); + assert!(!needs_redraw); + assert_eq!(composer.current_text(), "hello"); + + let area = Rect { + x: 0, + y: 0, + width: 40, + height: 5, + }; + assert_eq!(composer.cursor_pos(area), None); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 329d8ec805..fe626537ac 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -264,6 +264,16 @@ impl BottomPane { self.request_redraw(); } + #[allow(dead_code)] + pub(crate) fn set_composer_input_enabled( + &mut self, + enabled: bool, + placeholder: Option, + ) { + self.composer.set_input_enabled(enabled, placeholder); + self.request_redraw(); + } + pub(crate) fn clear_composer_for_ctrl_c(&mut self) { self.composer.clear_for_ctrl_c(); self.request_redraw(); diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index e136b81971..daa861f58b 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -113,6 +113,9 @@ pub(crate) struct ChatComposer { attached_images: Vec, 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. paste_burst: PasteBurst, // When true, disables paste-burst logic and inserts characters immediately. @@ -168,6 +171,8 @@ impl ChatComposer { attached_images: Vec::new(), placeholder_text, is_task_running: false, + input_enabled: true, + input_disabled_placeholder: None, paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), @@ -405,6 +410,10 @@ impl ChatComposer { /// 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 { + return (InputResult::None, false); + } + let result = match &mut self.active_popup { ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), @@ -1819,6 +1828,17 @@ impl ChatComposer { self.has_focus = has_focus; } + #[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 }; + + // Avoid leaving interactive popups open while input is blocked. + if !enabled && !matches!(self.active_popup, ActivePopup::None) { + self.active_popup = ActivePopup::None; + } + } + pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; } @@ -1844,6 +1864,10 @@ impl ChatComposer { impl Renderable for ChatComposer { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if !self.input_enabled { + return None; + } + let [_, textarea_rect, _] = self.layout_areas(area); let state = *self.textarea_state.borrow(); self.textarea.cursor_pos_with_state(textarea_rect, state) @@ -1922,10 +1946,15 @@ impl Renderable for ChatComposer { let style = user_message_style(); Block::default().style(style).render_ref(composer_rect, buf); if !textarea_rect.is_empty() { + let prompt = if self.input_enabled { + "›".bold() + } else { + "›".dim() + }; buf.set_span( textarea_rect.x - LIVE_PREFIX_COLS, textarea_rect.y, - &"›".bold(), + &prompt, textarea_rect.width, ); } @@ -1933,7 +1962,15 @@ impl Renderable for ChatComposer { let mut state = self.textarea_state.borrow_mut(); StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); if self.textarea.text().is_empty() { - let placeholder = Span::from(self.placeholder_text.as_str()).dim(); + let text = if self.input_enabled { + self.placeholder_text.as_str().to_string() + } else { + self.input_disabled_placeholder + .as_deref() + .unwrap_or("Input disabled.") + .to_string() + }; + let placeholder = Span::from(text).dim().italic(); Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf); } } @@ -4108,4 +4145,38 @@ mod tests { "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" ); } + #[test] + fn input_disabled_ignores_keypresses_and_hides_cursor() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_text_content("hello".to_string()); + composer.set_input_enabled(false, Some("Input disabled for test.".to_string())); + + let (result, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + + assert_eq!(result, InputResult::None); + assert!(!needs_redraw); + assert_eq!(composer.current_text(), "hello"); + + let area = Rect { + x: 0, + y: 0, + width: 40, + height: 5, + }; + assert_eq!(composer.cursor_pos(area), None); + } } diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 2ebd0715e7..4b6caf0d1a 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -256,6 +256,16 @@ impl BottomPane { self.request_redraw(); } + #[allow(dead_code)] + pub(crate) fn set_composer_input_enabled( + &mut self, + enabled: bool, + placeholder: Option, + ) { + self.composer.set_input_enabled(enabled, placeholder); + self.request_redraw(); + } + pub(crate) fn clear_composer_for_ctrl_c(&mut self) { self.composer.clear_for_ctrl_c(); self.request_redraw();