From 5e3793def286099deaf5a6ae625e1f31ad584790 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Sun, 22 Mar 2026 21:19:31 -0700 Subject: [PATCH] Use Shift+Left to edit queued messages in tmux (#15480) ## Summary - use Shift+Left to edit the most recent queued message when running under tmux - mirror the same binding change in the app-server TUI - add tmux-specific tests and snapshot coverage for the rendered queued-message hint ## Testing - just fmt - cargo test -p codex-tui - cargo test -p codex-tui-app-server - just argument-comment-lint -p codex-tui -p codex-tui-app-server Co-authored-by: Codex --- .../src/bottom_pane/pending_input_preview.rs | 15 +++ ...r_one_message_with_shift_left_binding.snap | 21 +++++ codex-rs/tui/src/chatwidget.rs | 28 +++--- codex-rs/tui/src/chatwidget/tests.rs | 91 ++++++++++++++++--- .../src/bottom_pane/pending_input_preview.rs | 15 +++ ...r_one_message_with_shift_left_binding.snap | 21 +++++ codex-rs/tui_app_server/src/chatwidget.rs | 22 +++-- .../tui_app_server/src/chatwidget/tests.rs | 91 ++++++++++++++++--- 8 files changed, 262 insertions(+), 42 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap create mode 100644 codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index 315e311e0..1f38a1777 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -176,6 +176,21 @@ mod tests { assert_snapshot!("render_one_message", format!("{buf:?}")); } + #[test] + fn render_one_message_with_shift_left_binding() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + queue.set_edit_binding(key_hint::shift(KeyCode::Left)); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_one_message_with_shift_left_binding", + format!("{buf:?}") + ); + } + #[test] fn render_two_messages() { let mut queue = PendingInputPreview::new(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap new file mode 100644 index 000000000..f3ad37e73 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + "• Queued follow-up messages ", + " ↳ Hello, world! ", + " shift + ← edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 38, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3729778b1..4a6ef9286 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -155,6 +155,8 @@ use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_sleep_inhibitor::SleepInhibitor; @@ -195,14 +197,21 @@ const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; /// Choose the keybinding used to edit the most-recently queued message. /// /// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently -/// swallow Alt+Up, so users in those environments would never be able to trigger -/// the edit action. We fall back to Shift+Left for those terminals while -/// keeping the more discoverable Alt+Up everywhere else. +/// swallow Alt+Up, and tmux does not reliably pass that chord through. We fall +/// back to Shift+Left for those environments while keeping the more discoverable +/// Alt+Up everywhere else. /// /// The match is exhaustive so that adding a new `TerminalName` variant forces /// an explicit decision about which binding that terminal should use. -fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyBinding { - match terminal_name { +fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyBinding { + if matches!( + terminal_info.multiplexer.as_ref(), + Some(Multiplexer::Tmux { .. }) + ) { + return key_hint::shift(KeyCode::Left); + } + + match terminal_info.name { TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => { key_hint::shift(KeyCode::Left) } @@ -3617,8 +3626,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -3819,8 +3827,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -4013,8 +4020,7 @@ impl ChatWidget { settings: fallback_default, }; - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d837bb8ab..ed37fc5e7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -120,6 +120,8 @@ use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::builtin_approval_presets; @@ -3756,10 +3758,10 @@ async fn alt_up_edits_most_recent_queued_message() { } async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( - terminal_name: TerminalName, + terminal_info: TerminalInfo, ) { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_name); + chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info); chat.bottom_pane .set_queued_message_edit_binding(chat.queued_message_edit_binding); @@ -3791,37 +3793,102 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::AppleTerminal) - .await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; } #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_warp_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::WarpTerminal) - .await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; } #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_vscode_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::VsCode).await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_tmux() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }) + .await; } #[test] -fn queued_message_edit_binding_mapping_covers_special_terminals() { +fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() { assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::AppleTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::WarpTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::VsCode), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::Iterm2), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::alt(KeyCode::Up) ); } diff --git a/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs index 315e311e0..1f38a1777 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/pending_input_preview.rs @@ -176,6 +176,21 @@ mod tests { assert_snapshot!("render_one_message", format!("{buf:?}")); } + #[test] + fn render_one_message_with_shift_left_binding() { + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + queue.set_edit_binding(key_hint::shift(KeyCode::Left)); + let width = 40; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_one_message_with_shift_left_binding", + format!("{buf:?}") + ); + } + #[test] fn render_two_messages() { let mut queue = PendingInputPreview::new(); diff --git a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap new file mode 100644 index 000000000..d70db5664 --- /dev/null +++ b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap @@ -0,0 +1,21 @@ +--- +source: tui_app_server/src/bottom_pane/pending_input_preview.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + "• Queued follow-up messages ", + " ↳ Hello, world! ", + " shift + ← edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 38, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 82cd0d99b..78fcc84e5 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -199,6 +199,8 @@ use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_sleep_inhibitor::SleepInhibitor; @@ -238,14 +240,21 @@ const APP_SERVER_TUI_STUB_MESSAGE: &str = "Not available in app-server TUI yet." /// Choose the keybinding used to edit the most-recently queued message. /// /// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently -/// swallow Alt+Up, so users in those environments would never be able to trigger -/// the edit action. We fall back to Shift+Left for those terminals while -/// keeping the more discoverable Alt+Up everywhere else. +/// swallow Alt+Up, and tmux does not reliably pass that chord through. We fall +/// back to Shift+Left for those environments while keeping the more discoverable +/// Alt+Up everywhere else. /// /// The match is exhaustive so that adding a new `TerminalName` variant forces /// an explicit decision about which binding that terminal should use. -fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyBinding { - match terminal_name { +fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyBinding { + if matches!( + terminal_info.multiplexer.as_ref(), + Some(Multiplexer::Tmux { .. }) + ) { + return key_hint::shift(KeyCode::Left); + } + + match terminal_info.name { TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => { key_hint::shift(KeyCode::Left) } @@ -4170,8 +4179,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); - let queued_message_edit_binding = - queued_message_edit_binding_for_terminal(terminal_info().name); + let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 639b57da0..31e94249a 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -143,6 +143,8 @@ use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::builtin_approval_presets; @@ -3765,10 +3767,10 @@ async fn alt_up_edits_most_recent_queued_message() { } async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( - terminal_name: TerminalName, + terminal_info: TerminalInfo, ) { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_name); + chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info); chat.bottom_pane .set_queued_message_edit_binding(chat.queued_message_edit_binding); @@ -3800,37 +3802,102 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::AppleTerminal) - .await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; } #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_warp_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::WarpTerminal) - .await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; } #[tokio::test] async fn shift_left_edits_most_recent_queued_message_in_vscode_terminal() { - assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::VsCode).await; + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }) + .await; +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_tmux() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }) + .await; } #[test] -fn queued_message_edit_binding_mapping_covers_special_terminals() { +fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() { assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::AppleTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::AppleTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::WarpTerminal), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::WarpTerminal, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::VsCode), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::VsCode, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( - queued_message_edit_binding_for_terminal(TerminalName::Iterm2), + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + }), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalInfo { + name: TerminalName::Iterm2, + term_program: None, + version: None, + term: None, + multiplexer: None, + }), crate::key_hint::alt(KeyCode::Up) ); }