Compare commits

...

1 Commits

Author SHA1 Message Date
Ahmed Ibrahim
a80cbc9807 Add alt-enter send and mid-turn input test 2026-01-11 17:15:05 -08:00
13 changed files with 330 additions and 62 deletions

View File

@@ -0,0 +1,180 @@
#![cfg(not(target_os = "windows"))]
use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::user_input::UserInput;
use core_test_support::responses;
use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_local_shell_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::Value;
#[derive(Clone, Copy, Debug)]
enum MidTurnOp {
UserInput,
UserTurn,
}
fn call_output(req: &ResponsesRequest, call_id: &str) -> String {
let raw = req.function_call_output(call_id);
assert_eq!(
raw.get("call_id").and_then(Value::as_str),
Some(call_id),
"mismatched call_id in function_call_output"
);
let (content_opt, _success) = match req.function_call_output_content_and_success(call_id) {
Some(values) => values,
None => panic!("function_call_output present"),
};
match content_opt {
Some(content) => content,
None => panic!("function_call_output content present"),
}
}
async fn run_mid_turn_injection(op_kind: MidTurnOp) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_model("gpt-5");
let TestCodex {
codex,
cwd,
session_configured,
..
} = builder.build(&server).await?;
let call_id = "shell-tool-call";
let command = vec!["/bin/sh", "-c", "sleep 2; echo finished"];
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_local_shell_call(call_id, "completed", command),
ev_completed("resp-1"),
]);
responses::mount_sse_once(&server, first_response).await;
let second_response = sse(vec![
ev_assistant_message("msg-1", "follow up"),
ev_completed("resp-2"),
]);
let second_mock = responses::mount_sse_once(&server, second_response).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "first message".into(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model.clone(),
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::ExecCommandBegin(_))).await;
let mid_turn_text = match op_kind {
MidTurnOp::UserInput => "mid-turn input",
MidTurnOp::UserTurn => "mid-turn turn",
};
match op_kind {
MidTurnOp::UserInput => {
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: mid_turn_text.to_string(),
}],
final_output_json_schema: None,
})
.await?;
}
MidTurnOp::UserTurn => {
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: mid_turn_text.to_string(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
}
}
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let req = second_mock.single_request();
let output_text = call_output(&req, call_id);
let exec_output: Value = serde_json::from_str(&output_text)?;
let stdout = exec_output["output"].as_str().expect("stdout field");
assert_eq!(stdout.trim(), "finished");
let user_messages = req.message_input_texts("user");
assert_eq!(
user_messages,
vec!["first message".to_string(), mid_turn_text.to_string()]
);
let input = req.input();
let call_idx = input
.iter()
.position(|item| {
item.get("type").and_then(Value::as_str) == Some("function_call_output")
&& item.get("call_id").and_then(Value::as_str) == Some(call_id)
})
.expect("function_call_output item");
let user_idx = input
.iter()
.position(|item| {
item.get("type").and_then(Value::as_str) == Some("message")
&& item.get("role").and_then(Value::as_str) == Some("user")
&& item
.get("content")
.and_then(Value::as_array)
.map(|content| {
content.iter().any(|span| {
span.get("type").and_then(Value::as_str) == Some("input_text")
&& span.get("text").and_then(Value::as_str) == Some(mid_turn_text)
})
})
.unwrap_or(false)
})
.expect("mid-turn user message");
assert!(call_idx < user_idx, "expected tool output before mid-turn input");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mid_turn_user_input_is_injected_after_tool_call() -> anyhow::Result<()> {
run_mid_turn_injection(MidTurnOp::UserInput).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mid_turn_user_turn_is_injected_after_tool_call() -> anyhow::Result<()> {
run_mid_turn_injection(MidTurnOp::UserTurn).await
}

View File

@@ -80,6 +80,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
#[derive(Debug, PartialEq)]
pub enum InputResult {
Submitted(String),
SubmittedImmediate(String),
Command(SlashCommand),
CommandWithArgs(SlashCommand, String),
None,
@@ -605,9 +606,15 @@ impl ChatComposer {
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
modifiers,
..
} => {
} if matches!(
modifiers,
KeyModifiers::NONE | KeyModifiers::SHIFT | KeyModifiers::ALT
) || modifiers == (KeyModifiers::SHIFT | KeyModifiers::ALT) =>
{
let submit_immediately =
modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT);
// If the current line starts with a custom prompt name and includes
// positional args for a numeric-style template, expand and submit
// immediately regardless of the popup selection.
@@ -619,7 +626,7 @@ impl ChatComposer {
expand_if_numeric_with_positional_args(prompt, first_line)
{
self.textarea.set_text("");
return (InputResult::Submitted(expanded), true);
return (Self::submitted_result(expanded, submit_immediately), true);
}
if let Some(sel) = popup.selected_item() {
@@ -637,7 +644,10 @@ impl ChatComposer {
) {
PromptSelectionAction::Submit { text } => {
self.textarea.set_text("");
return (InputResult::Submitted(text), true);
return (
Self::submitted_result(text, submit_immediately),
true,
);
}
PromptSelectionAction::Insert { text, cursor } => {
let target = cursor.unwrap_or(text.len());
@@ -1140,6 +1150,14 @@ impl ChatComposer {
self.textarea.set_cursor(new_cursor);
}
fn submitted_result(text: String, submit_immediately: bool) -> InputResult {
if submit_immediately {
InputResult::SubmittedImmediate(text)
} else {
InputResult::Submitted(text)
}
}
/// 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) {
@@ -1200,9 +1218,15 @@ impl ChatComposer {
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
modifiers,
..
} => {
} if matches!(
modifiers,
KeyModifiers::NONE | KeyModifiers::SHIFT | KeyModifiers::ALT
) || modifiers == (KeyModifiers::SHIFT | KeyModifiers::ALT) =>
{
let submit_immediately =
modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT);
// 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
@@ -1347,7 +1371,7 @@ impl ChatComposer {
self.history.record_local_submission(&text);
}
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
(InputResult::Submitted(text), true)
(Self::submitted_result(text, submit_immediately), true)
}
input => self.handle_input_basic(input),
}
@@ -2944,7 +2968,7 @@ mod tests {
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/init'")
}
InputResult::Submitted(text) => {
InputResult::Submitted(text) | InputResult::SubmittedImmediate(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
InputResult::None => panic!("expected Command result for '/init'"),
@@ -3020,7 +3044,7 @@ mod tests {
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/diff'")
}
InputResult::Submitted(text) => {
InputResult::Submitted(text) | InputResult::SubmittedImmediate(text) => {
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
}
InputResult::None => panic!("expected Command result for '/diff'"),
@@ -3056,7 +3080,7 @@ mod tests {
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/mention'")
}
InputResult::Submitted(text) => {
InputResult::Submitted(text) | InputResult::SubmittedImmediate(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
InputResult::None => panic!("expected Command result for '/mention'"),

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 send_immediately = 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::SendImmediate => send_immediately = 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,
send_immediately,
file_paths,
paste_image,
external_editor,
@@ -266,6 +269,7 @@ enum ShortcutId {
Commands,
ShellCommands,
InsertNewline,
SendImmediate,
FilePaths,
PasteImage,
ExternalEditor,
@@ -290,7 +294,6 @@ impl ShortcutBinding {
enum DisplayCondition {
Always,
WhenShiftEnterHint,
WhenNotShiftEnterHint,
WhenUnderWSL,
}
@@ -299,7 +302,6 @@ impl DisplayCondition {
match self {
DisplayCondition::Always => true,
DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint,
DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint,
DisplayCondition::WhenUnderWSL => state.is_wsl,
}
}
@@ -359,18 +361,27 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
},
ShortcutDescriptor {
id: ShortcutId::InsertNewline,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('j')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " for newline",
},
ShortcutDescriptor {
id: ShortcutId::SendImmediate,
bindings: &[
ShortcutBinding {
key: key_hint::alt(KeyCode::Enter),
condition: DisplayCondition::Always,
},
ShortcutBinding {
key: key_hint::shift(KeyCode::Enter),
condition: DisplayCondition::WhenShiftEnterHint,
},
ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('j')),
condition: DisplayCondition::WhenNotShiftEnterHint,
},
],
prefix: "",
label: " for newline",
label: " to send immediately",
},
ShortcutDescriptor {
id: ShortcutId::FilePaths,

View File

@@ -1,6 +1,6 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 2151
assertion_line: 2140
expression: terminal.backend()
---
" "
@@ -11,8 +11,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 "
" ctrl + j for newline ⌥ + enter to send immediately "
" @ 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 "

View File

@@ -1,10 +1,11 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 455
assertion_line: 466
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 "
" ctrl + j for newline ⌥ + enter to send immediately "
" @ 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 "

View File

@@ -1640,6 +1640,13 @@ impl ChatWidget {
};
self.queue_user_message(user_message);
}
InputResult::SubmittedImmediate(text) => {
let user_message = UserMessage {
text,
image_paths: self.bottom_pane.take_recent_submission_images(),
};
self.submit_user_message(user_message);
}
InputResult::Command(cmd) => {
self.dispatch_command(cmd);
}

View File

@@ -2,7 +2,7 @@
//!
//! This exposes a minimal interface suitable for other crates (e.g.,
//! codex-cloud-tasks) to reuse the mature composer behavior: multi-line input,
//! paste heuristics, Enter-to-submit, and Shift+Enter for newline.
//! paste heuristics, Enter-to-submit, and Shift/Alt+Enter to send immediately.
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
@@ -36,7 +36,7 @@ impl ComposerInput {
pub fn new() -> Self {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let sender = AppEventSender::new(tx.clone());
// `enhanced_keys_supported=true` enables Shift+Enter newline hint/behavior.
// `enhanced_keys_supported=true` enables Shift+Enter submit behavior.
let inner = ChatComposer::new(true, sender, true, "Compose new task".to_string(), false);
Self { inner, _tx: tx, rx }
}
@@ -55,6 +55,7 @@ impl ComposerInput {
pub fn input(&mut self, key: KeyEvent) -> ComposerAction {
let action = match self.inner.handle_key_event(key).0 {
InputResult::Submitted(text) => ComposerAction::Submitted(text),
InputResult::SubmittedImmediate(text) => ComposerAction::Submitted(text),
_ => ComposerAction::None,
};
self.drain_app_events();

View File

@@ -83,6 +83,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
#[derive(Debug, PartialEq)]
pub enum InputResult {
Submitted(String),
SubmittedImmediate(String),
Command(SlashCommand),
CommandWithArgs(SlashCommand, String),
None,
@@ -539,9 +540,15 @@ impl ChatComposer {
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
modifiers,
..
} => {
} if matches!(
modifiers,
KeyModifiers::NONE | KeyModifiers::SHIFT | KeyModifiers::ALT
) || modifiers == (KeyModifiers::SHIFT | KeyModifiers::ALT) =>
{
let submit_immediately =
modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT);
// If the current line starts with a custom prompt name and includes
// positional args for a numeric-style template, expand and submit
// immediately regardless of the popup selection.
@@ -553,7 +560,7 @@ impl ChatComposer {
expand_if_numeric_with_positional_args(prompt, first_line)
{
self.textarea.set_text("");
return (InputResult::Submitted(expanded), true);
return (Self::submitted_result(expanded, submit_immediately), true);
}
if let Some(sel) = popup.selected_item() {
@@ -571,7 +578,10 @@ impl ChatComposer {
) {
PromptSelectionAction::Submit { text } => {
self.textarea.set_text("");
return (InputResult::Submitted(text), true);
return (
Self::submitted_result(text, submit_immediately),
true,
);
}
PromptSelectionAction::Insert { text, cursor } => {
let target = cursor.unwrap_or(text.len());
@@ -1074,6 +1084,14 @@ impl ChatComposer {
self.textarea.set_cursor(new_cursor);
}
fn submitted_result(text: String, submit_immediately: bool) -> InputResult {
if submit_immediately {
InputResult::SubmittedImmediate(text)
} else {
InputResult::Submitted(text)
}
}
/// 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) {
@@ -1134,9 +1152,15 @@ impl ChatComposer {
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
modifiers,
..
} => {
} if matches!(
modifiers,
KeyModifiers::NONE | KeyModifiers::SHIFT | KeyModifiers::ALT
) || modifiers == (KeyModifiers::SHIFT | KeyModifiers::ALT) =>
{
let submit_immediately =
modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT);
// 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
@@ -1281,7 +1305,7 @@ impl ChatComposer {
self.history.record_local_submission(&text);
}
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
(InputResult::Submitted(text), true)
(Self::submitted_result(text, submit_immediately), true)
}
input => self.handle_input_basic(input),
}
@@ -2891,7 +2915,7 @@ mod tests {
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/init'")
}
InputResult::Submitted(text) => {
InputResult::Submitted(text) | InputResult::SubmittedImmediate(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
InputResult::None => panic!("expected Command result for '/init'"),
@@ -2967,7 +2991,7 @@ mod tests {
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/diff'")
}
InputResult::Submitted(text) => {
InputResult::Submitted(text) | InputResult::SubmittedImmediate(text) => {
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
}
InputResult::None => panic!("expected Command result for '/diff'"),
@@ -3003,7 +3027,7 @@ mod tests {
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/mention'")
}
InputResult::Submitted(text) => {
InputResult::Submitted(text) | InputResult::SubmittedImmediate(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
InputResult::None => panic!("expected Command result for '/mention'"),

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 send_immediately = 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::SendImmediate => send_immediately = 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,
send_immediately,
file_paths,
paste_image,
edit_previous,
@@ -307,6 +310,7 @@ enum ShortcutId {
Commands,
ShellCommands,
InsertNewline,
SendImmediate,
FilePaths,
PasteImage,
EditPrevious,
@@ -330,7 +334,6 @@ impl ShortcutBinding {
enum DisplayCondition {
Always,
WhenShiftEnterHint,
WhenNotShiftEnterHint,
WhenUnderWSL,
}
@@ -339,7 +342,6 @@ impl DisplayCondition {
match self {
DisplayCondition::Always => true,
DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint,
DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint,
DisplayCondition::WhenUnderWSL => state.is_wsl,
}
}
@@ -399,18 +401,27 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
},
ShortcutDescriptor {
id: ShortcutId::InsertNewline,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('j')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " for newline",
},
ShortcutDescriptor {
id: ShortcutId::SendImmediate,
bindings: &[
ShortcutBinding {
key: key_hint::alt(KeyCode::Enter),
condition: DisplayCondition::Always,
},
ShortcutBinding {
key: key_hint::shift(KeyCode::Enter),
condition: DisplayCondition::WhenShiftEnterHint,
},
ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('j')),
condition: DisplayCondition::WhenNotShiftEnterHint,
},
],
prefix: "",
label: " for newline",
label: " to send immediately",
},
ShortcutDescriptor {
id: ShortcutId::FilePaths,

View File

@@ -1,6 +1,6 @@
---
source: tui2/src/bottom_pane/chat_composer.rs
assertion_line: 2093
assertion_line: 2109
expression: terminal.backend()
---
" "
@@ -11,8 +11,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 "
" ctrl + j for newline ⌥ + enter to send immediately "
" @ for file paths ctrl + v to paste images "
" esc again to edit previous message ctrl + c to exit "
" ctrl + t to view transcript "

View File

@@ -1,10 +1,10 @@
---
source: tui2/src/bottom_pane/footer.rs
assertion_line: 486
assertion_line: 497
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 "
" ctrl + j for newline ⌥ + enter to send immediately "
" @ for file paths ctrl + v to paste images "
" esc again to edit previous message ctrl + c to exit "
" ctrl + t to view transcript "

View File

@@ -1499,6 +1499,13 @@ impl ChatWidget {
};
self.queue_user_message(user_message);
}
InputResult::SubmittedImmediate(text) => {
let user_message = UserMessage {
text,
image_paths: self.bottom_pane.take_recent_submission_images(),
};
self.submit_user_message(user_message);
}
InputResult::Command(cmd) => {
self.dispatch_command(cmd);
}

View File

@@ -2,7 +2,7 @@
//!
//! This exposes a minimal interface suitable for other crates (e.g.,
//! codex-cloud-tasks) to reuse the mature composer behavior: multi-line input,
//! paste heuristics, Enter-to-submit, and Shift+Enter for newline.
//! paste heuristics, Enter-to-submit, and Shift/Alt+Enter to send immediately.
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
@@ -36,7 +36,7 @@ impl ComposerInput {
pub fn new() -> Self {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let sender = AppEventSender::new(tx.clone());
// `enhanced_keys_supported=true` enables Shift+Enter newline hint/behavior.
// `enhanced_keys_supported=true` enables Shift+Enter submit behavior.
let inner = ChatComposer::new(true, sender, true, "Compose new task".to_string(), false);
Self { inner, _tx: tx, rx }
}
@@ -55,6 +55,7 @@ impl ComposerInput {
pub fn input(&mut self, key: KeyEvent) -> ComposerAction {
let action = match self.inner.handle_key_event(key).0 {
InputResult::Submitted(text) => ComposerAction::Submitted(text),
InputResult::SubmittedImmediate(text) => ComposerAction::Submitted(text),
_ => ComposerAction::None,
};
self.drain_app_events();