Compare commits

...

3 Commits

Author SHA1 Message Date
Ahmed Ibrahim
f016c9d79e Handle repeat key events for send-immediately 2026-01-11 18:35:14 -08:00
Ahmed Ibrahim
9116629f2c Treat Alt+Enter as Enter char codes 2026-01-11 18:30:01 -08:00
Ahmed Ibrahim
a0f7b323a4 Add Alt+Enter send-immediately + mid-turn input tests 2026-01-11 18:13:58 -08:00
15 changed files with 689 additions and 378 deletions

View File

@@ -0,0 +1,231 @@
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::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::json;
use serde_json::Value;
fn text_user_input(text: &str) -> Value {
json!({
"type": "message",
"role": "user",
"content": [ { "type": "input_text", "text": text } ]
})
}
fn find_input_index(input: &[Value], expected: &Value, label: &str) -> usize {
input
.iter()
.position(|item| item == expected)
.unwrap_or_else(|| panic!("expected {label} in input"))
}
fn find_function_call_output_index(input: &[Value], call_id: &str) -> usize {
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)
})
.unwrap_or_else(|| panic!("function_call_output {call_id} item not found in input"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mid_turn_user_input_is_inserted_after_tool_output() -> anyhow::Result<()> {
let call_id = "call-mid-turn-input";
let first_text = "first message";
let mid_text = "mid-turn input";
let args = json!({
"command": "sleep 2; echo finished",
"timeout_ms": 10_000,
})
.to_string();
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &args),
ev_completed("resp-1"),
]);
let follow_up_response = sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]);
let server = start_mock_server().await;
let first_mock = mount_sse_once(&server, first_response).await;
let follow_up_mock = mount_sse_once(&server, follow_up_response).await;
let test = test_codex().with_model("gpt-5.1").build(&server).await?;
let codex = test.codex.clone();
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: first_text.to_string(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| {
matches!(ev, EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id)
})
.await;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: mid_text.to_string(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let first_request = first_mock.single_request();
let first_input = first_request.input();
assert_eq!(
first_input,
vec![text_user_input(first_text)],
"expected first request input to match the initial message"
);
let follow_up = follow_up_mock.single_request();
let input = follow_up.input();
let first_message = text_user_input(first_text);
let mid_message = text_user_input(mid_text);
let first_index = find_input_index(&input, &first_message, "first user message");
let mid_index = find_input_index(&input, &mid_message, "mid-turn user message");
assert!(
first_index < mid_index,
"expected first message before mid-turn message"
);
let output_index = find_function_call_output_index(&input, call_id);
assert!(
output_index < mid_index,
"expected tool output before mid-turn message"
);
let output = follow_up
.function_call_output_text(call_id)
.expect("missing function_call_output text");
assert!(
output.contains("finished"),
"expected tool output to include \"finished\", got: {output}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mid_turn_user_turn_is_inserted_after_tool_output() -> anyhow::Result<()> {
let call_id = "call-mid-turn-turn";
let first_text = "first message";
let mid_text = "mid-turn turn";
let args = json!({
"command": "sleep 2; echo finished",
"timeout_ms": 10_000,
})
.to_string();
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &args),
ev_completed("resp-1"),
]);
let follow_up_response = sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]);
let server = start_mock_server().await;
let first_mock = mount_sse_once(&server, first_response).await;
let follow_up_mock = mount_sse_once(&server, follow_up_response).await;
let test = test_codex().with_model("gpt-5.1").build(&server).await?;
let codex = test.codex.clone();
let cwd = test.cwd_path().to_path_buf();
let model = test.session_configured.model.clone();
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: first_text.to_string(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| {
matches!(ev, EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id)
})
.await;
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: mid_text.to_string(),
}],
final_output_json_schema: None,
cwd,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model,
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let first_request = first_mock.single_request();
let first_input = first_request.input();
assert_eq!(
first_input,
vec![text_user_input(first_text)],
"expected first request input to match the initial message"
);
let follow_up = follow_up_mock.single_request();
let input = follow_up.input();
let first_message = text_user_input(first_text);
let mid_message = text_user_input(mid_text);
let first_index = find_input_index(&input, &first_message, "first user message");
let mid_index = find_input_index(&input, &mid_message, "mid-turn user message");
assert!(
first_index < mid_index,
"expected first message before mid-turn message"
);
let output_index = find_function_call_output_index(&input, call_id);
assert!(
output_index < mid_index,
"expected tool output before mid-turn message"
);
let output = follow_up
.function_call_output_text(call_id)
.expect("missing function_call_output text");
assert!(
output.contains("finished"),
"expected tool output to include \"finished\", got: {output}"
);
Ok(())
}

