Support two-stroke TUI key chords (#36511)

## What changed

- Accept two-stroke bindings such as `ctrl-x ctrl-s` in TUI keymap configuration while preserving arrays as alternative bindings.
- Route chords through the active TUI context, show pending and configured chord hints, and cancel pending chords on `esc`, context changes, or a one-second timeout.
- Extend `/keymap` to capture, add, replace, and display key chords.
- Reject chords that conflict with active single-key bindings, overlapping-context chords, or reserved terminal shortcuts.

## Testing

- Add coverage for chord parsing, validation, matching, timeout and cancellation behavior, context dispatch, key capture, and rendered shortcut hints.

GitOrigin-RevId: dfdede03f518d4936ad8621ebf5537026f2b282e
This commit is contained in:
Felipe Coury authored and copyberry committed 2026-08-01 17:43:03 +00:00
1 parent a1dd74b535
commit 1e85ca099e
61 files changed
+3503 -660

No files matched your search

+40 -16
View File
@@ -29,7 +29,7 @@ use crate::app::app_server_requests::ResolvedAppServerRequest;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::render::Insets;
use crate::render::RectExt as _;
@@ -669,23 +669,29 @@ impl AppLinkView {
}
fn hint_line(&self) -> Line<'static> {
Line::from(vec![
"Use ".into(),
key_hint::plain(KeyCode::Tab).into(),
" / ".into(),
key_hint::plain(KeyCode::Up).into(),
" ".into(),
key_hint::plain(KeyCode::Down).into(),
" to move, ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to select, ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
])
let mut spans = vec!["Use ".into(), key_hint::plain(KeyCode::Tab).into()];
if let Some(move_up) = self.list_keymap.primary_hint(ListAction::MoveUp) {
spans.extend([" / ".into(), move_up.into()]);
}
if let Some(move_down) = self.list_keymap.primary_hint(ListAction::MoveDown) {
spans.extend([" ".into(), move_down.into()]);
}
spans.push(" to move".into());
if let Some(accept) = self.list_keymap.primary_hint(ListAction::Accept) {
spans.extend([", ".into(), accept.into(), " to select".into()]);
}
if let Some(cancel) = self.list_keymap.primary_hint(ListAction::Cancel) {
spans.extend([", ".into(), cancel.into(), " to close".into()]);
}
Line::from(spans)
}
}
impl BottomPaneView for AppLinkView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
@@ -693,6 +699,9 @@ impl BottomPaneView for AppLinkView {
} => {
self.on_ctrl_c();
}
_ if self.list_keymap.action_for(key_event) == Some(ListAction::Cancel) => {
self.on_ctrl_c();
}
KeyEvent {
code: KeyCode::Up, ..
}
@@ -705,7 +714,13 @@ impl BottomPaneView for AppLinkView {
modifiers: KeyModifiers::NONE,
..
} => self.move_selection_prev(),
_ if self.list_keymap.move_left.is_pressed(key_event) => self.move_selection_prev(),
_ if matches!(
self.list_keymap.action_for(key_event),
Some(ListAction::MoveUp | ListAction::MoveLeft)
) =>
{
self.move_selection_prev()
}
KeyEvent {
code: KeyCode::Down,
..
@@ -718,7 +733,16 @@ impl BottomPaneView for AppLinkView {
modifiers: KeyModifiers::NONE,
..
} => self.move_selection_next(),
_ if self.list_keymap.move_right.is_pressed(key_event) => self.move_selection_next(),
_ if matches!(
self.list_keymap.action_for(key_event),
Some(ListAction::MoveDown | ListAction::MoveRight)
) =>
{
self.move_selection_next()
}
_ if self.list_keymap.action_for(key_event) == Some(ListAction::Accept) => {
self.activate_selected_action();
}
KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::NONE,
@@ -33,8 +33,8 @@ use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ApprovalKeymap;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::primary_binding;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
@@ -291,7 +291,7 @@ impl ApprovalOverlay {
.iter()
.map(|opt| SelectionItem {
name: opt.label.clone(),
display_shortcut: opt.shortcuts.first().copied(),
display_shortcut: approval_keymap.hint_for_bindings(&opt.shortcuts),
dismiss_on_select: false,
..Default::default()
})
@@ -563,6 +563,11 @@ impl ApprovalOverlay {
}
impl BottomPaneView for ApprovalOverlay {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::Approval)
.with(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.try_handle_shortcut(&key_event) {
return;
@@ -619,14 +624,15 @@ fn approval_footer_hint(
list_keymap: &ListKeymap,
) -> Line<'static> {
let mut spans = accept_cancel_hint_line(
primary_binding(&list_keymap.accept),
list_keymap.primary_hint(ListAction::Accept),
"to confirm",
primary_binding(&list_keymap.cancel),
list_keymap.primary_hint(ListAction::Cancel),
"to cancel",
)
.spans;
if request.thread_label().is_some()
&& let Some(open_thread) = primary_binding(&approval_keymap.open_thread)
&& let Some(open_thread) =
approval_keymap.primary_hint("open_thread", &approval_keymap.open_thread)
{
if !spans.is_empty() {
spans.push(" or ".into());
@@ -1106,11 +1112,15 @@ fn elicitation_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::keymap::RuntimeKeymap;
use codex_app_server_protocol::AdditionalFileSystemPermissions;
use codex_app_server_protocol::AdditionalNetworkPermissions;
use codex_app_server_protocol::ExecPolicyAmendment;
use codex_app_server_protocol::NetworkApprovalProtocol;
use codex_app_server_protocol::NetworkPolicyAmendment;
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::NetworkPermissions;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -1242,6 +1252,58 @@ mod tests {
})
}
#[test]
fn approval_shortcuts_display_chords_in_configured_order() {
let chord = crate::key_hint::ShortcutHint::Chord {
prefix: key_hint::ctrl(KeyCode::Char('x')),
completion: key_hint::plain(KeyCode::Char('y')),
};
let single = crate::key_hint::ShortcutHint::Single(key_hint::plain(KeyCode::Char('y')));
let binding = |spec: &str| KeybindingSpec(spec.to_string());
for (specs, expected_shortcut) in [
(KeybindingsSpec::One(binding("ctrl-x y")), chord),
(
KeybindingsSpec::Many(vec![binding("y"), binding("ctrl-x y")]),
single,
),
(
KeybindingsSpec::Many(vec![binding("ctrl-x y"), binding("y")]),
chord,
),
] {
let mut config = TuiKeymap::default();
config.approval.approve = Some(specs);
let keymap = RuntimeKeymap::from_config(&config).expect("valid approval keymap");
let request = make_exec_request();
let (_, params) = ApprovalOverlay::build_options(
&request,
build_header(&request),
&Features::with_defaults(),
&keymap.approval,
&keymap.list,
);
let approval = params
.items
.iter()
.find(|item| item.name == "Yes, proceed")
.expect("approval selection");
assert_eq!(approval.display_shortcut, Some(expected_shortcut));
if matches!(
expected_shortcut,
crate::key_hint::ShortcutHint::Chord { .. }
) {
let dispatch_token = keymap.approval.approve.last().expect("chord token");
assert_eq!(
keymap
.approval
.hint_for_bindings(std::slice::from_ref(dispatch_token)),
Some(expected_shortcut)
);
}
}
}
#[test]
fn ctrl_c_aborts_and_clears_queue() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
@@ -1,6 +1,7 @@
use crate::app::app_server_requests::ResolvedAppServerRequest;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::McpServerElicitationFormRequest;
use crate::keymap::KeymapContextSet;
use crate::render::renderable::Renderable;
use codex_app_server_protocol::ToolRequestUserInputParams;
use crossterm::event::KeyEvent;
@@ -21,6 +22,11 @@ pub(crate) trait BottomPaneView: Renderable {
/// scheduled after this call.
fn handle_key_event(&mut self, _key_event: KeyEvent) {}
/// Return the keymap contexts whose handlers are active in this view.
fn keymap_contexts(&self) -> KeymapContextSet {
KeymapContextSet::default()
}
/// Return `true` if the view has finished and should be removed.
fn is_complete(&self) -> bool {
false
+98 -23
View File
@@ -171,6 +171,7 @@
//!
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
use crate::key_hint::has_ctrl_or_alt;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::ui_consts::FOOTER_INDENT_COLS;
@@ -248,9 +249,11 @@ use crate::bottom_pane::paste_burst::FlushResult;
use crate::history_cell::sanitize_user_text;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::EditorKeymap;
use crate::keymap::KeymapContext;
use crate::keymap::KeymapContextSet;
use crate::keymap::RuntimeKeymap;
use crate::keymap::VimNormalKeymap;
use crate::keymap::primary_binding;
use crate::keymap::user_bindings;
use crate::onboarding::mark_underlined_hyperlink;
use crate::render::Insets;
use crate::render::RectExt;
@@ -625,19 +628,24 @@ impl ChatComposer {
status_line_enabled: false,
side_conversation_context_label: None,
active_agent_label: None,
external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))),
show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))),
external_editor_key: default_keymap
.primary_hint(KeymapContext::Global, "open_external_editor"),
show_transcript_key: default_keymap
.primary_hint(KeymapContext::Global, "open_transcript"),
insert_newline_key: footer_insert_newline_key(
&default_keymap.editor.insert_newline,
use_shift_enter_hint,
),
queue_key: Some(key_hint::plain(KeyCode::Tab)),
toggle_shortcuts_key: Some(key_hint::plain(KeyCode::Char('?'))),
history_search_key: primary_binding(
&default_keymap.composer.history_search_previous,
),
reasoning_down_key: primary_binding(&default_keymap.chat.decrease_reasoning_effort),
reasoning_up_key: primary_binding(&default_keymap.chat.increase_reasoning_effort),
)
.map(ShortcutHint::from),
queue_key: default_keymap.primary_hint(KeymapContext::Composer, "queue"),
toggle_shortcuts_key: default_keymap
.primary_hint(KeymapContext::Composer, "toggle_shortcuts"),
history_search_key: default_keymap
.primary_hint(KeymapContext::Composer, "history_search_previous"),
reasoning_down_key: default_keymap
.primary_hint(KeymapContext::Chat, "decrease_reasoning_effort"),
reasoning_up_key: default_keymap
.primary_hint(KeymapContext::Chat, "increase_reasoning_effort"),
},
has_focus: has_input_focus,
frame_requester: None,
@@ -842,17 +850,39 @@ impl ChatComposer {
self.editor_keymap = keymap.editor.clone();
self.vim_normal_keymap = keymap.vim_normal.clone();
self.draft.textarea.set_keymap_bindings(keymap);
self.footer.external_editor_key = primary_binding(&keymap.app.open_external_editor);
self.footer.show_transcript_key = primary_binding(&keymap.app.open_transcript);
self.footer.insert_newline_key = footer_insert_newline_key(
&keymap.editor.insert_newline,
self.footer.use_shift_enter_hint,
);
self.footer.queue_key = primary_binding(&keymap.composer.queue);
self.footer.toggle_shortcuts_key = primary_binding(&keymap.composer.toggle_shortcuts);
self.footer.history_search_key = primary_binding(&keymap.composer.history_search_previous);
self.footer.reasoning_down_key = primary_binding(&keymap.chat.decrease_reasoning_effort);
self.footer.reasoning_up_key = primary_binding(&keymap.chat.increase_reasoning_effort);
self.footer.external_editor_key =
keymap.primary_hint(KeymapContext::Global, "open_external_editor");
self.footer.show_transcript_key =
keymap.primary_hint(KeymapContext::Global, "open_transcript");
self.footer.insert_newline_key =
match keymap.primary_hint(KeymapContext::Editor, "insert_newline") {
hint @ Some(ShortcutHint::Chord { .. }) => hint,
_ => footer_insert_newline_key(
&keymap.editor.insert_newline,
self.footer.use_shift_enter_hint,
)
.map(ShortcutHint::from),
};
self.footer.queue_key = keymap.primary_hint(KeymapContext::Composer, "queue");
self.footer.toggle_shortcuts_key =
keymap.primary_hint(KeymapContext::Composer, "toggle_shortcuts");
self.footer.history_search_key =
keymap.primary_hint(KeymapContext::Composer, "history_search_previous");
self.footer.reasoning_down_key =
keymap.primary_hint(KeymapContext::Chat, "decrease_reasoning_effort");
self.footer.reasoning_up_key =
keymap.primary_hint(KeymapContext::Chat, "increase_reasoning_effort");
}
/// Return the contexts whose handlers can consume the next composer key.
pub(crate) fn keymap_contexts(&self) -> KeymapContextSet {
if self.history_search.is_some() {
return KeymapContextSet::new(KeymapContext::Composer);
}
if !self.draft.input_enabled || self.popups.active() {
return KeymapContextSet::default();
}
KeymapContextSet::new(KeymapContext::Composer).with(self.draft.textarea.keymap_context())
}
pub fn set_collaboration_mode_indicator(
@@ -3679,7 +3709,7 @@ impl ChatComposer {
queue: self.footer.queue_key,
insert_newline: self.footer.insert_newline_key,
external_editor: self.footer.external_editor_key,
edit_previous: Some(key_hint::plain(KeyCode::Esc)),
edit_previous: Some(key_hint::plain(KeyCode::Esc).into()),
show_transcript: self.footer.show_transcript_key,
history_search: self.footer.history_search_key,
reasoning_down: self.footer.reasoning_down_key,
@@ -4247,6 +4277,7 @@ fn footer_insert_newline_key(
bindings: &[KeyBinding],
enhanced_keys_supported: bool,
) -> Option<KeyBinding> {
let bindings = user_bindings(bindings);
let shift_enter = key_hint::shift(KeyCode::Enter);
if enhanced_keys_supported && bindings.contains(&shift_enter) {
return Some(shift_enter);
@@ -4850,6 +4881,50 @@ mod tests {
)
}
#[test]
fn shortcut_footer_displays_configured_chords() {
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
let mut config = TuiKeymap::default();
config.global.open_external_editor = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-g ctrl-g".to_string(),
)));
config.editor.insert_newline = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x enter".to_string(),
)));
let keymap = RuntimeKeymap::from_config(&config).expect("valid composer chords");
let (mut composer, _rx) = new_test_composer();
composer.set_keymap_bindings(&keymap);
composer.footer.mode = FooterMode::ShortcutOverlay;
let hints = composer.footer_props().key_hints;
assert_eq!(
hints.external_editor,
Some(ShortcutHint::Chord {
prefix: key_hint::ctrl(KeyCode::Char('g')),
completion: key_hint::ctrl(KeyCode::Char('g')),
})
);
assert_eq!(
hints.insert_newline,
Some(ShortcutHint::Chord {
prefix: key_hint::ctrl(KeyCode::Char('x')),
completion: key_hint::plain(KeyCode::Enter),
})
);
snapshot_composer_state(
"footer_mode_configured_key_chords",
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_keymap_bindings(&keymap);
composer.footer.mode = FooterMode::ShortcutOverlay;
},
);
}
#[test]
fn parent_owned_thread_allows_bare_navigation_commands() {
for (command, expected) in [
@@ -8,6 +8,7 @@ use crate::bottom_pane::footer::CollaborationModeIndicator;
use crate::bottom_pane::footer::FooterMode;
use crate::bottom_pane::footer::GoalStatusIndicator;
use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
#[cfg(test)]
use std::time::Duration;
@@ -30,14 +31,14 @@ pub(super) struct FooterState {
pub(super) status_line_enabled: bool,
pub(super) side_conversation_context_label: Option<String>,
pub(super) active_agent_label: Option<String>,
pub(super) external_editor_key: Option<KeyBinding>,
pub(super) show_transcript_key: Option<KeyBinding>,
pub(super) insert_newline_key: Option<KeyBinding>,
pub(super) queue_key: Option<KeyBinding>,
pub(super) toggle_shortcuts_key: Option<KeyBinding>,
pub(super) history_search_key: Option<KeyBinding>,
pub(super) reasoning_down_key: Option<KeyBinding>,
pub(super) reasoning_up_key: Option<KeyBinding>,
pub(super) external_editor_key: Option<ShortcutHint>,
pub(super) show_transcript_key: Option<ShortcutHint>,
pub(super) insert_newline_key: Option<ShortcutHint>,
pub(super) queue_key: Option<ShortcutHint>,
pub(super) toggle_shortcuts_key: Option<ShortcutHint>,
pub(super) history_search_key: Option<ShortcutHint>,
pub(super) reasoning_down_key: Option<ShortcutHint>,
pub(super) reasoning_up_key: Option<ShortcutHint>,
}
#[derive(Clone, Debug)]
@@ -14,6 +14,7 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::render::Insets;
use crate::render::RectExt as _;
@@ -66,7 +67,7 @@ impl ExperimentalFeaturesView {
complete: false,
app_event_tx,
header: Box::new(header),
footer_hint: experimental_popup_hint_line(),
footer_hint: experimental_popup_hint_line(&keymap),
keymap,
};
view.initialize_selection();
@@ -164,6 +165,10 @@ impl ExperimentalFeaturesView {
}
impl BottomPaneView for ExperimentalFeaturesView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
_ if self.keymap.move_up.is_pressed(key_event) => self.move_up(),
@@ -282,12 +287,18 @@ impl Renderable for ExperimentalFeaturesView {
}
}
fn experimental_popup_hint_line() -> Line<'static> {
Line::from(vec![
fn experimental_popup_hint_line(keymap: &ListKeymap) -> Line<'static> {
let mut spans = vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to select or ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to save for next conversation".into(),
])
" to select".into(),
];
if let Some(accept) = keymap.primary_hint(ListAction::Accept) {
spans.extend([
" or ".into(),
accept.into(),
" to save for next conversation".into(),
]);
}
Line::from(spans)
}
+23 -20
View File
@@ -43,6 +43,7 @@
//! `FooterProps` mapping.
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
use crate::render::line_utils::prefix_lines;
use crate::status::format_tokens_compact;
use crate::ui_consts::FOOTER_INDENT_COLS;
@@ -106,30 +107,30 @@ const FOOTER_CONTEXT_GAP_COLS: u16 = 1;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct FooterKeyHints {
pub(crate) toggle_shortcuts: Option<KeyBinding>,
pub(crate) queue: Option<KeyBinding>,
pub(crate) insert_newline: Option<KeyBinding>,
pub(crate) external_editor: Option<KeyBinding>,
pub(crate) edit_previous: Option<KeyBinding>,
pub(crate) show_transcript: Option<KeyBinding>,
pub(crate) history_search: Option<KeyBinding>,
pub(crate) reasoning_down: Option<KeyBinding>,
pub(crate) reasoning_up: Option<KeyBinding>,
pub(crate) toggle_shortcuts: Option<ShortcutHint>,
pub(crate) queue: Option<ShortcutHint>,
pub(crate) insert_newline: Option<ShortcutHint>,
pub(crate) external_editor: Option<ShortcutHint>,
pub(crate) edit_previous: Option<ShortcutHint>,
pub(crate) show_transcript: Option<ShortcutHint>,
pub(crate) history_search: Option<ShortcutHint>,
pub(crate) reasoning_down: Option<ShortcutHint>,
pub(crate) reasoning_up: Option<ShortcutHint>,
}
impl FooterKeyHints {
#[cfg(test)]
pub(crate) fn default_bindings() -> Self {
Self {
toggle_shortcuts: Some(key_hint::plain(KeyCode::Char('?'))),
queue: Some(key_hint::plain(KeyCode::Tab)),
insert_newline: Some(key_hint::ctrl(KeyCode::Char('j'))),
external_editor: Some(key_hint::ctrl(KeyCode::Char('g'))),
edit_previous: Some(key_hint::plain(KeyCode::Esc)),
show_transcript: Some(key_hint::ctrl(KeyCode::Char('t'))),
history_search: Some(key_hint::ctrl(KeyCode::Char('r'))),
reasoning_down: Some(key_hint::alt(KeyCode::Char(','))),
reasoning_up: Some(key_hint::alt(KeyCode::Char('.'))),
toggle_shortcuts: Some(key_hint::plain(KeyCode::Char('?')).into()),
queue: Some(key_hint::plain(KeyCode::Tab).into()),
insert_newline: Some(key_hint::ctrl(KeyCode::Char('j')).into()),
external_editor: Some(key_hint::ctrl(KeyCode::Char('g')).into()),
edit_previous: Some(key_hint::plain(KeyCode::Esc).into()),
show_transcript: Some(key_hint::ctrl(KeyCode::Char('t')).into()),
history_search: Some(key_hint::ctrl(KeyCode::Char('r')).into()),
reasoning_down: Some(key_hint::alt(KeyCode::Char(',')).into()),
reasoning_up: Some(key_hint::alt(KeyCode::Char('.')).into()),
}
}
}
@@ -1087,7 +1088,9 @@ impl ShortcutDescriptor {
| ShortcutId::FilePaths
| ShortcutId::PasteImage
| ShortcutId::Quit
| ShortcutId::ChangeMode => self.binding_for(state).map(|binding| binding.key),
| ShortcutId::ChangeMode => self
.binding_for(state)
.map(|binding| ShortcutHint::Single(binding.key)),
}?;
let mut line = Line::from(vec![self.prefix.into(), key.into()]);
match self.id {
@@ -1576,7 +1579,7 @@ mod tests {
status_line_value: None,
status_line_enabled: false,
key_hints: FooterKeyHints {
insert_newline: Some(key_hint::shift(KeyCode::Enter)),
insert_newline: Some(key_hint::shift(KeyCode::Enter).into()),
..FooterKeyHints::default_bindings()
},
active_agent_label: None,
@@ -30,6 +30,7 @@ use crate::hooks_rpc::HookTrustUpdate;
use crate::hooks_rpc::hook_needs_review;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::renderable::Renderable;
@@ -511,35 +512,40 @@ impl HooksBrowserView {
width: area.width.saturating_sub(2),
height: area.height,
};
let accept = self.keymap.primary_hint(ListAction::Accept);
let cancel = self
.keymap
.primary_hint(ListAction::Cancel)
.unwrap_or_else(|| key_hint::plain(KeyCode::Esc).into());
let footer = match self.page {
HooksBrowserPage::Events if self.review_needed_total_count() > 0 => Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Char('t')).into(),
" to trust all; ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to review hooks; ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
]),
HooksBrowserPage::Events => Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to view hooks; ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
]),
HooksBrowserPage::Events if self.review_needed_total_count() > 0 => {
let mut spans = vec![
"Press ".into(),
key_hint::plain(KeyCode::Char('t')).into(),
" to trust all; ".into(),
];
if let Some(accept) = accept {
spans.extend([accept.into(), " to review hooks; ".into()]);
}
spans.extend([cancel.into(), " to close".into()]);
Line::from(spans)
}
HooksBrowserPage::Events => {
let mut spans = vec!["Press ".into()];
if let Some(accept) = accept {
spans.extend([accept.into(), " to view hooks; ".into()]);
}
spans.extend([cancel.into(), " to close".into()]);
Line::from(spans)
}
HooksBrowserPage::Handlers(event_name) => {
let selected_hook = self.selected_hook(event_name);
if selected_hook.is_none() {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to go back".into(),
])
Line::from(vec!["Press ".into(), cancel.into(), " to go back".into()])
} else if selected_hook.is_some_and(|hook| hook.is_managed) {
Line::from(vec![
"Managed hooks are always on; press ".into(),
key_hint::plain(KeyCode::Esc).into(),
cancel.into(),
" to go back".into(),
])
} else if selected_hook.is_some_and(hook_needs_review) {
@@ -547,19 +553,17 @@ impl HooksBrowserView {
"Press ".into(),
key_hint::plain(KeyCode::Char('t')).into(),
" to trust; ".into(),
key_hint::plain(KeyCode::Esc).into(),
cancel.into(),
" to go back".into(),
])
} else {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" or ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to toggle; ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to go back".into(),
])
let mut spans =
vec!["Press ".into(), key_hint::plain(KeyCode::Char(' ')).into()];
if let Some(accept) = accept {
spans.extend([" or ".into(), accept.into()]);
}
spans.extend([" to toggle; ".into(), cancel.into(), " to go back".into()]);
Line::from(spans)
}
}
};
@@ -568,6 +572,10 @@ impl HooksBrowserView {
}
impl BottomPaneView for HooksBrowserView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
_ if self.keymap.move_up.is_pressed(key_event) => self.move_up(),
@@ -1002,8 +1010,10 @@ mod tests {
#[test]
fn renders_event_browser() {
let view = view();
let mut view = view();
assert_snapshot!("hooks_browser_events", render_lines(&view, /*width*/ 112));
view.keymap.accept.clear();
assert!(!render_lines(&view, /*width*/ 112).contains("enter to view hooks"));
}
#[test]
@@ -16,8 +16,8 @@ use super::selection_popup_common::render_menu_surface;
use super::selection_popup_common::wrap_styled_line;
use crate::app_event_sender::AppEventSender;
use crate::clipboard_paste::normalize_pasted_search_query;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint;
use crate::key_hint::is_plain_text_key_event;
use crate::keymap::ListKeymap;
use crate::render::renderable::ColumnRenderable;
@@ -135,7 +135,7 @@ pub(crate) struct SelectionItem {
pub name_prefix_spans: Vec<Span<'static>>,
pub toggle: Option<SelectionToggle>,
pub toggle_placeholder: Option<&'static str>,
pub display_shortcut: Option<KeyBinding>,
pub display_shortcut: Option<ShortcutHint>,
pub description: Option<String>,
pub selected_description: Option<String>,
pub is_current: bool,
@@ -950,6 +950,10 @@ impl ListSelectionView {
}
impl BottomPaneView for ListSelectionView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
// Searchable lists reserve printable characters for query input. This
// keeps vim-style plain j/k/h/l useful in non-search lists without
@@ -49,8 +49,11 @@ use crate::bottom_pane::selection_popup_common::menu_surface_inset;
use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
use crate::bottom_pane::selection_popup_common::render_menu_surface;
use crate::bottom_pane::selection_popup_common::render_rows;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint;
use crate::keymap::KeymapContext;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::RuntimeKeymap;
use crate::render::renderable::Renderable;
use crate::text_formatting::format_json_compact;
use crate::text_formatting::truncate_text;
@@ -713,6 +716,7 @@ pub(crate) struct McpServerElicitationOverlay {
done: bool,
validation_error: Option<String>,
list_keymap: ListKeymap,
composer_submit_hint: Option<ShortcutHint>,
}
impl McpServerElicitationOverlay {
@@ -730,7 +734,7 @@ impl McpServerElicitationOverlay {
has_input_focus,
enhanced_keys_supported,
disable_paste_burst,
crate::keymap::RuntimeKeymap::defaults().list,
RuntimeKeymap::defaults(),
)
}
@@ -740,8 +744,9 @@ impl McpServerElicitationOverlay {
has_input_focus: bool,
enhanced_keys_supported: bool,
disable_paste_burst: bool,
list_keymap: ListKeymap,
keymap: RuntimeKeymap,
) -> Self {
let composer_submit_hint = keymap.primary_hint(KeymapContext::Composer, "submit");
let mut composer = ChatComposer::new_with_config(
has_input_focus,
app_event_tx.clone(),
@@ -750,6 +755,7 @@ impl McpServerElicitationOverlay {
disable_paste_burst,
ChatComposerConfig::plain_text(),
);
composer.set_keymap_bindings(&keymap);
composer.set_footer_hint_override(Some(Vec::new()));
let mut overlay = Self {
app_event_tx,
@@ -760,7 +766,8 @@ impl McpServerElicitationOverlay {
current_idx: 0,
done: false,
validation_error: None,
list_keymap,
list_keymap: keymap.list,
composer_submit_hint,
};
overlay.reset_for_request();
overlay.restore_current_draft();
@@ -984,20 +991,21 @@ impl McpServerElicitationOverlay {
fn footer_tips(&self) -> Vec<FooterTip> {
let mut tips = Vec::new();
let is_last_field = self.current_index().saturating_add(1) >= self.field_count();
if self.current_field_is_select() {
if self.field_count() == 1 {
tips.push(FooterTip::highlighted("enter to submit"));
} else if is_last_field {
tips.push(FooterTip::highlighted("enter to submit all"));
} else {
tips.push(FooterTip::new("enter to submit answer"));
}
} else if self.field_count() == 1 {
tips.push(FooterTip::highlighted("enter to submit"));
} else if is_last_field {
tips.push(FooterTip::highlighted("enter to submit all"));
let submit_hint = if self.current_field_is_select() {
self.list_keymap.primary_hint(ListAction::Accept)
} else {
tips.push(FooterTip::new("enter to submit answer"));
self.composer_submit_hint
};
if let Some(submit_hint) = submit_hint.map(ShortcutHint::display_label) {
if self.field_count() == 1 {
tips.push(FooterTip::highlighted(format!("{submit_hint} to submit")));
} else if is_last_field {
tips.push(FooterTip::highlighted(format!(
"{submit_hint} to submit all"
)));
} else {
tips.push(FooterTip::new(format!("{submit_hint} to submit answer")));
}
}
if self.field_count() > 1 {
if self.current_field_is_select() {
@@ -1505,6 +1513,14 @@ impl Renderable for McpServerElicitationOverlay {
}
impl BottomPaneView for McpServerElicitationOverlay {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
if self.current_field_is_select() {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
} else {
self.composer.keymap_contexts()
}
}
fn prefer_esc_to_handle_key_event(&self) -> bool {
true
}
@@ -1514,7 +1530,10 @@ impl BottomPaneView for McpServerElicitationOverlay {
return;
}
if matches!(key_event.code, KeyCode::Esc) {
if matches!(key_event.code, KeyCode::Esc)
|| self.current_field_is_select()
&& self.list_keymap.action_for(key_event) == Some(ListAction::Cancel)
{
self.dispatch_cancel();
self.done = true;
return;
@@ -1561,7 +1580,7 @@ impl BottomPaneView for McpServerElicitationOverlay {
return;
}
_ if self.current_field_is_select()
&& self.list_keymap.move_left.is_pressed(key_event) =>
&& self.list_keymap.action_for(key_event) == Some(ListAction::MoveLeft) =>
{
self.move_field(/*next*/ false);
return;
@@ -1580,7 +1599,7 @@ impl BottomPaneView for McpServerElicitationOverlay {
return;
}
_ if self.current_field_is_select()
&& self.list_keymap.move_right.is_pressed(key_event) =>
&& self.list_keymap.action_for(key_event) == Some(ListAction::MoveRight) =>
{
self.move_field(/*next*/ true);
return;
@@ -1591,28 +1610,28 @@ impl BottomPaneView for McpServerElicitationOverlay {
if self.current_field_is_select() {
self.validation_error = None;
let options_len = self.options_len();
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
match (self.list_keymap.action_for(key_event), key_event.code) {
(Some(ListAction::MoveUp), _) | (_, KeyCode::Up | KeyCode::Char('k')) => {
if let Some(answer) = self.current_answer_mut() {
answer.selection.move_up_wrap(options_len);
answer.answer_committed = false;
}
}
KeyCode::Down | KeyCode::Char('j') => {
(Some(ListAction::MoveDown), _) | (_, KeyCode::Down | KeyCode::Char('j')) => {
if let Some(answer) = self.current_answer_mut() {
answer.selection.move_down_wrap(options_len);
answer.answer_committed = false;
}
}
KeyCode::Backspace | KeyCode::Delete => self.clear_selection(),
KeyCode::Char(' ') => self.select_current_option(/*committed*/ true),
KeyCode::Enter => {
(_, KeyCode::Backspace | KeyCode::Delete) => self.clear_selection(),
(_, KeyCode::Char(' ')) => self.select_current_option(/*committed*/ true),
(Some(ListAction::Accept), _) | (_, KeyCode::Enter) => {
if self.selected_option_index().is_some() {
self.select_current_option(/*committed*/ true);
}
self.go_next_or_submit();
}
KeyCode::Char(ch) => {
(_, KeyCode::Char(ch)) => {
if let Some(option_idx) = self.option_index_for_digit(ch) {
if let Some(answer) = self.current_answer_mut() {
answer.selection.selected_idx = Some(option_idx);
@@ -1738,6 +1757,9 @@ mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::render::renderable::Renderable;
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::mpsc::unbounded_channel;
@@ -2234,6 +2256,73 @@ mod tests {
assert_eq!(overlay.current_idx, 1);
overlay.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(overlay.current_idx, 0);
overlay.list_keymap.accept.clear();
assert!(
overlay
.footer_tips()
.iter()
.all(|tip| !tip.text.contains("submit"))
);
}
#[test]
fn text_fields_inherit_composer_chord_context_and_submit_binding() {
let (tx, _rx) = test_sender();
let thread_id = ThreadId::default();
let request = from_form_request(
thread_id,
form_request(
"Enter a name",
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name",
}
},
"required": ["name"],
}),
/*meta*/ None,
),
)
.expect("supported text field");
let mut config = TuiKeymap::default();
config.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x enter".to_string(),
)));
let keymap = RuntimeKeymap::from_config(&config).expect("valid MCP composer chord");
let mut overlay = McpServerElicitationOverlay::new_with_keymap(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ true, keymap,
);
let snapshot = render_snapshot(
&overlay,
Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 10,
),
)
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(
"mcp_server_elicitation_text_configured_key_chords",
snapshot
);
assert!(
overlay
.keymap_contexts()
.contains(crate::keymap::KeymapContext::Composer)
);
overlay.composer_submit_hint = None;
assert!(
overlay
.footer_tips()
.iter()
.all(|tip| !tip.text.contains("submit"))
);
}
#[test]
@@ -12,9 +12,10 @@ use ratatui::widgets::Widget;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::render::Insets;
use crate::render::RectExt as _;
@@ -290,14 +291,18 @@ impl MemoriesSettingsView {
fn footer_hint(&self) -> Line<'static> {
if self.reset_confirmation.is_some() {
standard_popup_hint_line()
standard_popup_hint_line_for_keymap(&self.keymap)
} else {
memories_settings_hint_line()
memories_settings_hint_line(&self.keymap)
}
}
}
impl BottomPaneView for MemoriesSettingsView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
_ if self.keymap.move_up.is_pressed(key_event) => self.move_up(),
@@ -459,12 +464,14 @@ impl Renderable for MemoriesSettingsView {
}
}
fn memories_settings_hint_line() -> Line<'static> {
Line::from(vec![
fn memories_settings_hint_line(keymap: &ListKeymap) -> Line<'static> {
let mut spans = vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to toggle; ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to save or select".into(),
])
" to toggle".into(),
];
if let Some(accept) = keymap.primary_hint(ListAction::Accept) {
spans.extend(["; ".into(), accept.into(), " to save or select".into()]);
}
Line::from(spans)
}
+25 -6
View File
@@ -27,8 +27,9 @@ use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::KeymapContext;
use crate::keymap::KeymapContextSet;
use crate::keymap::RuntimeKeymap;
use crate::keymap::primary_binding;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
@@ -398,7 +399,7 @@ impl BottomPane {
pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.keymap = keymap.clone();
self.composer.set_keymap_bindings(keymap);
let interrupt_binding = primary_binding(&keymap.chat.interrupt_turn);
let interrupt_binding = keymap.primary_hint(KeymapContext::Chat, "interrupt_turn");
self.pending_input_preview
.set_interrupt_binding(interrupt_binding);
if let Some(status) = self.status.as_mut() {
@@ -485,7 +486,10 @@ impl BottomPane {
/// 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<KeyBinding>) {
pub(crate) fn set_queued_message_edit_binding(
&mut self,
binding: Option<crate::key_hint::ShortcutHint>,
) {
self.pending_input_preview.set_edit_binding(binding);
self.request_redraw();
}
@@ -693,6 +697,15 @@ impl BottomPane {
}
}
/// Return the contexts whose ordinary handlers can consume the next key.
pub(crate) fn keymap_contexts(&self) -> KeymapContextSet {
if let Some(view) = self.view_stack.last() {
view.keymap_contexts()
} else {
self.composer.keymap_contexts()
}
}
/// Handles a Ctrl+C press within the bottom pane.
///
/// An active modal view is given the first chance to consume the key (typically to dismiss
@@ -1047,7 +1060,10 @@ impl BottomPane {
}
if let Some(status) = self.status.as_mut() {
status.set_interrupt_hint_visible(/*visible*/ true);
status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn));
status.set_interrupt_binding(
self.keymap
.primary_hint(KeymapContext::Chat, "interrupt_turn"),
);
}
self.sync_status_inline_message();
self.request_redraw();
@@ -1077,7 +1093,10 @@ impl BottomPane {
self.animations_enabled,
));
if let Some(status) = self.status.as_mut() {
status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn));
status.set_interrupt_binding(
self.keymap
.primary_hint(KeymapContext::Chat, "interrupt_turn"),
);
}
self.sync_status_inline_message();
self.request_redraw();
@@ -1553,7 +1572,7 @@ impl BottomPane {
self.has_input_focus,
self.enhanced_keys_supported,
self.disable_paste_burst,
self.keymap.list.clone(),
self.keymap.clone(),
);
self.pause_status_timer_for_modal();
self.set_composer_input_enabled(
@@ -63,9 +63,9 @@ use crate::bottom_pane::selection_popup_common::render_rows_single_line;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_plain_text_key_event;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::RuntimeKeymap;
use crate::keymap::primary_binding;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
use crate::render::RectExt;
@@ -515,6 +515,10 @@ impl MultiSelectPicker {
}
impl BottomPaneView for MultiSelectPicker {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn is_complete(&self) -> bool {
self.complete
}
@@ -828,8 +832,8 @@ impl MultiSelectPickerBuilder {
];
if self.ordering_enabled
&& let (Some(move_left), Some(move_right)) = (
primary_binding(&self.keymap.move_left),
primary_binding(&self.keymap.move_right),
self.keymap.primary_hint(ListAction::MoveLeft),
self.keymap.primary_hint(ListAction::MoveRight),
)
{
spans.push("; ".into());
@@ -838,12 +842,12 @@ impl MultiSelectPickerBuilder {
spans.push(move_right.into());
spans.push(" to move".into());
}
if let Some(accept) = primary_binding(&self.keymap.accept) {
if let Some(accept) = self.keymap.primary_hint(ListAction::Accept) {
spans.push("; ".into());
spans.push(accept.into());
spans.push(" to confirm and close".into());
}
if let Some(cancel) = primary_binding(&self.keymap.cancel) {
if let Some(cancel) = self.keymap.primary_hint(ListAction::Cancel) {
spans.push("; ".into());
spans.push(cancel.into());
spans.push(" to close".into());
@@ -26,9 +26,9 @@ pub(crate) struct PendingInputPreview {
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.
edit_binding: Option<key_hint::KeyBinding>,
edit_binding: Option<key_hint::ShortcutHint>,
/// Key combination rendered for immediately interrupting and sending steers.
interrupt_binding: Option<key_hint::KeyBinding>,
interrupt_binding: Option<key_hint::ShortcutHint>,
}
const PREVIEW_LINE_LIMIT: usize = 3;
@@ -39,19 +39,19 @@ impl PendingInputPreview {
pending_steers: Vec::new(),
rejected_steers: Vec::new(),
queued_messages: Vec::new(),
edit_binding: Some(key_hint::alt(KeyCode::Up)),
interrupt_binding: Some(key_hint::plain(KeyCode::Esc)),
edit_binding: Some(key_hint::alt(KeyCode::Up).into()),
interrupt_binding: Some(key_hint::plain(KeyCode::Esc).into()),
}
}
/// Replace the keybinding shown in the hint line at the bottom of the
/// queued-messages list. The caller is responsible for also wiring the
/// corresponding key event handler.
pub(crate) fn set_edit_binding(&mut self, binding: Option<key_hint::KeyBinding>) {
pub(crate) fn set_edit_binding(&mut self, binding: Option<key_hint::ShortcutHint>) {
self.edit_binding = binding;
}
pub(crate) fn set_interrupt_binding(&mut self, binding: Option<key_hint::KeyBinding>) {
pub(crate) fn set_interrupt_binding(&mut self, binding: Option<key_hint::ShortcutHint>) {
self.interrupt_binding = binding;
}
@@ -216,7 +216,7 @@ mod tests {
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(Some(key_hint::shift(KeyCode::Left)));
queue.set_edit_binding(Some(key_hint::shift(KeyCode::Left).into()));
let width = 40;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
@@ -337,7 +337,7 @@ mod tests {
fn render_one_pending_steer_with_remapped_interrupt_binding() {
let mut queue = PendingInputPreview::new();
queue.pending_steers.push("Please continue.".to_string());
queue.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12))));
queue.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)).into()));
let width = 48;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
+6 -6
View File
@@ -3,9 +3,9 @@
use ratatui::text::Line;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::primary_binding;
use crossterm::event::KeyCode;
/// Maximum number of rows any popup should attempt to display.
@@ -25,17 +25,17 @@ pub(crate) fn standard_popup_hint_line() -> Line<'static> {
pub(crate) fn standard_popup_hint_line_for_keymap(list_keymap: &ListKeymap) -> Line<'static> {
accept_cancel_hint_line(
primary_binding(&list_keymap.accept),
list_keymap.primary_hint(ListAction::Accept),
"to confirm",
primary_binding(&list_keymap.cancel),
list_keymap.primary_hint(ListAction::Cancel),
"to go back",
)
}
pub(crate) fn accept_cancel_hint_line(
accept: Option<KeyBinding>,
accept: Option<ShortcutHint>,
accept_label: &'static str,
cancel: Option<KeyBinding>,
cancel: Option<ShortcutHint>,
cancel_label: &'static str,
) -> Line<'static> {
match (accept, cancel) {
@@ -33,6 +33,9 @@ use crate::bottom_pane::selection_popup_common::measure_rows_height;
use crate::history_cell;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint;
use crate::keymap::KeymapContext;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::RuntimeKeymap;
use crate::render::renderable::Renderable;
@@ -170,7 +173,9 @@ pub(crate) struct RequestUserInputOverlay {
request_started_at: Instant,
auto_resolution_snoozed: bool,
composer_submit_keys: Vec<KeyBinding>,
composer_submit_hint: Option<ShortcutHint>,
interrupt_turn_keys: Vec<KeyBinding>,
interrupt_turn_hint: Option<ShortcutHint>,
list_keymap: ListKeymap,
}
@@ -228,7 +233,9 @@ impl RequestUserInputOverlay {
request_started_at: Instant::now(),
auto_resolution_snoozed: false,
composer_submit_keys: keymap.composer.submit.clone(),
composer_submit_hint: keymap.primary_hint(KeymapContext::Composer, "submit"),
interrupt_turn_keys: keymap.chat.interrupt_turn.clone(),
interrupt_turn_hint: keymap.primary_hint(KeymapContext::Chat, "interrupt_turn"),
list_keymap: keymap.list,
};
overlay.reset_for_request();
@@ -594,11 +601,11 @@ impl RequestUserInputOverlay {
let question_count = self.question_count();
let is_last_question = self.current_index().saturating_add(1) >= question_count;
let submit_key = if self.focus_is_notes() || !self.has_options() {
self.composer_submit_keys
.first()
.map(KeyBinding::display_label)
self.composer_submit_hint.map(ShortcutHint::display_label)
} else {
Some("enter".to_string())
self.list_keymap
.primary_hint(ListAction::Accept)
.map(ShortcutHint::display_label)
};
if let Some(submit_key) = submit_key {
let submit_tip = if question_count == 1 {
@@ -617,10 +624,10 @@ impl RequestUserInputOverlay {
tips.push(FooterTip::new("ctrl + p / ctrl + n change question"));
}
}
if let Some(interrupt_key) = self.interrupt_turn_keys.first()
if let Some(interrupt_key) = self.interrupt_turn_hint
&& !(self.has_options()
&& notes_visible
&& *interrupt_key == crate::key_hint::plain(KeyCode::Esc))
&& interrupt_key == ShortcutHint::Single(crate::key_hint::plain(KeyCode::Esc)))
{
tips.push(FooterTip::new(format!(
"{} to interrupt",
@@ -1179,6 +1186,19 @@ impl RequestUserInputOverlay {
}
impl BottomPaneView for RequestUserInputOverlay {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
if self.confirm_unanswered_active() {
return crate::keymap::KeymapContextSet::default();
}
if matches!(self.focus, Focus::Options) {
return crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
.with(crate::keymap::KeymapContext::Chat);
}
self.composer
.keymap_contexts()
.with(crate::keymap::KeymapContext::Chat)
}
fn prefer_esc_to_handle_key_event(&self) -> bool {
true
}
@@ -1280,7 +1300,7 @@ impl BottomPaneView for RequestUserInputOverlay {
}
_ if self.has_options()
&& matches!(self.focus, Focus::Options)
&& self.list_keymap.move_left.is_pressed(key_event) =>
&& self.list_keymap.action_for(key_event) == Some(ListAction::MoveLeft) =>
{
self.move_question(/*next*/ false);
return;
@@ -1300,7 +1320,7 @@ impl BottomPaneView for RequestUserInputOverlay {
}
_ if self.has_options()
&& matches!(self.focus, Focus::Options)
&& self.list_keymap.move_right.is_pressed(key_event) =>
&& self.list_keymap.action_for(key_event) == Some(ListAction::MoveRight) =>
{
self.move_question(/*next*/ true);
return;
@@ -1312,8 +1332,8 @@ impl BottomPaneView for RequestUserInputOverlay {
Focus::Options => {
let options_len = self.options_len();
// Keep selection synchronized as the user moves.
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
match (self.list_keymap.action_for(key_event), key_event.code) {
(Some(ListAction::MoveUp), _) | (_, KeyCode::Up | KeyCode::Char('k')) => {
let moved = if let Some(answer) = self.current_answer_mut() {
answer.options_state.move_up_wrap(options_len);
answer.answer_committed = false;
@@ -1325,7 +1345,7 @@ impl BottomPaneView for RequestUserInputOverlay {
self.sync_composer_placeholder();
}
}
KeyCode::Down | KeyCode::Char('j') => {
(Some(ListAction::MoveDown), _) | (_, KeyCode::Down | KeyCode::Char('j')) => {
let moved = if let Some(answer) = self.current_answer_mut() {
answer.options_state.move_down_wrap(options_len);
answer.answer_committed = false;
@@ -1337,24 +1357,24 @@ impl BottomPaneView for RequestUserInputOverlay {
self.sync_composer_placeholder();
}
}
KeyCode::Char(' ') => {
(_, KeyCode::Char(' ')) => {
self.select_current_option(/*committed*/ true);
}
KeyCode::Backspace | KeyCode::Delete => {
(_, KeyCode::Backspace | KeyCode::Delete) => {
self.clear_selection();
}
KeyCode::Tab if self.selected_option_index().is_some() => {
(_, KeyCode::Tab) if self.selected_option_index().is_some() => {
self.focus = Focus::Notes;
self.ensure_selected_for_notes();
}
KeyCode::Enter => {
(Some(ListAction::Accept), _) | (_, KeyCode::Enter) => {
let has_selection = self.selected_option_index().is_some();
if has_selection {
self.select_current_option(/*committed*/ true);
}
self.go_next_or_submit();
}
KeyCode::Char(ch) => {
(_, KeyCode::Char(ch)) => {
if let Some(option_idx) = self.option_index_for_digit(ch) {
if let Some(answer) = self.current_answer_mut() {
answer.options_state.selected_idx = Some(option_idx);
@@ -1516,6 +1536,9 @@ mod tests {
use crate::app_event::AppEvent;
use crate::bottom_pane::selection_popup_common::menu_surface_inset;
use crate::render::renderable::Renderable;
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -2514,6 +2537,49 @@ mod tests {
);
}
#[test]
fn freeform_footer_displays_configured_chords_without_internal_dispatch_keys() {
for (specs, expected_tips) in [
(
KeybindingsSpec::One(KeybindingSpec("ctrl-x enter".to_string())),
vec!["ctrl + x enter to submit answer", "esc to interrupt"],
),
(
KeybindingsSpec::Many(vec![
KeybindingSpec("ctrl-enter".to_string()),
KeybindingSpec("ctrl-x enter".to_string()),
]),
vec!["ctrl + enter to submit answer", "esc to interrupt"],
),
(
KeybindingsSpec::Many(vec![
KeybindingSpec("ctrl-x enter".to_string()),
KeybindingSpec("ctrl-enter".to_string()),
]),
vec!["ctrl + x enter to submit answer", "esc to interrupt"],
),
] {
let (tx, _rx) = test_sender();
let mut config = TuiKeymap::default();
config.composer.submit = Some(specs);
let keymap = RuntimeKeymap::from_config(&config).expect("valid submit keymap");
let overlay = RequestUserInputOverlay::new_with_keymap(
request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
keymap,
);
let tips = overlay.footer_tips();
assert_eq!(
tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>(),
expected_tips
);
}
}
#[test]
fn request_user_input_uses_remapped_interrupt_binding_while_notes_are_visible() {
let (tx, mut rx) = test_sender();
@@ -3656,6 +3722,44 @@ mod tests {
);
}
#[test]
fn request_user_input_freeform_chord_only_submit_snapshot() {
let (tx, _rx) = test_sender();
let mut config = TuiKeymap::default();
config.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x enter".to_string(),
)));
let keymap = RuntimeKeymap::from_config(&config).expect("valid submit chord");
let overlay = RequestUserInputOverlay::new_with_keymap(
request_event("turn-1", vec![question_without_options("q1", "Goal")]),
tx,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
keymap,
);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 120, /*height*/ 10,
);
let snapshot = render_snapshot(&overlay, area)
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(snapshot, @r"
Question 1/1 (1 unanswered)
Share details.
› Type your answer (optional)
ctrl + x enter to submit answer | esc to interrupt
");
}
#[test]
fn request_user_input_freeform_remapped_submit_snapshot() {
let (tx, _rx) = test_sender();
@@ -8,7 +8,7 @@ use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
use crate::line_truncation::line_width;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
@@ -32,7 +32,7 @@ use super::selection_row_layout::wrap_stacked_row;
pub(crate) struct GenericDisplayRow {
pub name: String,
pub name_prefix_spans: Vec<Span<'static>>,
pub display_shortcut: Option<KeyBinding>,
pub display_shortcut: Option<ShortcutHint>,
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
pub description: Option<String>, // optional grey text after the name
pub category_tag: Option<String>, // optional right-side category label
@@ -15,9 +15,10 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint;
use crate::key_hint::is_plain_text_key_event;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::primary_binding;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
@@ -232,6 +233,10 @@ impl SkillsToggleView {
}
impl BottomPaneView for SkillsToggleView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
// Printable characters always feed search. Movement aliases such as
// plain j/k only apply through non-text events or modified bindings.
@@ -388,8 +393,10 @@ impl Renderable for SkillsToggleView {
fn skills_toggle_hint_line(keymap: &ListKeymap) -> Line<'static> {
let space = key_hint::plain(KeyCode::Char(' '));
let accept = primary_binding(&keymap.accept).filter(|binding| *binding != space);
let cancel = primary_binding(&keymap.cancel);
let accept = keymap
.primary_hint(ListAction::Accept)
.filter(|binding| *binding != ShortcutHint::Single(space));
let cancel = keymap.primary_hint(ListAction::Cancel);
match (accept, cancel) {
(Some(accept), Some(cancel)) => Line::from(vec![
@@ -0,0 +1,21 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" / for commands ! for shell commands "
" ctrl + x enter for newline tab to submit message "
" @ for file paths ctrl + v to paste images "
" ctrl + g ctrl + g to edit in external editor esc esc to edit previous message "
" ctrl + r search history ctrl + c to exit "
" ⌥ + , reasoning down ⌥ + . reasoning up "
" ctrl + t to view transcript "
" "
" customize shortcuts with /keymap "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/mcp_server_elicitation.rs
expression: snapshot
---
Field 1/1 (1 required unanswered)
Enter a name
Name
› Type your answer
ctrl + x enter to submit | esc to cancel
@@ -371,6 +371,10 @@ impl StatusLineSetupView {
}
impl BottomPaneView for StatusLineSetupView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) {
self.picker.handle_key_event(key_event);
}
+13
View File
@@ -13,6 +13,7 @@
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_altgr;
use crate::keymap::EditorKeymap;
use crate::keymap::KeymapContext;
use crate::keymap::RuntimeKeymap;
use crate::keymap::VimNormalKeymap;
use crate::keymap::VimOperatorKeymap;
@@ -280,6 +281,18 @@ impl TextArea {
!matches!(self.vim_pending, VimPending::None)
}
/// Return the keymap context that owns the next editing key.
pub(crate) fn keymap_context(&self) -> KeymapContext {
if !self.vim_enabled || self.vim_mode == VimMode::Insert {
return KeymapContext::Editor;
}
match self.vim_pending {
VimPending::None => KeymapContext::VimNormal,
VimPending::Operator(_) => KeymapContext::VimOperator,
VimPending::TextObject { .. } => KeymapContext::VimTextObject,
}
}
/// Enter Vim insert mode if modal editing is enabled.
///
/// Calling this while Vim is disabled is a no-op, which lets parent
@@ -345,6 +345,10 @@ impl TerminalTitleSetupView {
}
impl BottomPaneView for TerminalTitleSetupView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) {
self.picker.handle_key_event(key_event);
}