mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
1 parent
a1dd74b535
commit
1e85ca099e
61 files changed
+3503
-660
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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![
|
||||
|
||||
+21
@@ -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 "
|
||||
+13
@@ -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,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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user