View File

@@ -1,4 +1,5 @@
use crate::key_hint::has_ctrl_or_alt;
use crate::key_hint::is_altgr;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -80,6 +81,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
#[derive(Debug, PartialEq)]
pub enum InputResult {
Submitted(String),
SubmittedImmediately(String),
Command(SlashCommand),
CommandWithArgs(SlashCommand, String),
None,
@@ -1140,6 +1142,175 @@ impl ChatComposer {
self.textarea.set_cursor(new_cursor);
}
fn is_send_immediately_key(&self, key_event: &KeyEvent) -> bool {
if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return false;
}
let is_enter = matches!(
key_event.code,
KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
);
if !is_enter {
return false;
}
let modifiers = key_event.modifiers;
if modifiers.contains(KeyModifiers::ALT) && !is_altgr(modifiers) {
return true;
}
modifiers == KeyModifiers::SHIFT
}
fn handle_submit(&mut self, immediate: 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 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().
let result = if immediate {
InputResult::SubmittedImmediately(text)
} else {
InputResult::Submitted(text)
};
(result, true)
}
/// 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) {
@@ -1156,6 +1327,10 @@ impl ChatComposer {
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
if self.is_task_running && self.is_send_immediately_key(&key_event) {
return self.handle_submit(true);
}
match key_event {
KeyEvent {
code: KeyCode::Char('d'),
@@ -1202,153 +1377,7 @@ impl ChatComposer {
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)
}
} => self.handle_submit(false),
input => self.handle_input_basic(input),
}
}
@@ -2490,7 +2519,9 @@ mod tests {
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "1あ"),
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
assert_eq!(text, "1あ")
}
_ => panic!("expected Submitted"),
}
}
@@ -2629,7 +2660,9 @@ mod tests {
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "hello"),
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
assert_eq!(text, "hello")
}
_ => panic!("expected Submitted"),
}
}
@@ -2689,7 +2722,9 @@ mod tests {
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, large),
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
assert_eq!(text, large)
}
_ => panic!("expected Submitted"),
}
assert!(composer.pending_pastes.is_empty());
@@ -2944,7 +2979,7 @@ mod tests {
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/init'")
}
InputResult::Submitted(text) => {
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
InputResult::None => panic!("expected Command result for '/init'"),
@@ -3020,7 +3055,7 @@ mod tests {
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/diff'")
}
InputResult::Submitted(text) => {
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
}
InputResult::None => panic!("expected Command result for '/diff'"),
@@ -3056,7 +3091,7 @@ mod tests {
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/mention'")
}
InputResult::Submitted(text) => {
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
InputResult::None => panic!("expected Command result for '/mention'"),
@@ -3363,7 +3398,9 @@ mod tests {
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"),
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
assert_eq!(text, "[Image #1] hi")
}
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
@@ -3386,7 +3423,9 @@ mod tests {
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"),
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
assert_eq!(text, "[Image #1]")
}
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
@@ -3759,7 +3798,7 @@ mod tests {
// Verify the custom prompt was expanded with the large content as positional arg
match result {
InputResult::Submitted(text) => {
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
// The prompt should be expanded, with the large content replacing $1
assert_eq!(
text,

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::SendImmediately => 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,
SendImmediately,
FilePaths,
PasteImage,
ExternalEditor,
@@ -372,6 +376,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
prefix: "",
label: " for newline",
},
ShortcutDescriptor {
id: ShortcutId::SendImmediately,
bindings: &[ShortcutBinding {
key: key_hint::alt(KeyCode::Enter),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to send immediately",
},
ShortcutDescriptor {
id: ShortcutId::FilePaths,
bindings: &[ShortcutBinding {

View File

@@ -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 ⌥ + 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,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 ⌥ + 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::SubmittedImmediately(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

@@ -54,7 +54,9 @@ impl ComposerInput {
/// Feed a key event into the composer and return a high-level action.
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::Submitted(text) | InputResult::SubmittedImmediately(text) => {
ComposerAction::Submitted(text)
}
_ => ComposerAction::None,
};
self.drain_app_events();

View File

@@ -18,9 +18,6 @@ use crossterm::event::DisableFocusChange;
use crossterm::event::EnableBracketedPaste;
use crossterm::event::EnableFocusChange;
use crossterm::event::KeyEvent;
use crossterm::event::KeyboardEnhancementFlags;
use crossterm::event::PopKeyboardEnhancementFlags;
use crossterm::event::PushKeyboardEnhancementFlags;
use crossterm::terminal::EnterAlternateScreen;
use crossterm::terminal::LeaveAlternateScreen;
use crossterm::terminal::supports_keyboard_enhancement;
@@ -59,21 +56,6 @@ pub fn set_modes() -> Result<()> {
execute!(stdout(), EnableBracketedPaste)?;
enable_raw_mode()?;
// Enable keyboard enhancement flags so modifiers for keys like Enter are disambiguated.
// chat_composer.rs is using a keyboard event listener to enter for any modified keys
// to create a new line that require this.
// Some terminals (notably legacy Windows consoles) do not support
// keyboard enhancement flags. Attempt to enable them, but continue
// gracefully if unsupported.
let _ = execute!(
stdout(),
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
);
let _ = execute!(stdout(), EnableFocusChange);
Ok(())
}
@@ -121,8 +103,6 @@ impl Command for DisableAlternateScroll {
}
fn restore_common(should_disable_raw_mode: bool) -> Result<()> {
// Pop may fail on platforms that didn't support the push; ignore errors.
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
execute!(stdout(), DisableBracketedPaste)?;
let _ = execute!(stdout(), DisableFocusChange);
if should_disable_raw_mode {

View File

@@ -1,6 +1,7 @@
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::has_ctrl_or_alt;
use crate::key_hint::is_altgr;
use crate::transcript_copy_action::TranscriptCopyFeedback;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -83,6 +84,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
#[derive(Debug, PartialEq)]
pub enum InputResult {
Submitted(String),
SubmittedImmediately(String),
Command(SlashCommand),
CommandWithArgs(SlashCommand, String),
None,
@@ -1074,6 +1076,175 @@ impl ChatComposer {
self.textarea.set_cursor(new_cursor);
}
fn is_send_immediately_key(&self, key_event: &KeyEvent) -> bool {
if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return false;
}
let is_enter = matches!(
key_event.code,
KeyCode::Enter | KeyCode::Char('\n') | KeyCode::Char('\r')
);
if !is_enter {
return false;
}
let modifiers = key_event.modifiers;
if modifiers.contains(KeyModifiers::ALT) && !is_altgr(modifiers) {
return true;
}
modifiers == KeyModifiers::SHIFT
}
fn handle_submit(&mut self, immediate: 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 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().
let result = if immediate {
InputResult::SubmittedImmediately(text)
} else {
InputResult::Submitted(text)
};
(result, true)
}
/// 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) {
@@ -1090,6 +1261,10 @@ impl ChatComposer {
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
if self.is_task_running && self.is_send_immediately_key(&key_event) {
return self.handle_submit(true);
}
match key_event {
KeyEvent {
code: KeyCode::Char('d'),
@@ -1136,153 +1311,7 @@ impl ChatComposer {
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)
}
} => self.handle_submit(false),
input => self.handle_input_basic(input),
}
}
@@ -2460,7 +2489,9 @@ mod tests {
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "1あ"),
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
assert_eq!(text, "1あ")
}
_ => panic!("expected Submitted"),
}
}
@@ -2576,7 +2607,9 @@ mod tests {
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "hello"),
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
assert_eq!(text, "hello")
}
_ => panic!("expected Submitted"),
}
}
@@ -2636,7 +2669,9 @@ mod tests {
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, large),
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
assert_eq!(text, large)
}
_ => panic!("expected Submitted"),
}
assert!(composer.pending_pastes.is_empty());
@@ -2891,7 +2926,7 @@ mod tests {
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/init'")
}
InputResult::Submitted(text) => {
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
InputResult::None => panic!("expected Command result for '/init'"),
@@ -2967,7 +3002,7 @@ mod tests {
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/diff'")
}
InputResult::Submitted(text) => {
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
}
InputResult::None => panic!("expected Command result for '/diff'"),
@@ -3003,7 +3038,7 @@ mod tests {
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/mention'")
}
InputResult::Submitted(text) => {
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
InputResult::None => panic!("expected Command result for '/mention'"),
@@ -3310,7 +3345,9 @@ mod tests {
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"),
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
assert_eq!(text, "[Image #1] hi")
}
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
@@ -3333,7 +3370,9 @@ mod tests {
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"),
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
assert_eq!(text, "[Image #1]")
}
_ => panic!("expected Submitted"),
}
let imgs = composer.take_recent_submission_images();
@@ -3678,7 +3717,7 @@ mod tests {
// Verify the custom prompt was expanded with the large content as positional arg
match result {
InputResult::Submitted(text) => {
InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => {
// The prompt should be expanded, with the large content replacing $1
assert_eq!(
text,

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::SendImmediately => 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,
SendImmediately,
FilePaths,
PasteImage,
EditPrevious,
@@ -412,6 +416,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
prefix: "",
label: " for newline",
},
ShortcutDescriptor {
id: ShortcutId::SendImmediately,
bindings: &[ShortcutBinding {
key: key_hint::alt(KeyCode::Enter),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to send immediately",
},
ShortcutDescriptor {
id: ShortcutId::FilePaths,
bindings: &[ShortcutBinding {

View File

@@ -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 ⌥ + 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,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 ⌥ + 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::SubmittedImmediately(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

@@ -54,7 +54,9 @@ impl ComposerInput {
/// Feed a key event into the composer and return a high-level action.
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::Submitted(text) | InputResult::SubmittedImmediately(text) => {
ComposerAction::Submitted(text)
}
_ => ComposerAction::None,
};
self.drain_app_events();

View File

@@ -18,9 +18,6 @@ use crossterm::event::EnableFocusChange;
use crossterm::event::EnableMouseCapture;
use crossterm::event::Event;
use crossterm::event::KeyEvent;
use crossterm::event::KeyboardEnhancementFlags;
use crossterm::event::PopKeyboardEnhancementFlags;
use crossterm::event::PushKeyboardEnhancementFlags;
use crossterm::terminal::EnterAlternateScreen;
use crossterm::terminal::LeaveAlternateScreen;
use crossterm::terminal::supports_keyboard_enhancement;
@@ -60,21 +57,6 @@ pub fn set_modes() -> Result<()> {
execute!(stdout(), EnableBracketedPaste)?;
enable_raw_mode()?;
// Enable keyboard enhancement flags so modifiers for keys like Enter are disambiguated.
// chat_composer.rs is using a keyboard event listener to enter for any modified keys
// to create a new line that require this.
// Some terminals (notably legacy Windows consoles) do not support
// keyboard enhancement flags. Attempt to enable them, but continue
// gracefully if unsupported.
let _ = execute!(
stdout(),
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
);
let _ = execute!(stdout(), EnableFocusChange);
// Enable application mouse mode so scroll events are delivered as
// Mouse events instead of arrow keys.
@@ -85,8 +67,6 @@ pub fn set_modes() -> Result<()> {
/// Restore the terminal to its original state.
/// Inverse of `set_modes`.
pub fn restore() -> Result<()> {
// Pop may fail on platforms that didn't support the push; ignore errors.
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
let _ = execute!(stdout(), DisableMouseCapture);
execute!(stdout(), DisableBracketedPaste)?;
let _ = execute!(stdout(), DisableFocusChange);