add ability to disable input temporarily in the TUI. (#8876)
We will disable input while the elevated sandbox setup is running.
This commit is contained in:
parent
75076aabfe
commit
ccba737d26
4 changed files with 166 additions and 4 deletions
|
|
@ -110,6 +110,9 @@ pub(crate) struct ChatComposer {
|
||||||
attached_images: Vec<AttachedImage>,
|
attached_images: Vec<AttachedImage>,
|
||||||
placeholder_text: String,
|
placeholder_text: String,
|
||||||
is_task_running: bool,
|
is_task_running: bool,
|
||||||
|
/// When false, the composer is temporarily read-only (e.g. during sandbox setup).
|
||||||
|
input_enabled: bool,
|
||||||
|
input_disabled_placeholder: Option<String>,
|
||||||
// Non-bracketed paste burst tracker.
|
// Non-bracketed paste burst tracker.
|
||||||
paste_burst: PasteBurst,
|
paste_burst: PasteBurst,
|
||||||
// When true, disables paste-burst logic and inserts characters immediately.
|
// When true, disables paste-burst logic and inserts characters immediately.
|
||||||
|
|
@ -160,6 +163,8 @@ impl ChatComposer {
|
||||||
attached_images: Vec::new(),
|
attached_images: Vec::new(),
|
||||||
placeholder_text,
|
placeholder_text,
|
||||||
is_task_running: false,
|
is_task_running: false,
|
||||||
|
input_enabled: true,
|
||||||
|
input_disabled_placeholder: None,
|
||||||
paste_burst: PasteBurst::default(),
|
paste_burst: PasteBurst::default(),
|
||||||
disable_paste_burst: false,
|
disable_paste_burst: false,
|
||||||
custom_prompts: Vec::new(),
|
custom_prompts: Vec::new(),
|
||||||
|
|
@ -488,6 +493,10 @@ impl ChatComposer {
|
||||||
|
|
||||||
/// Handle a key event coming from the main UI.
|
/// Handle a key event coming from the main UI.
|
||||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
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 {
|
let result = match &mut self.active_popup {
|
||||||
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
|
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
|
||||||
ActivePopup::File(_) => self.handle_key_event_with_file_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;
|
self.has_focus = has_focus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option<String>) {
|
||||||
|
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) {
|
pub fn set_task_running(&mut self, running: bool) {
|
||||||
self.is_task_running = running;
|
self.is_task_running = running;
|
||||||
}
|
}
|
||||||
|
|
@ -1902,6 +1922,10 @@ impl ChatComposer {
|
||||||
|
|
||||||
impl Renderable for ChatComposer {
|
impl Renderable for ChatComposer {
|
||||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||||
|
if !self.input_enabled {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let [_, textarea_rect, _] = self.layout_areas(area);
|
let [_, textarea_rect, _] = self.layout_areas(area);
|
||||||
let state = *self.textarea_state.borrow();
|
let state = *self.textarea_state.borrow();
|
||||||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||||||
|
|
@ -1980,10 +2004,15 @@ impl Renderable for ChatComposer {
|
||||||
let style = user_message_style();
|
let style = user_message_style();
|
||||||
Block::default().style(style).render_ref(composer_rect, buf);
|
Block::default().style(style).render_ref(composer_rect, buf);
|
||||||
if !textarea_rect.is_empty() {
|
if !textarea_rect.is_empty() {
|
||||||
|
let prompt = if self.input_enabled {
|
||||||
|
"›".bold()
|
||||||
|
} else {
|
||||||
|
"›".dim()
|
||||||
|
};
|
||||||
buf.set_span(
|
buf.set_span(
|
||||||
textarea_rect.x - LIVE_PREFIX_COLS,
|
textarea_rect.x - LIVE_PREFIX_COLS,
|
||||||
textarea_rect.y,
|
textarea_rect.y,
|
||||||
&"›".bold(),
|
&prompt,
|
||||||
textarea_rect.width,
|
textarea_rect.width,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1991,7 +2020,15 @@ impl Renderable for ChatComposer {
|
||||||
let mut state = self.textarea_state.borrow_mut();
|
let mut state = self.textarea_state.borrow_mut();
|
||||||
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
||||||
if self.textarea.text().is_empty() {
|
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);
|
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);
|
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::<AppEvent>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,16 @@ impl BottomPane {
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn set_composer_input_enabled(
|
||||||
|
&mut self,
|
||||||
|
enabled: bool,
|
||||||
|
placeholder: Option<String>,
|
||||||
|
) {
|
||||||
|
self.composer.set_input_enabled(enabled, placeholder);
|
||||||
|
self.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn clear_composer_for_ctrl_c(&mut self) {
|
pub(crate) fn clear_composer_for_ctrl_c(&mut self) {
|
||||||
self.composer.clear_for_ctrl_c();
|
self.composer.clear_for_ctrl_c();
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,9 @@ pub(crate) struct ChatComposer {
|
||||||
attached_images: Vec<AttachedImage>,
|
attached_images: Vec<AttachedImage>,
|
||||||
placeholder_text: String,
|
placeholder_text: String,
|
||||||
is_task_running: bool,
|
is_task_running: bool,
|
||||||
|
/// When false, the composer is temporarily read-only (e.g. during sandbox setup).
|
||||||
|
input_enabled: bool,
|
||||||
|
input_disabled_placeholder: Option<String>,
|
||||||
// Non-bracketed paste burst tracker.
|
// Non-bracketed paste burst tracker.
|
||||||
paste_burst: PasteBurst,
|
paste_burst: PasteBurst,
|
||||||
// When true, disables paste-burst logic and inserts characters immediately.
|
// When true, disables paste-burst logic and inserts characters immediately.
|
||||||
|
|
@ -168,6 +171,8 @@ impl ChatComposer {
|
||||||
attached_images: Vec::new(),
|
attached_images: Vec::new(),
|
||||||
placeholder_text,
|
placeholder_text,
|
||||||
is_task_running: false,
|
is_task_running: false,
|
||||||
|
input_enabled: true,
|
||||||
|
input_disabled_placeholder: None,
|
||||||
paste_burst: PasteBurst::default(),
|
paste_burst: PasteBurst::default(),
|
||||||
disable_paste_burst: false,
|
disable_paste_burst: false,
|
||||||
custom_prompts: Vec::new(),
|
custom_prompts: Vec::new(),
|
||||||
|
|
@ -405,6 +410,10 @@ impl ChatComposer {
|
||||||
|
|
||||||
/// Handle a key event coming from the main UI.
|
/// Handle a key event coming from the main UI.
|
||||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
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 {
|
let result = match &mut self.active_popup {
|
||||||
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
|
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
|
||||||
ActivePopup::File(_) => self.handle_key_event_with_file_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;
|
self.has_focus = has_focus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option<String>) {
|
||||||
|
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) {
|
pub fn set_task_running(&mut self, running: bool) {
|
||||||
self.is_task_running = running;
|
self.is_task_running = running;
|
||||||
}
|
}
|
||||||
|
|
@ -1844,6 +1864,10 @@ impl ChatComposer {
|
||||||
|
|
||||||
impl Renderable for ChatComposer {
|
impl Renderable for ChatComposer {
|
||||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||||
|
if !self.input_enabled {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let [_, textarea_rect, _] = self.layout_areas(area);
|
let [_, textarea_rect, _] = self.layout_areas(area);
|
||||||
let state = *self.textarea_state.borrow();
|
let state = *self.textarea_state.borrow();
|
||||||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||||||
|
|
@ -1922,10 +1946,15 @@ impl Renderable for ChatComposer {
|
||||||
let style = user_message_style();
|
let style = user_message_style();
|
||||||
Block::default().style(style).render_ref(composer_rect, buf);
|
Block::default().style(style).render_ref(composer_rect, buf);
|
||||||
if !textarea_rect.is_empty() {
|
if !textarea_rect.is_empty() {
|
||||||
|
let prompt = if self.input_enabled {
|
||||||
|
"›".bold()
|
||||||
|
} else {
|
||||||
|
"›".dim()
|
||||||
|
};
|
||||||
buf.set_span(
|
buf.set_span(
|
||||||
textarea_rect.x - LIVE_PREFIX_COLS,
|
textarea_rect.x - LIVE_PREFIX_COLS,
|
||||||
textarea_rect.y,
|
textarea_rect.y,
|
||||||
&"›".bold(),
|
&prompt,
|
||||||
textarea_rect.width,
|
textarea_rect.width,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1933,7 +1962,15 @@ impl Renderable for ChatComposer {
|
||||||
let mut state = self.textarea_state.borrow_mut();
|
let mut state = self.textarea_state.borrow_mut();
|
||||||
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
||||||
if self.textarea.text().is_empty() {
|
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);
|
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"
|
"'/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::<AppEvent>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,16 @@ impl BottomPane {
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn set_composer_input_enabled(
|
||||||
|
&mut self,
|
||||||
|
enabled: bool,
|
||||||
|
placeholder: Option<String>,
|
||||||
|
) {
|
||||||
|
self.composer.set_input_enabled(enabled, placeholder);
|
||||||
|
self.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn clear_composer_for_ctrl_c(&mut self) {
|
pub(crate) fn clear_composer_for_ctrl_c(&mut self) {
|
||||||
self.composer.clear_for_ctrl_c();
|
self.composer.clear_for_ctrl_c();
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue