Prefer Shift-arrow hints for queued messages and questions (#47618)

## Why

Queued-message edit hints could vary by terminal instead of showing the first configured shortcut. Use the keymap consistently for queued messages and async question navigation.

## What changed

- Make `Shift+Left` the primary shortcut for advancing through questions and editing the last queued message, and `Shift+Right` for navigating back toward the composer. Keep `Alt+Up` and `Alt+Down` as alternatives.
- Remove terminal-specific hint selection and derive hints from the configured keymap, including custom chords and unbound actions.
- Update question footers, queued-input previews, and keymap picker snapshots to reflect the preferred shortcuts.

## Testing

Extend tests to cover both default shortcut pairs, configured shortcut ordering, custom chord hints, and hiding the queued-message edit hint when the action is unbound.

GitOrigin-RevId: e0f60860ef1e81c71ca0fc32369b8cbd12582b47
This commit is contained in:
Ian MacLeod
2026-09-23 18:39:19 +00:00
committed by copyberry
parent 88b498ded4
commit 591ffb1aae
56 changed files with 202 additions and 388 deletions
@@ -4,7 +4,7 @@ expression: "normalize_snapshot_paths(render_bottom_popup(&app.chat_widget, 80))
---
• Queued follow-up inputs
↳ keep queued
⌥+↑ edit last queued message
shift+← edit last queued message
› keep draft
@@ -7,7 +7,7 @@ expression: "format!(\"{}\\n{}\",\nlines_to_single_string(&app.transcript_cells.
• Queued follow-up inputs
↳ queued before disconnect
⌥+↑ edit last queued message
shift+← edit last queued message
› keep this draft!
@@ -67,7 +67,6 @@ pub(crate) struct AsyncQuestions {
pub(crate) delivery_enabled: bool,
pub(crate) submission: Option<QuestionSubmission>,
visible_options: std::cell::Cell<(usize, usize)>,
pub(crate) next_hint: Option<crate::key_hint::ShortcutHint>,
keymap: RuntimeKeymap,
// Ignore autorepeat from the number key that opened Other.
other_selector: Option<KeyCode>,
@@ -103,7 +102,6 @@ impl AsyncQuestions {
delivery_enabled: true,
submission: None,
visible_options: std::cell::Cell::new((0, 0)),
next_hint: None,
keymap,
other_selector: None,
composer,
@@ -209,7 +209,7 @@ impl AsyncQuestions {
None
};
if let Some(label) = next
&& let Some(key) = self.next_hint
&& let Some(key) = chat_hint("edit_queued_message")
{
tips.push(crate::footer_hint::shortcut(&key.display_label(), label));
}
@@ -16,4 +16,5 @@ expression: buffer_text(&buffer)
long draft
enter submit ctrl+] skip ⌥+↓ main prompt
enter submit ctrl+] skip
shift+→ main prompt
@@ -12,4 +12,4 @@ expression: buffer_text(&buffer)
reverse-i-search: e enter accept · esc
enter submit ctrl+] skip option 13/13
⌥+↓ prev question
shift+→ prev question
@@ -9,4 +9,5 @@ expression: buffer_text(&buffer)
› 1. Other
2. Other (write an answer)
enter submit ctrl+] skip ⌥+↓ prev question
enter submit ctrl+] skip
shift+→ prev question
@@ -14,7 +14,7 @@ Full height:
enter submit
ctrl+] skip
⌥+↓ main prompt
shift+→ main prompt
Clipped:
@@ -12,4 +12,4 @@ expression: buffer_text(&buffer)
rows
f9 submit ctrl+] skip option 1/13
⌥+↓ prev question
shift+→ prev question
@@ -11,4 +11,4 @@ expression: buffer_text(&buffer)
2. Other
enter submit ctrl+] skip
⌥+↓ main prompt
shift+→ main prompt
@@ -13,4 +13,4 @@ expression: buffer_text(&buffer)
rows
enter submit ctrl+] skip
⌥+↓ main prompt
shift+→ main prompt
@@ -10,4 +10,4 @@ expression: buffer_text(&clipped)
multiple rows
enter submit ctrl+] skip
option 2/3 ⌥+↓ main prompt
option 2/3 shift+→ main prompt
@@ -17,7 +17,6 @@ fn editor() -> AsyncQuestions {
/*disable_paste_burst*/ true,
RuntimeKeymap::defaults(),
);
editor.next_hint = Some(crate::key_hint::alt(KeyCode::Up).into());
editor.append(
"message",
&[
@@ -446,7 +445,7 @@ fn existing_cross_context_keymaps_load_without_misleading_submit_hints() {
let mut editor = editor();
editor.navigate(/*forward*/ true);
editor.set_keymap(&keymap);
insta::allow_duplicates! { insta::assert_snapshot!(editor.footer_lines(/*width*/ 100, /*option_tip*/ None)[0].to_string(), @"ctrl+] skip ⌥+↓ prev question"); }
insta::allow_duplicates! { insta::assert_snapshot!(editor.footer_lines(/*width*/ 100, /*option_tip*/ None)[0].to_string(), @"ctrl+] skip shift+→ prev question"); }
editor.handle_key_event(KeyEvent::from(KeyCode::F(12)));
assert!(editor.submission.is_none());
}
+3 -13
View File
@@ -510,6 +510,9 @@ impl BottomPane {
if let Some(questions) = &mut self.questions {
questions.set_keymap(keymap);
}
// Show the first shortcut from the same keymap ChatWidget uses to handle queued edits.
self.pending_input_preview
.set_edit_binding(keymap.primary_hint(KeymapContext::Chat, "edit_queued_message"));
let interrupt_binding = keymap.primary_hint(KeymapContext::Chat, "interrupt_turn");
self.pending_input_preview
.set_interrupt_binding(interrupt_binding);
@@ -605,19 +608,6 @@ impl BottomPane {
self.request_redraw();
}
/// Update the key hint shown next to queued messages so it matches the
/// binding that `ChatWidget` actually listens for.
pub(crate) fn set_queued_message_edit_binding(
&mut self,
binding: Option<crate::key_hint::ShortcutHint>,
) {
self.pending_input_preview.set_edit_binding(binding);
if let Some(questions) = &mut self.questions {
questions.next_hint = binding;
}
self.request_redraw();
}
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
self.composer.set_vim_enabled(enabled);
if let Some(questions) = &mut self.questions {
@@ -17,15 +17,13 @@ use crate::wrapping::adaptive_wrap_lines;
/// steers explain that they will be submitted after the next tool/result
/// boundary unless the user invokes the interrupt binding to send them
/// immediately. The edit hint at the bottom only appears when there are actual
/// queued user inputs to pop back into the composer. Because some terminals
/// intercept certain modifier-key combinations, the displayed binding is
/// queued user inputs to pop back into the composer. The displayed binding is
/// configurable via [`set_edit_binding`](Self::set_edit_binding).
pub(crate) struct PendingInputPreview {
pub pending_steers: Vec<String>,
pub rejected_steers: Vec<String>,
pub queued_messages: Vec<String>,
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
/// be overridden for terminals where that chord is unavailable.
/// Key combination rendered in the hint line. Defaults to Shift+Left.
pub(super) edit_binding: Option<key_hint::ShortcutHint>,
/// Key combination rendered for immediately interrupting and sending steers.
interrupt_binding: Option<key_hint::ShortcutHint>,
@@ -44,7 +42,7 @@ impl PendingInputPreview {
pending_steers: Vec::new(),
rejected_steers: Vec::new(),
queued_messages: Vec::new(),
edit_binding: Some(key_hint::alt(KeyCode::Up).into()),
edit_binding: Some(key_hint::shift(KeyCode::Left).into()),
interrupt_binding: Some(key_hint::plain(KeyCode::Esc).into()),
}
}
@@ -30,7 +30,6 @@ impl BottomPane {
self.keymap.clone(),
);
questions.set_vim_enabled(self.composer.is_vim_enabled());
questions.next_hint = self.pending_input_preview.edit_binding;
Box::new(questions)
})
}
@@ -10,7 +10,7 @@ Buffer {
" a message ",
" with many ",
" … ",
" ⌥+↑ edit last queued message ",
" shift+← edit last queued message ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
@@ -25,7 +25,7 @@ Buffer {
x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 5, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 32, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 11, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -10,7 +10,7 @@ Buffer {
" ↳ This is another message ",
" ↳ This is a third message ",
" ↳ This is a fourth message ",
" ⌥+↑ edit last queued message ",
" shift+← edit last queued message ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
@@ -28,7 +28,7 @@ Buffer {
x: 4, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 28, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 32, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 11, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -7,7 +7,7 @@ Buffer {
content: [
"• Queued follow-up inputs ",
" ↳ Hello, world! ",
" ⌥+↑ edit last queued message ",
" shift+← edit last queued message ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
@@ -16,7 +16,7 @@ Buffer {
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: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 32, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 11, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -15,7 +15,7 @@ Buffer {
" ",
"• Queued follow-up inputs ",
" ↳ Queued follow-up question ",
" ⌥+↑ edit last queued message ",
" shift+← edit last queued message ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
@@ -38,7 +38,7 @@ Buffer {
x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 29, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 32, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 11, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -8,7 +8,7 @@ Buffer {
"• Queued follow-up inputs ",
" ↳ Hello, world! ",
" ↳ This is another message ",
" ⌥+↑ edit last queued message ",
" shift+← edit last queued message ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
@@ -20,7 +20,7 @@ Buffer {
x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 32, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 11, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -9,7 +9,7 @@ Buffer {
" ↳ This is a longer message that should",
" be wrapped ",
" ↳ This is another message ",
" ⌥+↑ edit last queued message ",
" shift+← edit last queued message ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
@@ -23,7 +23,7 @@ Buffer {
x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
x: 27, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 7, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 32, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 11, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -3,4 +3,4 @@ source: tui/src/bottom_pane/questions_tests.rs
expression: "pane.question_summary(now).unwrap().iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\")"
---
? 2 questions · 15s
⌥+↑ to answer
shift+← to answer
@@ -4,7 +4,7 @@ expression: "render_snapshot(&pane, area)"
---
• Queued follow-up inputs
↳ Queued follow-up question
⌥+↑ edit last queued message
shift+← edit last queued message
› Ask Codex to do anything
@@ -6,7 +6,7 @@ expression: "render_snapshot(&pane, area)"
• Queued follow-up inputs
↳ Queued follow-up question
⌥+↑ edit last queued message
shift+← edit last queued message
› Ask Codex to do anything
@@ -8,7 +8,7 @@ expression: "render_snapshot(&pane, area)"
• Queued follow-up inputs
↳ Queued follow-up question
⌥+↑ edit last queued message
shift+← edit last queued message
› Ask Codex to do anything
-64
View File
@@ -150,10 +150,6 @@ use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus;
use codex_protocol::request_permissions::RequestPermissionsEvent;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use codex_terminal_detection::Multiplexer;
use codex_terminal_detection::TerminalInfo;
use codex_terminal_detection::TerminalName;
use codex_terminal_detection::terminal_info;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use crossterm::event::KeyCode;
@@ -188,62 +184,6 @@ const TUI_STUB_MESSAGE: &str = "Not available in TUI yet.";
const PARENT_OWNED_INPUT_MESSAGE: &str =
"This sub-agent is controlled by its parent. Direct input is disabled.";
/// Choose the keybinding used to edit the most-recently queued message.
///
/// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently
/// 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_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)
}
TerminalName::Ghostty
| TerminalName::Iterm2
| TerminalName::WezTerm
| TerminalName::Kitty
| TerminalName::Alacritty
| TerminalName::Konsole
| TerminalName::GnomeTerminal
| TerminalName::Vte
| TerminalName::WindowsTerminal
| TerminalName::Dumb
| TerminalName::Unknown => key_hint::alt(KeyCode::Up),
}
}
fn queued_message_edit_hint_binding(
keymap: &RuntimeKeymap,
terminal_info: TerminalInfo,
) -> Option<crate::key_hint::ShortcutHint> {
let configured = keymap.primary_hint(crate::keymap::KeymapContext::Chat, "edit_queued_message");
if matches!(
configured,
Some(crate::key_hint::ShortcutHint::Chord { .. })
) {
return configured;
}
let terminal_binding = queued_message_edit_binding_for_terminal(terminal_info);
keymap
.chat
.edit_queued_message
.contains(&terminal_binding)
.then_some(crate::key_hint::ShortcutHint::Single(terminal_binding))
.or(configured)
}
fn normalize_thread_name(name: &str) -> Option<String> {
let trimmed = name.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
@@ -744,10 +684,6 @@ pub(crate) struct ChatWidget {
/// Main chat-surface bindings resolved from `tui.keymap.chat`.
chat_keymap: ChatKeymap,
permission_shortcut_pending: bool,
/// Keybinding to show for popping the most-recently queued message back
/// into the composer. This may differ from the first configured binding
/// when the default set includes a terminal-specific fallback.
queued_message_edit_hint_binding: Option<crate::key_hint::ShortcutHint>,
// Pending notification to show when unfocused on next Draw
pending_notification: Option<Notification>,
/// When `Some`, the user has pressed a quit shortcut and the second press
@@ -72,7 +72,6 @@ impl ChatWidget {
&header_model,
&model_catalog.try_list_models().unwrap_or_default(),
);
let current_terminal_info = terminal_info();
let runtime_keymap = RuntimeKeymap::from_config(&local_settings.tui.keymap).ok();
let default_keymap = RuntimeKeymap::defaults();
let copy_last_response_binding = runtime_keymap
@@ -83,10 +82,6 @@ impl ChatWidget {
.as_ref()
.map(|keymap| keymap.chat.clone())
.unwrap_or_else(|| default_keymap.chat.clone());
let queued_message_edit_hint_binding = queued_message_edit_hint_binding(
runtime_keymap.as_ref().unwrap_or(&default_keymap),
current_terminal_info,
);
let pet_http_client = codex_http_client::RouteAwareClientPool::new(
config.http_client_factory(),
codex_http_client::ClientRouteClass::Other,
@@ -240,7 +235,6 @@ impl ChatWidget {
safety_buffering_source: UserMessageSource::Prompt,
chat_keymap,
permission_shortcut_pending: false,
queued_message_edit_hint_binding,
show_welcome_banner: is_first_run,
startup_tooltip_override,
suppress_session_configured_redraw: false,
@@ -309,9 +303,6 @@ impl ChatWidget {
.bottom_pane
.set_voice_command_enabled(/*enabled*/ false);
widget.sync_mentions_v2_enabled();
widget
.bottom_pane
.set_queued_message_edit_binding(widget.queued_message_edit_hint_binding);
widget.update_collaboration_mode_indicator();
widget
@@ -12,10 +12,8 @@
//! appear to accept a remap while some handlers still respond to the old keys.
use codex_config::types::TuiKeymap;
use codex_terminal_detection::terminal_info;
use super::ChatWidget;
use super::queued_message_edit_hint_binding;
use crate::app_event::KeymapCaptureMode;
use crate::app_event::KeymapEditIntent;
use crate::keymap::RuntimeKeymap;
@@ -173,10 +171,6 @@ impl ChatWidget {
self.local_settings.tui.keymap = keymap_config;
self.copy_last_response_binding = runtime_keymap.app.copy.clone();
self.chat_keymap = runtime_keymap.chat.clone();
self.queued_message_edit_hint_binding =
queued_message_edit_hint_binding(runtime_keymap, terminal_info());
self.bottom_pane
.set_queued_message_edit_binding(self.queued_message_edit_hint_binding);
self.bottom_pane.set_keymap_bindings(runtime_keymap);
self.update_realtime_footer();
self.request_redraw();
@@ -6,7 +6,7 @@ expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 80))"
• Queued follow-up inputs
↳ [Pasted Content 1006 chars]
⌥+↑ edit last queued message
shift+← edit last queued message
› Ask Codex to do anything
@@ -6,7 +6,7 @@ expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 80)).replace('
• Queued follow-up inputs
↳ x
⌥+↑ edit last queued message
shift+← edit last queued message
› describe
@@ -4,7 +4,7 @@ expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 80))"
---
• Queued follow-up inputs
↳ Preparing images: describe
⌥+↑ edit last queued message
shift+← edit last queued message
› x
-3
View File
@@ -159,9 +159,6 @@ pub(super) use codex_protocol::plan_tool::StepStatus;
pub(super) use codex_protocol::plan_tool::UpdatePlanArgs;
pub(super) use codex_protocol::request_permissions::RequestPermissionProfile;
pub(super) use codex_protocol::user_input::TextElement;
pub(super) use codex_terminal_detection::Multiplexer;
pub(super) use codex_terminal_detection::TerminalInfo;
pub(super) use codex_terminal_detection::TerminalName;
pub(super) use codex_utils_absolute_path::AbsolutePathBuf;
pub(super) use codex_utils_approval_presets::builtin_approval_presets;
pub(super) use codex_utils_path_uri::LegacyAppPathString;
@@ -1924,54 +1924,42 @@ async fn restore_thread_input_state_applies_running_state_policy() {
}
#[tokio::test]
async fn alt_up_edits_most_recent_queued_message() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.chat_keymap.edit_queued_message = vec![crate::key_hint::alt(KeyCode::Up)];
chat.queued_message_edit_hint_binding = Some(crate::key_hint::alt(KeyCode::Up).into());
chat.bottom_pane
.set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
async fn default_shortcuts_edit_most_recent_queued_message() {
for key in [
KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT),
KeyEvent::new(KeyCode::Up, KeyModifiers::ALT),
] {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane.set_task_running(/*running*/ true);
for message in ["first queued", "second queued"] {
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from(message.to_string()).into());
}
chat.refresh_pending_input_preview();
// Simulate a running task so messages would normally be queued.
chat.bottom_pane.set_task_running(/*running*/ true);
chat.handle_key_event(key);
// Seed two queued messages.
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("first queued".to_string()).into());
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
// Press Alt+Up to edit the most recent (last) queued message.
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
// Composer should now contain the last queued message.
assert_eq!(
chat.bottom_pane.composer_text(),
"second queued".to_string()
);
// And the queue should now contain only the remaining (older) item.
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_eq!(
chat.input_queue.queued_user_messages.front().unwrap().text,
"first queued"
);
assert_eq!(chat.bottom_pane.composer_text(), "second queued");
assert_eq!(chat.queued_user_message_texts(), vec!["first queued"]);
}
}
#[tokio::test]
async fn unbound_queued_message_edit_does_not_fall_back_to_alt_up() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.chat_keymap.edit_queued_message = Vec::new();
chat.queued_message_edit_hint_binding = None;
chat.bottom_pane
.set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
let mut config = codex_config::types::TuiKeymap::default();
config.chat.edit_queued_message = Some(codex_config::types::KeybindingsSpec::Many(Vec::new()));
let keymap = RuntimeKeymap::from_config(&config).expect("valid unbound queued edit");
chat.apply_keymap_update(config, &keymap);
chat.bottom_pane.set_task_running(/*running*/ true);
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("queued".to_string()).into());
chat.refresh_pending_input_preview();
assert!(!render_bottom_popup(&chat, /*width*/ 100).contains("edit last queued message"));
chat.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT));
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
assert!(chat.bottom_pane.composer_text().is_empty());
@@ -1979,141 +1967,31 @@ async fn unbound_queued_message_edit_does_not_fall_back_to_alt_up() {
}
#[tokio::test]
async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() {
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(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(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_hint_displays_configured_chords() {
async fn queued_message_edit_hint_displays_configured_chords() {
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
let terminal_info = || TerminalInfo {
name: TerminalName::Iterm2,
term_program: None,
version: None,
term: None,
multiplexer: None,
};
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut config = TuiKeymap::default();
config.chat.edit_queued_message = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x up".to_string(),
)));
let keymap = RuntimeKeymap::from_config(&config).expect("valid queued edit chord");
chat.apply_keymap_update(config, &keymap);
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("queued".to_string()).into());
chat.refresh_pending_input_preview();
assert_eq!(
queued_message_edit_hint_binding(&keymap, terminal_info()),
Some(crate::key_hint::ShortcutHint::Chord {
prefix: crate::key_hint::ctrl(KeyCode::Char('x')),
completion: crate::key_hint::plain(KeyCode::Up),
})
);
let default_keymap = RuntimeKeymap::defaults();
assert_eq!(
queued_message_edit_hint_binding(&default_keymap, terminal_info()),
Some(crate::key_hint::ShortcutHint::Single(crate::key_hint::alt(
KeyCode::Up,
)))
);
}
#[test]
fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() {
assert_eq!(
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(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(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(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)
);
let hint = crate::key_hint::ShortcutHint::Chord {
prefix: crate::key_hint::ctrl(KeyCode::Char('x')),
completion: crate::key_hint::plain(KeyCode::Up),
};
assert!(render_bottom_popup(&chat, /*width*/ 100).contains(&format!(
"{} edit last queued message",
hint.display_label()
)));
}
/// Pressing Up to recall the most recent history entry and immediately queuing
@@ -2,7 +2,6 @@ use super::*;
use codex_app_server_protocol::ImageGenerationItem;
use codex_app_server_protocol::PluginAvailability;
use codex_utils_absolute_path::test_support::PathExt;
use pretty_assertions::assert_eq;
pub(super) async fn test_config() -> Config {
// Start from the built-in defaults so tests do not inherit host/system config.
@@ -1311,43 +1310,6 @@ pub(super) fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset
.unwrap_or_else(|| panic!("{model} preset not found"))
}
pub(super) async fn assert_shift_left_edits_most_recent_queued_message_for_terminal(
terminal_info: TerminalInfo,
) {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.queued_message_edit_hint_binding =
Some(queued_message_edit_binding_for_terminal(terminal_info).into());
chat.bottom_pane
.set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
// Simulate a running task so messages would normally be queued.
chat.bottom_pane.set_task_running(/*running*/ true);
// Seed two queued messages.
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("first queued".to_string()).into());
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
// Press Shift+Left to edit the most recent (last) queued message.
chat.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT));
// Composer should now contain the last queued message.
assert_eq!(
chat.bottom_pane.composer_text(),
"second queued".to_string()
);
// And the queue should now contain only the remaining (older) item.
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_eq!(
chat.input_queue.queued_user_messages.front().unwrap().text,
"first queued"
);
}
pub(super) fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
let height = chat.desired_height(width);
let area = Rect::new(0, 0, width, height);
@@ -865,21 +865,93 @@ async fn questions_and_queued_messages_share_the_resolved_shortcut() {
.queued_user_messages
.push_back(UserMessage::from("queued".to_string()).into());
chat.refresh_pending_input_preview();
for binding in [key_hint::shift(KeyCode::Left), key_hint::alt(KeyCode::Up)] {
chat.bottom_pane
.set_queued_message_edit_binding(Some(binding.into()));
let hint = binding.display_label();
assert!(render_bottom_popup(&chat, /*width*/ 100).contains(&hint));
chat.add_async_questions(&hint, &questions());
assert!(render_bottom_popup(&chat, /*width*/ 100).contains(&format!("{hint} to answer")));
let (key, modifiers) = binding.parts();
chat.handle_key_event(KeyEvent::new(key, modifiers));
insta::assert_snapshot!(
format!("question_queue_hint_{}", key),
render_bottom_popup(&chat, /*width*/ 100)
);
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
let forward_hint = key_hint::shift(KeyCode::Left).display_label();
let backward_hint = key_hint::shift(KeyCode::Right).display_label();
assert!(
render_bottom_popup(&chat, /*width*/ 100)
.contains(&format!("{forward_hint} edit last queued message"))
);
chat.add_async_questions("message", &questions());
assert!(
render_bottom_popup(&chat, /*width*/ 100).contains(&format!("{forward_hint} to answer"))
);
for (forward, backward) in [
(
KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT),
KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT),
),
(
KeyEvent::new(KeyCode::Up, KeyModifiers::ALT),
KeyEvent::new(KeyCode::Down, KeyModifiers::ALT),
),
] {
chat.handle_key_event(forward);
let rendered = render_bottom_popup(&chat, /*width*/ 100);
assert!(rendered.contains(&format!("{backward_hint} main prompt")));
assert!(rendered.contains(&format!("{forward_hint} next question")));
if forward.code == KeyCode::Left {
insta::assert_snapshot!(
"question_queue_hint_Left",
render_bottom_popup(&chat, /*width*/ 100)
);
}
chat.handle_key_event(forward);
let rendered = render_bottom_popup(&chat, /*width*/ 100);
assert!(rendered.contains(&format!("{backward_hint} prev question")));
assert!(rendered.contains(&format!("{forward_hint} queued messages")));
chat.handle_key_event(backward);
assert!(render_bottom_popup(&chat, /*width*/ 100).contains("Which way?"));
chat.handle_key_event(backward);
assert!(!chat.bottom_pane.questions.as_ref().unwrap().expanded);
}
let config = toml::from_str(
r#"
[chat]
edit_queued_message = ["alt-up", "shift-left"]
prompt_stack_back = ["alt-down", "shift-right"]
"#,
)
.unwrap();
let keymap = RuntimeKeymap::from_config(&config).expect("valid alternate shortcuts");
chat.apply_keymap_update(config, &keymap);
chat.add_async_questions("remapped", &questions());
let forward_hint = key_hint::alt(KeyCode::Up).display_label();
assert!(
render_bottom_popup(&chat, /*width*/ 100).contains(&format!("{forward_hint} to answer"))
);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
insta::assert_snapshot!(
"question_queue_hint_Up",
render_bottom_popup(&chat, /*width*/ 100)
);
let config = toml::from_str(
r#"
[chat]
edit_queued_message = ["ctrl-x up", "shift-left"]
prompt_stack_back = ["ctrl-x down", "shift-right"]
"#,
)
.unwrap();
let keymap = RuntimeKeymap::from_config(&config).expect("valid configured chords");
chat.apply_keymap_update(config, &keymap);
let hint = |completion| {
crate::key_hint::ShortcutHint::Chord {
prefix: key_hint::ctrl(KeyCode::Char('x')),
completion: key_hint::plain(completion),
}
.display_label()
};
let rendered = render_bottom_popup(&chat, /*width*/ 100);
assert!(rendered.contains(&format!("{} main prompt", hint(KeyCode::Down))));
assert!(rendered.contains(&format!("{} next question", hint(KeyCode::Up))));
chat.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT));
assert!(
render_bottom_popup(&chat, /*width*/ 100)
.contains(&format!("{} to answer", hint(KeyCode::Up)))
);
}
#[tokio::test]
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
assertion_line: 937
expression: "render_bottom_popup(&chat, 80)"
---
• Queued follow-up inputs
@@ -9,4 +8,4 @@ expression: "render_bottom_popup(&chat, 80)"
My draft for the second question
enter submit ctrl+] skip ⌥+↓ main prompt
enter submit ctrl+] skip shift+→ main prompt
@@ -10,4 +10,4 @@ expression: "render_bottom_popup(&chat, 80)"
Type your answer
enter submit ctrl+] skip ⌥+↓ main prompt ⌥+↑ next question
enter submit ctrl+] skip shift+→ main prompt shift+← next question
@@ -12,4 +12,4 @@ expression: "render_bottom_popup(&chat, 80)"
› 1. Next
2. Other
enter submit ctrl+] skip ⌥+↓ prev question ⌥+↑ queued messages
enter submit ctrl+] skip shift+→ prev question shift+← queued messages
@@ -14,4 +14,4 @@ expression: "render_bottom_popup(&chat, 80)"
xxxxxxxxxxxxxxxx but do not deploy
2. Other
enter submit ctrl+] skip ⌥+↓ main prompt
enter submit ctrl+] skip shift+→ main prompt
@@ -8,4 +8,4 @@ expression: "render_bottom_popup(&chat, 80)"
Type your answer
enter submit ctrl+] skip ⌥+↓ main prompt
enter submit ctrl+] skip shift+→ main prompt
@@ -11,4 +11,4 @@ expression: "render_bottom_popup(&chat, 100)"
Type your answer
enter submit ctrl+] skip ⌥+↓ main prompt shift+← next question
enter submit ctrl+] skip shift+→ main prompt shift+← next question
@@ -11,7 +11,7 @@ NORMAL
› 2. alpha beta
alpha gamma
Vim: Normal
enter submit ctrl+] skip ⌥+↓ main prompt
enter submit ctrl+] skip shift+→ main prompt
SEARCH
• Queued follow-up inputs
@@ -23,4 +23,4 @@ SEARCH
alpha gamma
/gamma
Vim: Normal
enter submit ctrl+] skip ⌥+↓ main prompt
enter submit ctrl+] skip shift+→ main prompt
@@ -10,7 +10,7 @@ NORMAL
alpha beta
alpha gamma
Vim: Normal
enter submit ctrl+] skip ⌥+↓ main prompt
enter submit ctrl+] skip shift+→ main prompt
SEARCH
• Queued follow-up inputs
@@ -21,4 +21,4 @@ SEARCH
alpha gamma
/gamma
Vim: Normal
enter submit ctrl+] skip ⌥+↓ main prompt
enter submit ctrl+] skip shift+→ main prompt
@@ -11,4 +11,4 @@ OPEN
ONLY QUESTIONS
• Queued follow-up inputs
? 2 questions
⌥+↑ to answer
shift+← to answer
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests/questions_tests.rs
assertion_line: 51
expression: "render_bottom_popup(&chat, 80)"
---
• Queued follow-up inputs
@@ -12,4 +11,4 @@ expression: "render_bottom_popup(&chat, 80)"
Type your answer
enter submit ctrl+] skip ⌥+↓ main prompt ⌥+↑ queued messages
enter submit ctrl+] skip shift+→ main prompt shift+← queued messages
@@ -10,4 +10,4 @@ expression: rendered
A typed answer
enter submit ctrl+] skip ⌥+↓ main prompt
enter submit ctrl+] skip shift+→ main prompt
+4 -4
View File
@@ -142,7 +142,7 @@ pub(crate) struct ChatKeymap {
pub(crate) previous_permission_mode: Vec<KeyBinding>,
/// Switch to the next available permission mode.
pub(crate) next_permission_mode: Vec<KeyBinding>,
/// Move up through async questions, then edit the most recently queued message.
/// Move forward through async questions, then edit the most recently queued message.
pub(crate) edit_queued_message: Vec<KeyBinding>,
/// Move back through async questions toward the composer.
pub(crate) prompt_stack_back: Vec<KeyBinding>,
@@ -1672,8 +1672,8 @@ impl RuntimeKeymap {
],
previous_permission_mode: default_bindings![],
next_permission_mode: default_bindings![],
edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)],
prompt_stack_back: default_bindings![alt(KeyCode::Down), shift(KeyCode::Right)],
edit_queued_message: default_bindings![shift(KeyCode::Left), alt(KeyCode::Up)],
prompt_stack_back: default_bindings![shift(KeyCode::Right), alt(KeyCode::Down)],
skip_question: default_bindings![ctrl(KeyCode::Char(']'))],
},
composer: ComposerKeymap {
@@ -2944,7 +2944,7 @@ mod tests {
);
assert_eq!(
runtime.chat.edit_queued_message,
vec![key_hint::alt(KeyCode::Up), key_hint::shift(KeyCode::Left)]
vec![key_hint::shift(KeyCode::Left), key_hint::alt(KeyCode::Up)]
);
assert_eq!(
runtime.composer.history_search_previous,
+1 -1
View File
@@ -102,7 +102,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."),
action("chat", "Chat", "previous_permission_mode", "Switch to the previous available permission mode."),
action("chat", "Chat", "next_permission_mode", "Switch to the next available permission mode."),
action("chat", "Chat", "edit_queued_message", "Move up through questions, then edit the last queued message."),
action("chat", "Chat", "edit_queued_message", "Move forward through questions, then edit the last queued message."),
action("chat", "Chat", "prompt_stack_back", "Move back through questions toward the composer."),
action("chat", "Chat", "skip_question", "Skip the focused question."),
action("chat", "Chat", "toggle_voice", "Start or stop a voice conversation."),
@@ -26,8 +26,8 @@ expression: "render_picker(params, 120)"
Chat Increase Reasoning Effort alt-., shift-up
Chat - Previous Permission Mode unbound
Chat - Next Permission Mode unbound
Chat Edit Queued Message alt-up, shift-left
Chat Prompt Stack Back alt-down, shift-right
Chat Edit Queued Message shift-left, alt-up
Chat Prompt Stack Back shift-right, alt-down
Chat Skip Question ctrl-]
Chat Toggle Voice f8
Chat Toggle Voice Mute ctrl-x
@@ -27,8 +27,8 @@ expression: "render_picker(params, 120)"
Chat Increase Reasoning Effort alt-., shift-up
Chat - Previous Permission Mode unbound
Chat - Next Permission Mode unbound
Chat Edit Queued Message alt-up, shift-left
Chat Prompt Stack Back alt-down, shift-right
Chat Edit Queued Message shift-left, alt-up
Chat Prompt Stack Back shift-right, alt-down
Chat Skip Question ctrl-]
Chat Toggle Voice f8
Chat Toggle Voice Mute ctrl-x
@@ -26,8 +26,8 @@ expression: "render_picker(params, 120)"
Chat Increase Reasoning Effort alt-., shift-up
Chat - Previous Permission Mode unbound
Chat - Next Permission Mode unbound
Chat Edit Queued Message alt-up, shift-left
Chat Prompt Stack Back alt-down, shift-right
Chat Edit Queued Message shift-left, alt-up
Chat Prompt Stack Back shift-right, alt-down
Chat Skip Question ctrl-]
Chat Toggle Voice f8
Chat - Toggle Voice Mute unbound
@@ -26,8 +26,8 @@ expression: "render_picker(params, 78)"
Chat Increase Reasoning Effort alt-., shift-up
Chat - Previous Permission Mode unbound
Chat - Next Permission Mode unbound
Chat Edit Queued Message alt-up, shift-left
Chat Prompt Stack Back alt-down, shift-right
Chat Edit Queued Message shift-left, alt-up
Chat Prompt Stack Back shift-right, alt-down
Chat Skip Question ctrl-]
Chat Toggle Voice f8
Chat Toggle Voice Mute ctrl-x
@@ -26,8 +26,8 @@ expression: "render_picker(params, 120)"
Chat Increase Reasoning Effort alt-., shift-up
Chat - Previous Permission Mode unbound
Chat - Next Permission Mode unbound
Chat Edit Queued Message alt-up, shift-left
Chat Prompt Stack Back alt-down, shift-right
Chat Edit Queued Message shift-left, alt-up
Chat Prompt Stack Back shift-right, alt-down
Chat Skip Question ctrl-]
Chat Toggle Voice f8
Chat Toggle Voice Mute ctrl-x
@@ -5,14 +5,14 @@ expression: descriptions
Edit Shortcut
Edit Queued Message · Chat
Current alt-up, shift-left · Default keymap
Current shift-left, alt-up · Default keymap
Config `tui.keymap.chat.edit_queued_message`
Move up through questions, then edit the last queued message.
Move forward through questions, then edit the last queued message.
Edit Shortcut
Prompt Stack Back · Chat
Current alt-down, shift-right · Default keymap
Current shift-right, alt-down · Default keymap
Config `tui.keymap.chat.prompt_stack_back`
Move back through questions toward the composer.
@@ -26,8 +26,8 @@ expression: "render_picker(params, 120)"
Chat Increase Reasoning Effort alt-., shift-up
Chat - Previous Permission Mode unbound
Chat - Next Permission Mode unbound
Chat Edit Queued Message alt-up, shift-left
Chat Prompt Stack Back alt-down, shift-right
Chat Edit Queued Message shift-left, alt-up
Chat Prompt Stack Back shift-right, alt-down
› Chat Skip Question ctrl-]
Chat Toggle Voice f8
Chat Toggle Voice Mute ctrl-x