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
2026-01-12 23:06:35 -08:00
committed by GitHub
Unverified
parent e726a82c8a
commit cbca43d57a
24 changed files with 875 additions and 342 deletions
+14
View File
@@ -1608,6 +1608,17 @@ impl Session {
}
}
pub async fn has_pending_input(&self) -> bool {
let active = self.active_turn.lock().await;
match active.as_ref() {
Some(at) => {
let ts = at.turn_state.lock().await;
ts.has_pending_input()
}
None => false,
}
}
pub async fn list_resources(
&self,
server: &str,
@@ -2904,6 +2915,9 @@ async fn try_run_turn(
.await;
should_emit_turn_diff = true;
needs_follow_up |= sess.has_pending_input().await;
error!("needs_follow_up: {needs_follow_up}");
break Ok(TurnRunResult {
needs_follow_up,
last_agent_message,
+12
View File
@@ -96,6 +96,8 @@ pub enum Feature {
EnableRequestCompression,
/// Enable collab tools.
Collab,
/// Steer feature flag - when enabled, Enter submits immediately instead of queuing.
Steer,
}
impl Feature {
@@ -420,4 +422,14 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Steer,
key: "steer",
stage: Stage::Beta {
name: "Steer conversation",
menu_description: "Enter submits immediately; Tab queues messages when a task is running.",
announcement: "NEW! Try Steer mode: Enter submits immediately, Tab queues. Enable in /experimental!",
},
default_enabled: false,
},
];
+4
View File
@@ -104,6 +104,10 @@ impl TurnState {
ret
}
}
pub(crate) fn has_pending_input(&self) -> bool {
!self.pending_input.is_empty()
}
}
impl ActiveTurn {
+24 -11
View File
@@ -19,6 +19,7 @@ pub struct StreamingSseChunk {
/// Minimal streaming SSE server for tests that need gated per-chunk delivery.
pub struct StreamingSseServer {
uri: String,
requests: Arc<TokioMutex<Vec<Vec<u8>>>>,
shutdown: oneshot::Sender<()>,
task: tokio::task::JoinHandle<()>,
}
@@ -28,6 +29,10 @@ impl StreamingSseServer {
&self.uri
}
pub async fn requests(&self) -> Vec<Vec<u8>> {
self.requests.lock().await.clone()
}
pub async fn shutdown(self) {
let _ = self.shutdown.send(());
let _ = self.task.await;
@@ -61,6 +66,8 @@ pub async fn start_streaming_sse_server(
responses: VecDeque::from(responses),
completions: VecDeque::from(completion_senders),
}));
let requests = Arc::new(TokioMutex::new(Vec::new()));
let requests_for_task = Arc::clone(&requests);
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
let task = tokio::spawn(async move {
@@ -70,6 +77,7 @@ pub async fn start_streaming_sse_server(
accept_res = listener.accept() => {
let (mut stream, _) = accept_res.expect("accept streaming SSE connection");
let state = Arc::clone(&state);
let requests = Arc::clone(&requests_for_task);
tokio::spawn(async move {
let (request, body_prefix) = read_http_request(&mut stream).await;
let Some((method, path)) = parse_request_line(&request) else {
@@ -78,7 +86,7 @@ pub async fn start_streaming_sse_server(
};
if method == "GET" && path == "/v1/models" {
if drain_request_body(&mut stream, &request, body_prefix)
if read_request_body(&mut stream, &request, body_prefix)
.await
.is_err()
{
@@ -95,13 +103,16 @@ pub async fn start_streaming_sse_server(
}
if method == "POST" && path == "/v1/responses" {
if drain_request_body(&mut stream, &request, body_prefix)
let body = match read_request_body(&mut stream, &request, body_prefix)
.await
.is_err()
{
let _ = write_http_response(&mut stream, 400, "bad request", "text/plain").await;
return;
}
Ok(body) => body,
Err(_) => {
let _ = write_http_response(&mut stream, 400, "bad request", "text/plain").await;
return;
}
};
requests.lock().await.push(body);
let Some((chunks, completion)) = take_next_stream(&state).await else {
let _ = write_http_response(&mut stream, 500, "no responses queued", "text/plain").await;
return;
@@ -137,6 +148,7 @@ pub async fn start_streaming_sse_server(
(
StreamingSseServer {
uri,
requests,
shutdown: shutdown_tx,
task,
},
@@ -202,13 +214,13 @@ fn content_length(headers: &str) -> Option<usize> {
})
}
async fn drain_request_body(
async fn read_request_body(
stream: &mut tokio::net::TcpStream,
headers: &str,
mut body_prefix: Vec<u8>,
) -> std::io::Result<()> {
) -> std::io::Result<Vec<u8>> {
let Some(content_len) = content_length(headers) else {
return Ok(());
return Ok(body_prefix);
};
if body_prefix.len() > content_len {
@@ -217,12 +229,13 @@ async fn drain_request_body(
let remaining = content_len.saturating_sub(body_prefix.len());
if remaining == 0 {
return Ok(());
return Ok(body_prefix);
}
let mut rest = vec![0u8; remaining];
stream.read_exact(&mut rest).await?;
Ok(())
body_prefix.extend_from_slice(&rest);
Ok(body_prefix)
}
async fn write_sse_headers(stream: &mut tokio::net::TcpStream) -> std::io::Result<()> {
+1
View File
@@ -42,6 +42,7 @@ mod model_overrides;
mod model_tools;
mod models_etag_responses;
mod otel;
mod pending_input;
mod permissions_messages;
mod prompt_caching;
mod quota_exceeded;
+143
View File
@@ -0,0 +1,143 @@
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_protocol::user_input::UserInput;
use core_test_support::responses;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_message_item_added;
use core_test_support::responses::ev_output_text_delta;
use core_test_support::responses::ev_response_created;
use core_test_support::streaming_sse::StreamingSseChunk;
use core_test_support::streaming_sse::start_streaming_sse_server;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::Value;
use tokio::sync::oneshot;
fn ev_message_item_done(id: &str, text: &str) -> Value {
serde_json::json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"id": id,
"content": [{"type": "output_text", "text": text}]
}
})
}
fn sse_event(event: Value) -> String {
responses::sse(vec![event])
}
fn message_input_texts(body: &Value, role: &str) -> Vec<String> {
body.get("input")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("message"))
.filter(|item| item.get("role").and_then(Value::as_str) == Some(role))
.filter_map(|item| item.get("content").and_then(Value::as_array))
.flatten()
.filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text"))
.filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned))
.collect()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn injected_user_input_triggers_follow_up_request_with_deltas() {
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
let first_chunks = vec![
StreamingSseChunk {
gate: None,
body: sse_event(ev_response_created("resp-1")),
},
StreamingSseChunk {
gate: None,
body: sse_event(ev_message_item_added("msg-1", "")),
},
StreamingSseChunk {
gate: None,
body: sse_event(ev_output_text_delta("first ")),
},
StreamingSseChunk {
gate: None,
body: sse_event(ev_output_text_delta("turn")),
},
StreamingSseChunk {
gate: None,
body: sse_event(ev_message_item_done("msg-1", "first turn")),
},
StreamingSseChunk {
gate: Some(gate_completed_rx),
body: sse_event(ev_completed("resp-1")),
},
];
let second_chunks = vec![
StreamingSseChunk {
gate: None,
body: sse_event(ev_response_created("resp-2")),
},
StreamingSseChunk {
gate: None,
body: sse_event(ev_completed("resp-2")),
},
];
let (server, _completions) =
start_streaming_sse_server(vec![first_chunks, second_chunks]).await;
let codex = test_codex()
.with_model("gpt-5.1")
.build_with_streaming_server(&server)
.await
.unwrap()
.codex;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "first prompt".into(),
}],
final_output_json_schema: None,
})
.await
.unwrap();
wait_for_event(&codex, |event| {
matches!(event, EventMsg::AgentMessageContentDelta(_))
})
.await;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "second prompt".into(),
}],
final_output_json_schema: None,
})
.await
.unwrap();
let _ = gate_completed_tx.send(());
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let requests = server.requests().await;
assert_eq!(requests.len(), 2);
let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request");
let second_body: Value = serde_json::from_slice(&requests[1]).expect("parse second request");
let first_texts = message_input_texts(&first_body, "user");
assert!(first_texts.iter().any(|text| text == "first prompt"));
assert!(!first_texts.iter().any(|text| text == "second prompt"));
let second_texts = message_input_texts(&second_body, "user");
assert!(second_texts.iter().any(|text| text == "first prompt"));
assert!(second_texts.iter().any(|text| text == "second prompt"));
server.shutdown().await;
}
+275 -145
View File
@@ -80,6 +80,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
#[derive(Debug, PartialEq)]
pub enum InputResult {
Submitted(String),
Queued(String),
Command(SlashCommand),
CommandWithArgs(SlashCommand, String),
None,
@@ -132,6 +133,7 @@ pub(crate) struct ChatComposer {
context_window_used_tokens: Option<i64>,
skills: Option<Vec<SkillMetadata>>,
dismissed_skill_popup_token: Option<String>,
steer_enabled: bool,
}
/// Popup state at most one can be visible at any time.
@@ -182,6 +184,7 @@ impl ChatComposer {
context_window_used_tokens: 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);
@@ -192,6 +195,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
@@ -1140,6 +1147,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) {
@@ -1198,156 +1398,19 @@ 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::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),
}
@@ -2208,6 +2271,14 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
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()));
@@ -2234,6 +2305,13 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
@@ -2274,6 +2352,10 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
// Force an active paste burst so this test doesn't depend on tight timing.
composer
@@ -2307,6 +2389,10 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay);
@@ -2481,6 +2567,10 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert!(composer.is_in_paste_burst());
@@ -2510,6 +2600,10 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
@@ -2581,6 +2675,9 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
// Force an active burst so this test doesn't depend on tight timing.
composer
@@ -2620,6 +2717,9 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
let needs_redraw = composer.handle_paste("hello".to_string());
assert!(needs_redraw);
@@ -2649,6 +2749,9 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
composer.set_steer_enabled(true);
// Ensure composer is empty and press Enter.
assert!(composer.textarea.text().is_empty());
@@ -2676,6 +2779,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());
@@ -2711,6 +2815,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);
@@ -2807,6 +2912,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
// Type "/mo" humanlike so paste-burst doesnt interfere.
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
@@ -2835,6 +2941,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 {
@@ -2866,6 +2973,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
// Type "/res" humanlike so paste-burst doesnt interfere.
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']);
@@ -2959,6 +3067,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");
@@ -3035,6 +3146,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());
@@ -3071,6 +3185,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");
@@ -3093,6 +3210,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 = [
@@ -3369,6 +3487,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());
@@ -3393,6 +3512,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, _) =
@@ -3628,6 +3748,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 {
@@ -3664,6 +3785,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(),
@@ -3698,6 +3820,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(),
@@ -3736,6 +3859,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 {
@@ -3800,6 +3924,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer
.textarea
@@ -3836,6 +3961,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");
@@ -3969,6 +4095,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(),
@@ -4006,6 +4133,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(),
@@ -4073,6 +4201,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(),
@@ -4110,6 +4239,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
@@ -161,6 +161,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 external_editor = Line::from("");
@@ -174,6 +175,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::ExternalEditor => external_editor = text,
@@ -188,6 +190,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
commands,
shell_commands,
newline,
queue_message_tab,
file_paths,
paste_image,
external_editor,
@@ -266,6 +269,7 @@ enum ShortcutId {
Commands,
ShellCommands,
InsertNewline,
QueueMessageTab,
FilePaths,
PasteImage,
ExternalEditor,
@@ -372,6 +376,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
@@ -145,6 +145,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: tui/src/bottom_pane/chat_composer.rs
assertion_line: 2151
expression: terminal.backend()
---
" "
@@ -11,8 +10,9 @@ expression: terminal.backend()
" "
" "
" "
" / for commands ! for shell commands "
" shift + enter for newline @ 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 "
" / 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 "
@@ -1,10 +1,10 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 455
expression: terminal.backend()
---
" / for commands ! for shell commands "
" shift + enter for newline @ 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 "
" / 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 "
+34 -1
View File
@@ -1543,6 +1543,9 @@ impl ChatWidget {
};
widget.prefetch_rate_limits();
widget
.bottom_pane
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
widget
}
@@ -1630,6 +1633,9 @@ impl ChatWidget {
};
widget.prefetch_rate_limits();
widget
.bottom_pane
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
widget
}
@@ -1695,7 +1701,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(),
@@ -2109,6 +2127,18 @@ impl ChatWidget {
if !text.is_empty() {
self.add_to_history(history_cell::new_user_prompt(text));
}
// 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;
}
@@ -3634,6 +3664,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
@@ -372,7 +372,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,
@@ -382,6 +382,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 {
@@ -1058,7 +1059,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);
@@ -1080,7 +1081,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!(
+1
View File
@@ -6,5 +6,6 @@ You can run any shell command from Codex using `!` (e.g. `!ls`)
Type / to open the command popup; Tab autocompletes slash commands and saved prompts.
You can define your own `/` commands with custom prompts. More info: https://developers.openai.com/codex/guides/slash-commands#create-your-own-slash-commands-with-custom-prompts
When the composer is empty, press Esc to step back and edit your last message; Enter confirms.
Press Tab to queue a message instead of sending it immediately; Enter always sends immediately.
Paste an image with Ctrl+V to attach it to your next message.
You can resume a previous conversation by running `codex resume`
+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 doesnt 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 doesnt 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!(
+1
View File
@@ -6,5 +6,6 @@ You can run any shell command from Codex using `!` (e.g. `!ls`)
Type / to open the command popup; Tab autocompletes slash commands and saved prompts.
You can define your own `/` commands with custom prompts. More info: https://developers.openai.com/codex/guides/slash-commands#create-your-own-slash-commands-with-custom-prompts
When the composer is empty, press Esc to step back and edit your last message; Enter confirms.
Press Tab to queue a message instead of sending it immediately; Enter always sends immediately.
Paste an image with Ctrl+V to attach it to your next message.
You can resume a previous conversation by running `codex resume`