Send message by default mid turn. queue messages by tab (#9077)

https://github.com/user-attachments/assets/03838730-4ddc-44df-a2c7-cb8ecda78660
This commit is contained in:
Ahmed Ibrahim authored and GitHub committed 2026-01-12 23:06:35 -08:00
1 parent e726a82c8a
commit cbca43d57a
24 files changed
+875 -342

No files matched your search

+253 -145
View File
@@ -83,6 +83,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
#[derive(Debug, PartialEq)]
pub enum InputResult {
Submitted(String),
Queued(String),
Command(SlashCommand),
CommandWithArgs(SlashCommand, String),
None,
@@ -140,6 +141,7 @@ pub(crate) struct ChatComposer {
transcript_copy_feedback: Option<TranscriptCopyFeedback>,
skills: Option<Vec<SkillMetadata>>,
dismissed_skill_popup_token: Option<String>,
steer_enabled: bool,
}
/// Popup state – at most one can be visible at any time.
@@ -195,6 +197,7 @@ impl ChatComposer {
transcript_copy_feedback: None,
skills: None,
dismissed_skill_popup_token: None,
steer_enabled: false,
};
// Apply configuration via the setter to keep side-effects centralized.
this.set_disable_paste_burst(disable_paste_burst);
@@ -205,6 +208,10 @@ impl ChatComposer {
self.skills = skills;
}
pub fn set_steer_enabled(&mut self, enabled: bool) {
self.steer_enabled = enabled;
}
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
let footer_props = self.footer_props();
let footer_hint_height = self
@@ -1074,6 +1081,199 @@ impl ChatComposer {
self.textarea.set_cursor(new_cursor);
}
/// Prepare text for submission/queuing. Returns None if submission should be suppressed.
fn prepare_submission_text(&mut self) -> Option<String> {
// If we have pending placeholder pastes, replace them in the textarea text
// and continue to the normal submission flow to handle slash commands.
if !self.pending_pastes.is_empty() {
let mut text = self.textarea.text().to_string();
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
text = text.replace(placeholder, actual);
}
}
self.textarea.set_text(&text);
self.pending_pastes.clear();
}
let mut text = self.textarea.text().to_string();
let original_input = text.clone();
let input_starts_with_space = original_input.starts_with(' ');
self.textarea.set_text("");
// Replace all pending pastes in the text
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
text = text.replace(placeholder, actual);
}
}
self.pending_pastes.clear();
// If there is neither text nor attachments, suppress submission entirely.
let has_attachments = !self.attached_images.is_empty();
text = text.trim().to_string();
if let Some((name, _rest)) = parse_slash_name(&text) {
let treat_as_plain_text = input_starts_with_space || name.contains('/');
if !treat_as_plain_text {
let is_builtin = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| {
windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox
})
.any(|(command_name, _)| command_name == name);
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
let is_known_prompt = name
.strip_prefix(&prompt_prefix)
.map(|prompt_name| {
self.custom_prompts
.iter()
.any(|prompt| prompt.name == prompt_name)
})
.unwrap_or(false);
if !is_builtin && !is_known_prompt {
let message = format!(
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(message, None),
)));
self.textarea.set_text(&original_input);
self.textarea.set_cursor(original_input.len());
return None;
}
}
}
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
Ok(expanded) => expanded,
Err(err) => {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(err.user_message()),
)));
self.textarea.set_text(&original_input);
self.textarea.set_cursor(original_input.len());
return None;
}
};
if let Some(expanded) = expanded_prompt {
text = expanded;
}
if text.is_empty() && !has_attachments {
return None;
}
if !text.is_empty() {
self.history.record_local_submission(&text);
}
Some(text)
}
/// Common logic for handling message submission/queuing.
/// Returns the appropriate InputResult based on `should_queue`.
fn handle_submission(&mut self, should_queue: bool) -> (InputResult, bool) {
// If the first line is a bare built-in slash command (no args),
// dispatch it even when the slash popup isn't visible. This preserves
// the workflow: type a prefix ("/di"), press Tab to complete to
// "/diff ", then press Enter/Ctrl+Shift+Q to run it. Tab moves the cursor beyond
// the '/name' token and our caret-based heuristic hides the popup,
// but Enter/Ctrl+Shift+Q should still dispatch the command rather than submit
// literal text.
if let Some(result) = self.try_dispatch_bare_slash_command() {
return (result, true);
}
// If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst
// and accumulate it rather than submitting or inserting immediately.
// Do not treat as paste inside a slash-command context.
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|| self
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/');
if self.paste_burst.is_active() && !in_slash_context {
let now = Instant::now();
if self.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.
let now = Instant::now();
if self
.paste_burst
.newline_should_insert_instead_of_submit(now)
&& !in_slash_context
{
self.textarea.insert_str("\n");
self.paste_burst.extend_window(now);
return (InputResult::None, true);
}
let original_input = self.textarea.text().to_string();
if let Some(result) = self.try_dispatch_slash_command_with_args() {
return (result, true);
}
if let Some(text) = self.prepare_submission_text() {
if should_queue {
(InputResult::Queued(text), true)
} else {
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
(InputResult::Submitted(text), true)
}
} else {
// Restore text if submission was suppressed
self.textarea.set_text(&original_input);
(InputResult::None, true)
}
}
/// 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<InputResult> {
let first_line = self.textarea.text().lines().next().unwrap_or("");
if let Some((name, rest)) = parse_slash_name(first_line)
&& rest.is_empty()
&& let Some((_n, cmd)) = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| {
windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox
})
.find(|(n, _)| *n == name)
{
self.textarea.set_text("");
Some(InputResult::Command(cmd))
} else {
None
}
}
/// 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<InputResult> {
let original_input = self.textarea.text().to_string();
let input_starts_with_space = original_input.starts_with(' ');
if !input_starts_with_space {
let text = self.textarea.text().to_string();
if let Some((name, rest)) = parse_slash_name(&text)
&& !rest.is_empty()
&& !name.contains('/')
&& let Some((_n, cmd)) = built_in_slash_commands()
.into_iter()
.find(|(command_name, _)| *command_name == name)
&& cmd == SlashCommand::Review
{
self.textarea.set_text("");
return Some(InputResult::CommandWithArgs(cmd, rest.to_string()));
}
}
None
}
/// Handle key event when no popup is visible.
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
@@ -1132,156 +1332,28 @@ impl ChatComposer {
}
self.handle_input_basic(key_event)
}
KeyEvent {
code: KeyCode::Tab,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
..
} => self.handle_submission(true),
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
// Tab queues the message instead of submitting immediately
self.handle_submission(true)
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
// If the first line is a bare built-in slash command (no args),
// dispatch it even when the slash popup isn't visible. This preserves
// the workflow: type a prefix ("/di"), press Tab to complete to
// "/diff ", then press Enter to run it. Tab moves the cursor beyond
// the '/name' token and our caret-based heuristic hides the popup,
// but Enter should still dispatch the command rather than submit
// literal text.
let first_line = self.textarea.text().lines().next().unwrap_or("");
if let Some((name, rest)) = parse_slash_name(first_line)
&& rest.is_empty()
&& let Some((_n, cmd)) = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| {
windows_degraded_sandbox_active()
|| *cmd != SlashCommand::ElevateSandbox
})
.find(|(n, _)| *n == name)
{
self.textarea.set_text("");
return (InputResult::Command(cmd), true);
}
// If we're in a paste-like burst capture, treat Enter as part of the burst
// and accumulate it rather than submitting or inserting immediately.
// Do not treat Enter as paste inside a slash-command context.
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|| self
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/');
if self.paste_burst.is_active() && !in_slash_context {
let now = Instant::now();
if self.paste_burst.append_newline_if_active(now) {
return (InputResult::None, true);
}
}
// If we have pending placeholder pastes, replace them in the textarea text
// and continue to the normal submission flow to handle slash commands.
if !self.pending_pastes.is_empty() {
let mut text = self.textarea.text().to_string();
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
text = text.replace(placeholder, actual);
}
}
self.textarea.set_text(&text);
self.pending_pastes.clear();
}
// During a paste-like burst, treat Enter as a newline instead of submit.
let now = Instant::now();
if self
.paste_burst
.newline_should_insert_instead_of_submit(now)
&& !in_slash_context
{
self.textarea.insert_str("\n");
self.paste_burst.extend_window(now);
return (InputResult::None, true);
}
let mut text = self.textarea.text().to_string();
let original_input = text.clone();
let input_starts_with_space = original_input.starts_with(' ');
self.textarea.set_text("");
// Replace all pending pastes in the text
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
text = text.replace(placeholder, actual);
}
}
self.pending_pastes.clear();
// If there is neither text nor attachments, suppress submission entirely.
let has_attachments = !self.attached_images.is_empty();
text = text.trim().to_string();
if let Some((name, _rest)) = parse_slash_name(&text) {
let treat_as_plain_text = input_starts_with_space || name.contains('/');
if !treat_as_plain_text {
let is_builtin = built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| {
windows_degraded_sandbox_active()
|| *cmd != SlashCommand::ElevateSandbox
})
.any(|(command_name, _)| command_name == name);
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
let is_known_prompt = name
.strip_prefix(&prompt_prefix)
.map(|prompt_name| {
self.custom_prompts
.iter()
.any(|prompt| prompt.name == prompt_name)
})
.unwrap_or(false);
if !is_builtin && !is_known_prompt {
let message = format!(
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(message, None),
)));
self.textarea.set_text(&original_input);
self.textarea.set_cursor(original_input.len());
return (InputResult::None, true);
}
}
}
if !input_starts_with_space
&& let Some((name, rest)) = parse_slash_name(&text)
&& !rest.is_empty()
&& !name.contains('/')
&& let Some((_n, cmd)) = built_in_slash_commands()
.into_iter()
.find(|(command_name, _)| *command_name == name)
&& cmd == SlashCommand::Review
{
return (InputResult::CommandWithArgs(cmd, rest.to_string()), true);
}
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
Ok(expanded) => expanded,
Err(err) => {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(err.user_message()),
)));
self.textarea.set_text(&original_input);
self.textarea.set_cursor(original_input.len());
return (InputResult::None, true);
}
};
if let Some(expanded) = expanded_prompt {
text = expanded;
}
if text.is_empty() && !has_attachments {
return (InputResult::None, true);
}
if !text.is_empty() {
self.history.record_local_submission(&text);
}
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
(InputResult::Submitted(text), true)
let should_queue = !self.steer_enabled;
self.handle_submission(should_queue)
}
input => self.handle_input_basic(input),
}
@@ -2177,6 +2249,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_text_content("draft text".to_string());
assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string()));
@@ -2203,6 +2276,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
@@ -2243,6 +2317,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
// Force an active paste burst so this test doesn't depend on tight timing.
composer
@@ -2277,6 +2352,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay);
@@ -2451,6 +2527,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert!(composer.is_in_paste_burst());
@@ -2528,6 +2605,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
// Force an active burst so this test doesn't depend on tight timing.
composer
@@ -2567,6 +2645,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let needs_redraw = composer.handle_paste("hello".to_string());
assert!(needs_redraw);
@@ -2596,6 +2675,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
// Ensure composer is empty and press Enter.
assert!(composer.textarea.text().is_empty());
@@ -2623,6 +2703,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
let needs_redraw = composer.handle_paste(large.clone());
@@ -2658,6 +2739,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.handle_paste(large);
assert_eq!(composer.pending_pastes.len(), 1);
@@ -2742,6 +2824,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
// Type "/mo" humanlike so paste-burst doesn’t interfere.
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
@@ -2770,6 +2853,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
match &composer.active_popup {
@@ -2801,6 +2885,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
// Type "/res" humanlike so paste-burst doesn’t interfere.
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']);
@@ -2826,6 +2911,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']);
match &composer.active_popup {
@@ -2894,6 +2980,9 @@ mod tests {
InputResult::Submitted(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
InputResult::Queued(_) => {
panic!("expected command dispatch, but composer queued literal text")
}
InputResult::None => panic!("expected Command result for '/init'"),
}
assert!(composer.textarea.is_empty(), "composer should be cleared");
@@ -2970,6 +3059,9 @@ mod tests {
InputResult::Submitted(text) => {
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
}
InputResult::Queued(_) => {
panic!("expected command dispatch after Tab completion, got literal queue")
}
InputResult::None => panic!("expected Command result for '/diff'"),
}
assert!(composer.textarea.is_empty());
@@ -3006,6 +3098,9 @@ mod tests {
InputResult::Submitted(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
InputResult::Queued(_) => {
panic!("expected command dispatch, but composer queued literal text")
}
InputResult::None => panic!("expected Command result for '/mention'"),
}
assert!(composer.textarea.is_empty(), "composer should be cleared");
@@ -3028,6 +3123,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
// Define test cases: (paste content, is_large)
let test_cases = [
@@ -3304,6 +3400,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let path = PathBuf::from("/tmp/image1.png");
composer.attach_image(path.clone());
composer.handle_paste(" hi".into());
@@ -3328,6 +3425,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let path = PathBuf::from("/tmp/image2.png");
composer.attach_image(path.clone());
let (result, _) =
@@ -3535,6 +3633,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
// Inject prompts as if received via event.
composer.set_custom_prompts(vec![CustomPrompt {
@@ -3571,6 +3670,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_custom_prompts(vec![CustomPrompt {
name: "my-prompt".to_string(),
@@ -3605,6 +3705,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_custom_prompts(vec![CustomPrompt {
name: "my-prompt".to_string(),
@@ -3643,6 +3744,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
// Create a custom prompt with positional args (no named args like $USER)
composer.set_custom_prompts(vec![CustomPrompt {
@@ -3707,6 +3809,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer
.textarea
@@ -3743,6 +3846,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.textarea.set_text(" /this-looks-like-a-command");
@@ -3876,6 +3980,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_custom_prompts(vec![CustomPrompt {
name: "my-prompt".to_string(),
@@ -3913,6 +4018,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_custom_prompts(vec![CustomPrompt {
name: "elegant".to_string(),
@@ -3980,6 +4086,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_custom_prompts(vec![CustomPrompt {
name: "price".to_string(),
@@ -4017,6 +4124,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_custom_prompts(vec![CustomPrompt {
name: "repeat".to_string(),
+13
View File
@@ -205,6 +205,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut commands = Line::from("");
let mut shell_commands = Line::from("");
let mut newline = Line::from("");
let mut queue_message_tab = Line::from("");
let mut file_paths = Line::from("");
let mut paste_image = Line::from("");
let mut edit_previous = Line::from("");
@@ -217,6 +218,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
ShortcutId::Commands => commands = text,
ShortcutId::ShellCommands => shell_commands = text,
ShortcutId::InsertNewline => newline = text,
ShortcutId::QueueMessageTab => queue_message_tab = text,
ShortcutId::FilePaths => file_paths = text,
ShortcutId::PasteImage => paste_image = text,
ShortcutId::EditPrevious => edit_previous = text,
@@ -230,6 +232,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
commands,
shell_commands,
newline,
queue_message_tab,
file_paths,
paste_image,
edit_previous,
@@ -307,6 +310,7 @@ enum ShortcutId {
Commands,
ShellCommands,
InsertNewline,
QueueMessageTab,
FilePaths,
PasteImage,
EditPrevious,
@@ -412,6 +416,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
prefix: "",
label: " for newline",
},
ShortcutDescriptor {
id: ShortcutId::QueueMessageTab,
bindings: &[ShortcutBinding {
key: key_hint::plain(KeyCode::Tab),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to queue message",
},
ShortcutDescriptor {
id: ShortcutId::FilePaths,
bindings: &[ShortcutBinding {
+4
View File
@@ -137,6 +137,10 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_steer_enabled(&mut self, enabled: bool) {
self.composer.set_steer_enabled(enabled);
}
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
self.status.as_ref()
}
@@ -1,6 +1,5 @@
---
source: tui2/src/bottom_pane/chat_composer.rs
assertion_line: 2093
expression: terminal.backend()
---
" "
@@ -11,8 +10,8 @@ expression: terminal.backend()
" "
" "
" "
" / for commands ! for shell commands "
" shift + enter for newline @ for file paths "
" ctrl + v to paste images esc again to edit previous message "
" ctrl + c to exit "
" ctrl + t to view transcript "
" / for commands ! for shell commands "
" shift + enter for newline tab to queue message "
" @ for file paths ctrl + v to paste images "
" esc again to edit previous message ctrl + c to exit "
" ctrl + t to view transcript "
@@ -1,10 +1,9 @@
---
source: tui2/src/bottom_pane/footer.rs
assertion_line: 486
expression: terminal.backend()
---
" / for commands ! for shell commands "
" shift + enter for newline @ for file paths "
" ctrl + v to paste images esc again to edit previous message "
" ctrl + c to exit "
" ctrl + t to view transcript "
" / for commands ! for shell commands "
" shift + enter for newline tab to queue message "
" @ for file paths ctrl + v to paste images "
" esc again to edit previous message ctrl + c to exit "
" ctrl + t to view transcript "
@@ -9,8 +9,9 @@ expression: terminal.backend()
" "
" "
" "
" "
" / for commands shift + enter for newline "
" @ for file paths ctrl + v to paste images "
" esc again to edit previous message ctrl + c to exit "
" ctrl + t to view transcript "
" / for commands ! for shell commands "
" shift + enter for newline tab to queue message "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc again to edit previous message "
" ctrl + c to exit "
" ctrl + t to view transcript "
@@ -2,7 +2,9 @@
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" / for commands shift + enter for newline "
" @ for file paths ctrl + v to paste images "
" esc again to edit previous message ctrl + c to exit "
" ctrl + t to view transcript "
" / for commands ! for shell commands "
" shift + enter for newline tab to queue message "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc again to edit previous message "
" ctrl + c to exit "
" ctrl + t to view transcript "
+37 -1
View File
@@ -1401,6 +1401,9 @@ impl ChatWidget {
};
widget.prefetch_rate_limits();
widget
.bottom_pane
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
widget
}
@@ -1486,6 +1489,9 @@ impl ChatWidget {
};
widget.prefetch_rate_limits();
widget
.bottom_pane
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
widget
}
@@ -1551,7 +1557,19 @@ impl ChatWidget {
_ => {
match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted(text) => {
// If a task is running, queue the user input to be sent after the turn completes.
// Enter always sends messages immediately (bypasses queue check)
// Clear any reasoning status header when submitting a new message
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
let user_message = UserMessage {
text,
image_paths: self.bottom_pane.take_recent_submission_images(),
};
self.submit_user_message(user_message);
}
InputResult::Queued(text) => {
// Tab queues the message if a task is running, otherwise submits immediately
let user_message = UserMessage {
text,
image_paths: self.bottom_pane.take_recent_submission_images(),
@@ -1837,6 +1855,7 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
}
#[allow(dead_code)] // Used in tests
fn queue_user_message(&mut self, user_message: UserMessage) {
if self.bottom_pane.is_task_running() {
self.queued_user_messages.push_back(user_message);
@@ -2111,9 +2130,23 @@ impl ChatWidget {
fn on_user_message_event(&mut self, event: UserMessageEvent) {
let message = event.message.trim();
// Only show the text portion in conversation history.
if !message.is_empty() {
self.add_to_history(history_cell::new_user_prompt(message.to_string()));
}
// If steer is enabled and a task is running, show hint about queuing with Tab
if self.config.features.enabled(Feature::Steer) && self.bottom_pane.is_task_running() {
use crate::key_hint;
use ratatui::text::Line;
let hint_line = Line::from(vec![
"You can queue messages by pressing ".dim(),
key_hint::plain(KeyCode::Tab).into(),
]);
self.add_to_history(history_cell::PlainHistoryCell::new(vec![hint_line]));
}
self.needs_final_message_separator = false;
}
fn request_exit(&self) {
@@ -3362,6 +3395,9 @@ impl ChatWidget {
} else {
self.config.features.disable(feature);
}
if feature == Feature::Steer {
self.bottom_pane.set_steer_enabled(enabled);
}
}
pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) {
+4 -3
View File
@@ -361,7 +361,7 @@ async fn make_chatwidget_manual(
if let Some(model) = model_override {
cfg.model = Some(model.to_string());
}
let bottom = BottomPane::new(BottomPaneParams {
let mut bottom = BottomPane::new(BottomPaneParams {
app_event_tx: app_event_tx.clone(),
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
@@ -371,6 +371,7 @@ async fn make_chatwidget_manual(
animations_enabled: cfg.animations,
skills: None,
});
bottom.set_steer_enabled(true);
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
let codex_home = cfg.codex_home.clone();
let widget = ChatWidget {
@@ -1009,7 +1010,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
assert_eq!(chat.bottom_pane.composer_text(), "repeat me");
// Queue the prompt while the task is running.
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
}
assert_eq!(chat.queued_user_messages.len(), 3);
@@ -1031,7 +1032,7 @@ async fn streaming_final_answer_keeps_task_running_state() {
chat.bottom_pane
.set_composer_text("queued submission".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(