mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Compare commits
1 Commits
1271d450b1
...
alt-enter-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a80cbc9807 |
180
codex-rs/core/tests/suite/mid_turn_input.rs
Normal file
180
codex-rs/core/tests/suite/mid_turn_input.rs
Normal 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
|
||||
}
|
||||
@@ -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'"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user