mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(tui): add configurable keymap support (#18593)
## Why The TUI currently handles keyboard shortcuts as hard-coded event matches spread across app, composer, pager, list, approval, and navigation code. That makes shortcuts hard to customize, makes displayed hints easy to drift from actual behavior, and makes future keymap work riskier because there is no central action inventory. This PR adds the foundation for configurable, action-based keymaps without adding the interactive remapping UI yet. Onboarding intentionally stays on fixed startup shortcuts because users cannot reasonably configure keymaps before completing onboarding. This is PR1 in the keymap stack: - PR1: #18593: configurable keymap foundation - PR2: #18594: `/keymap` picker and guided remapping UI - PR3: #18595: Vim composer mode and the remap option ## Design Notes The new model resolves named actions into concrete runtime bindings once from config, then passes those bindings to the UI surfaces that handle input or render shortcut hints. The main concepts are: - **Context**: a scope where an action is active, such as `global`, `chat`, `composer`, `editor`, `pager`, `list`, or `approval`. - **Action**: a named operation inside a context, such as `global.open_transcript`, `composer.submit`, or `pager.close`. - **Binding**: one or more single-key shortcuts assigned to an action, written as config strings such as `ctrl-t`, `alt-backspace`, or `page-down`. Multi-step sequences such as `ctrl-x ctrl-s`, `g g`, or leader-key flows are not part of this PR. - **Resolution order**: context-specific config wins first, supported global fallbacks come next, and built-in defaults fill in anything unset. - **Explicit unbinding**: an empty array removes an action binding in that scope and does not fall through to a fallback binding. - **Conflict validation**: a resolved keymap rejects duplicate active bindings inside the same scope so one keypress cannot dispatch two actions. ## What Changed - Added `TuiKeymap` config support under `[tui.keymap]`, including typed contexts/actions, key alias normalization, generated schema coverage, and user-facing config errors. - Added `RuntimeKeymap` resolution in `codex-rs/tui/src/keymap.rs`, including fallback precedence, built-in defaults, explicit unbinding, and per-context conflict validation. - Rewired existing TUI handlers to consume resolved keymap actions instead of directly matching hard-coded keys in each component. - Updated key hint rendering and footer/pager/list surfaces so displayed shortcuts follow the resolved keymap. - Kept onboarding shortcuts fixed in `codex-rs/tui/src/onboarding/keys.rs` instead of exposing them through `[tui.keymap]`. ## Validation The branch includes focused coverage for config parsing, key normalization, runtime fallback resolution, explicit unbinding, duplicate-key conflict validation, default keymap consistency, onboarding startup key behavior, and UI hint snapshots affected by resolved key bindings.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -155,6 +155,7 @@ use super::command_popup::CommandPopup;
|
||||
use super::command_popup::CommandPopupFlags;
|
||||
use super::file_search_popup::FileSearchPopup;
|
||||
use super::footer::CollaborationModeIndicator;
|
||||
use super::footer::FooterKeyHints;
|
||||
use super::footer::FooterMode;
|
||||
use super::footer::FooterProps;
|
||||
use super::footer::GoalStatusIndicator;
|
||||
@@ -185,6 +186,10 @@ use super::slash_commands;
|
||||
use super::slash_commands::BuiltinCommandFlags;
|
||||
use crate::bottom_pane::paste_burst::FlushResult;
|
||||
use crate::bottom_pane::prompt_args::parse_slash_name;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::EditorKeymap;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::keymap::primary_binding;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
use crate::render::renderable::Renderable;
|
||||
@@ -390,6 +395,20 @@ pub(crate) struct ChatComposer {
|
||||
// Agent label injected into the footer's contextual row when multi-agent mode is active.
|
||||
active_agent_label: Option<String>,
|
||||
history_search: Option<HistorySearchSession>,
|
||||
submit_keys: Vec<KeyBinding>,
|
||||
queue_keys: Vec<KeyBinding>,
|
||||
toggle_shortcuts_keys: Vec<KeyBinding>,
|
||||
history_search_previous_keys: Vec<KeyBinding>,
|
||||
history_search_next_keys: Vec<KeyBinding>,
|
||||
editor_keymap: EditorKeymap,
|
||||
footer_external_editor_key: Option<KeyBinding>,
|
||||
footer_show_transcript_key: Option<KeyBinding>,
|
||||
footer_insert_newline_key: Option<KeyBinding>,
|
||||
footer_queue_key: Option<KeyBinding>,
|
||||
footer_toggle_shortcuts_key: Option<KeyBinding>,
|
||||
footer_history_search_key: Option<KeyBinding>,
|
||||
footer_reasoning_down_key: Option<KeyBinding>,
|
||||
footer_reasoning_up_key: Option<KeyBinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -486,6 +505,8 @@ impl ChatComposer {
|
||||
config: ChatComposerConfig,
|
||||
) -> Self {
|
||||
let use_shift_enter_hint = enhanced_keys_supported;
|
||||
let default_keymap = RuntimeKeymap::defaults();
|
||||
let default_editor_keymap = default_keymap.editor.clone();
|
||||
|
||||
let mut this = Self {
|
||||
textarea: TextArea::new(),
|
||||
@@ -549,6 +570,32 @@ impl ChatComposer {
|
||||
side_conversation_context_label: None,
|
||||
active_agent_label: None,
|
||||
history_search: None,
|
||||
submit_keys: vec![key_hint::plain(KeyCode::Enter)],
|
||||
queue_keys: vec![key_hint::plain(KeyCode::Tab)],
|
||||
toggle_shortcuts_keys: vec![
|
||||
key_hint::plain(KeyCode::Char('?')),
|
||||
key_hint::shift(KeyCode::Char('?')),
|
||||
],
|
||||
history_search_previous_keys: default_keymap.composer.history_search_previous.clone(),
|
||||
history_search_next_keys: default_keymap.composer.history_search_next.clone(),
|
||||
editor_keymap: default_editor_keymap,
|
||||
footer_external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))),
|
||||
footer_show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))),
|
||||
footer_insert_newline_key: footer_insert_newline_key(
|
||||
&default_keymap.editor.insert_newline,
|
||||
use_shift_enter_hint,
|
||||
),
|
||||
footer_queue_key: Some(key_hint::plain(KeyCode::Tab)),
|
||||
footer_toggle_shortcuts_key: Some(key_hint::plain(KeyCode::Char('?'))),
|
||||
footer_history_search_key: primary_binding(
|
||||
&default_keymap.composer.history_search_previous,
|
||||
),
|
||||
footer_reasoning_down_key: primary_binding(
|
||||
&default_keymap.chat.decrease_reasoning_effort,
|
||||
),
|
||||
footer_reasoning_up_key: primary_binding(
|
||||
&default_keymap.chat.increase_reasoning_effort,
|
||||
),
|
||||
};
|
||||
// Apply configuration via the setter to keep side-effects centralized.
|
||||
this.set_disable_paste_burst(disable_paste_burst);
|
||||
@@ -626,6 +673,31 @@ impl ChatComposer {
|
||||
self.goal_command_enabled = enabled;
|
||||
}
|
||||
|
||||
/// Replace composer, editor, and footer-hint key bindings from one runtime snapshot.
|
||||
///
|
||||
/// Submit and queue bindings are cached here because composer dispatch must
|
||||
/// check them before generic textarea editing. The embedded textarea receives
|
||||
/// the same snapshot's editor bindings so a live remap cannot leave submit
|
||||
/// keys updated while cursor/editing keys still use old defaults.
|
||||
pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
|
||||
self.submit_keys = keymap.composer.submit.clone();
|
||||
self.queue_keys = keymap.composer.queue.clone();
|
||||
self.toggle_shortcuts_keys = keymap.composer.toggle_shortcuts.clone();
|
||||
self.history_search_previous_keys = keymap.composer.history_search_previous.clone();
|
||||
self.history_search_next_keys = keymap.composer.history_search_next.clone();
|
||||
self.editor_keymap = keymap.editor.clone();
|
||||
self.textarea.set_keymap_bindings(&self.editor_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.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);
|
||||
}
|
||||
|
||||
pub fn set_collaboration_mode_indicator(
|
||||
&mut self,
|
||||
indicator: Option<CollaborationModeIndicator>,
|
||||
@@ -1445,7 +1517,7 @@ impl ChatComposer {
|
||||
return self.handle_history_search_key(key_event);
|
||||
}
|
||||
|
||||
if Self::is_history_search_key(&key_event) {
|
||||
if Self::is_history_search_key(&key_event, &self.history_search_previous_keys) {
|
||||
return self.begin_history_search();
|
||||
}
|
||||
|
||||
@@ -1630,7 +1702,7 @@ impl ChatComposer {
|
||||
if self.disable_paste_burst {
|
||||
// When burst detection is disabled, treat IME/non-ASCII input as normal typing.
|
||||
// In particular, do not retro-capture or buffer already-inserted prefix text.
|
||||
self.textarea.input(input);
|
||||
self.textarea.input_with_keymap(input, &self.editor_keymap);
|
||||
let text_after = self.textarea.text();
|
||||
self.pending_pastes
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
@@ -1687,7 +1759,7 @@ impl ChatComposer {
|
||||
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
self.textarea.input(input);
|
||||
self.textarea.input_with_keymap(input, &self.editor_keymap);
|
||||
|
||||
let text_after = self.textarea.text();
|
||||
self.pending_pastes
|
||||
@@ -2870,6 +2942,16 @@ impl ChatComposer {
|
||||
} else {
|
||||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||||
}
|
||||
if self.queue_keys.is_pressed(key_event)
|
||||
&& (self.is_task_running || !self.is_bang_shell_command())
|
||||
{
|
||||
return self.handle_submission(self.is_task_running);
|
||||
}
|
||||
|
||||
if self.submit_keys.is_pressed(key_event) {
|
||||
return self.handle_submission(/*should_queue*/ false);
|
||||
}
|
||||
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
@@ -2910,19 +2992,6 @@ impl ChatComposer {
|
||||
}
|
||||
self.handle_input_basic(key_event)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Tab,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if self.is_task_running || !self.is_bang_shell_command() => {
|
||||
self.handle_submission(self.is_task_running)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.handle_submission(/*should_queue*/ false),
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
}
|
||||
@@ -3094,7 +3163,7 @@ impl ChatComposer {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
self.textarea.input(input);
|
||||
self.textarea.input_with_keymap(input, &self.editor_keymap);
|
||||
self.sync_bash_mode_from_text();
|
||||
|
||||
if let Some(elements_before) = elements_before {
|
||||
@@ -3170,13 +3239,18 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the dedicated shortcut-overlay toggle key(s).
|
||||
///
|
||||
/// This only toggles when the composer is empty and no paste burst is in
|
||||
/// progress, so typing/pasting `?` still inserts text instead of opening
|
||||
/// help. The bound key list intentionally supports terminal-variant
|
||||
/// modifier reporting (for example `?` vs `shift-?`).
|
||||
fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool {
|
||||
if key_event.kind != KeyEventKind::Press {
|
||||
return false;
|
||||
}
|
||||
|
||||
let toggles = matches!(key_event.code, KeyCode::Char('?'))
|
||||
&& !has_ctrl_or_alt(key_event.modifiers)
|
||||
let toggles = self.toggle_shortcuts_keys.is_pressed(*key_event)
|
||||
&& self.is_empty()
|
||||
&& !self.is_in_paste_burst();
|
||||
|
||||
@@ -3219,6 +3293,17 @@ impl ChatComposer {
|
||||
context_window_used_tokens: self.context_window_used_tokens,
|
||||
status_line_value: self.status_line_value.clone(),
|
||||
status_line_enabled: self.status_line_enabled,
|
||||
key_hints: FooterKeyHints {
|
||||
toggle_shortcuts: self.footer_toggle_shortcuts_key,
|
||||
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)),
|
||||
show_transcript: self.footer_show_transcript_key,
|
||||
history_search: self.footer_history_search_key,
|
||||
reasoning_down: self.footer_reasoning_down_key,
|
||||
reasoning_up: self.footer_reasoning_up_key,
|
||||
},
|
||||
active_agent_label: self.active_agent_label.clone(),
|
||||
}
|
||||
}
|
||||
@@ -3780,6 +3865,23 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_insert_newline_key(
|
||||
bindings: &[KeyBinding],
|
||||
enhanced_keys_supported: bool,
|
||||
) -> Option<KeyBinding> {
|
||||
let shift_enter = key_hint::shift(KeyCode::Enter);
|
||||
if enhanced_keys_supported && bindings.contains(&shift_enter) {
|
||||
return Some(shift_enter);
|
||||
}
|
||||
|
||||
let plain_enter = key_hint::plain(KeyCode::Enter);
|
||||
bindings
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|binding| *binding != plain_enter)
|
||||
.or_else(|| bindings.first().copied())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl ChatComposer {
|
||||
pub fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
|
||||
@@ -4048,6 +4150,7 @@ impl ChatComposer {
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
footer_props.key_hints,
|
||||
))
|
||||
}
|
||||
FooterMode::EscHint
|
||||
@@ -5248,6 +5351,30 @@ mod tests {
|
||||
assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_question_mark_toggles_shortcut_overlay_when_empty() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
/*has_input_focus*/ true,
|
||||
sender,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
let (result, needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT));
|
||||
assert_eq!(result, InputResult::None);
|
||||
assert!(needs_redraw, "toggling overlay should request redraw");
|
||||
assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay);
|
||||
}
|
||||
|
||||
/// Behavior: while a paste-like burst is being captured, `?` must not toggle the shortcut
|
||||
/// overlay; it should be treated as part of the pasted content.
|
||||
#[test]
|
||||
@@ -6929,6 +7056,96 @@ mod tests {
|
||||
assert_queued_slash("/does-not-exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remapped_submit_does_not_fall_back_to_enter() {
|
||||
use crate::key_hint;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
/*has_input_focus*/ true,
|
||||
sender,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
composer
|
||||
.textarea
|
||||
.set_text_clearing_elements("explain the change");
|
||||
composer.textarea.set_cursor(composer.textarea.text().len());
|
||||
let mut keymap = RuntimeKeymap::defaults();
|
||||
keymap.composer.submit = vec![key_hint::ctrl(KeyCode::Char('j'))];
|
||||
composer.set_keymap_bindings(&keymap);
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::None, result);
|
||||
assert_eq!("explain the change\n", composer.textarea.text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remapped_queue_does_not_fall_back_to_tab() {
|
||||
use crate::key_hint;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
/*has_input_focus*/ true,
|
||||
sender,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
composer.set_task_running(/*running*/ true);
|
||||
composer.textarea.set_text_clearing_elements("queue me");
|
||||
let mut keymap = RuntimeKeymap::defaults();
|
||||
keymap.composer.queue = vec![key_hint::ctrl(KeyCode::Char('q'))];
|
||||
composer.set_keymap_bindings(&keymap);
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::None, result);
|
||||
assert_eq!("queue me", composer.textarea.text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remapped_history_search_does_not_fall_back_to_ctrl_r() {
|
||||
use crate::key_hint;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
/*has_input_focus*/ true,
|
||||
sender,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
let mut keymap = RuntimeKeymap::defaults();
|
||||
keymap.composer.history_search_previous = vec![key_hint::plain(KeyCode::F(2))];
|
||||
composer.set_keymap_bindings(&keymap);
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
assert!(!composer.history_search_active());
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
|
||||
assert!(composer.history_search_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_queues_leading_space_slash_as_plain_text_while_task_running() {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -41,6 +41,8 @@ use super::ComposerDraft;
|
||||
use super::InputResult;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::key_hint::has_ctrl_or_alt;
|
||||
use crate::ui_consts::FOOTER_INDENT_COLS;
|
||||
|
||||
@@ -84,44 +86,12 @@ impl ChatComposer {
|
||||
/// some terminals emit. Callers should only use this before generic text handling; treating the
|
||||
/// raw control character as ordinary input would insert an invisible byte into the search query
|
||||
/// or composer draft.
|
||||
pub(super) fn is_history_search_key(key_event: &KeyEvent) -> bool {
|
||||
matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'r')
|
||||
) || matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('\u{0012}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}
|
||||
)
|
||||
pub(super) fn is_history_search_key(key_event: &KeyEvent, bindings: &[KeyBinding]) -> bool {
|
||||
bindings.is_pressed(*key_event)
|
||||
}
|
||||
|
||||
fn is_history_search_forward_key(key_event: &KeyEvent) -> bool {
|
||||
matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'s')
|
||||
) || matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('\u{0013}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}
|
||||
)
|
||||
fn is_history_search_forward_key(key_event: &KeyEvent, bindings: &[KeyBinding]) -> bool {
|
||||
bindings.is_pressed(*key_event)
|
||||
}
|
||||
|
||||
/// Opens footer-owned reverse history search without previewing history yet.
|
||||
@@ -166,12 +136,14 @@ impl ChatComposer {
|
||||
return (InputResult::None, false);
|
||||
}
|
||||
|
||||
if Self::is_history_search_key(&key_event) || matches!(key_event.code, KeyCode::Up) {
|
||||
if Self::is_history_search_key(&key_event, &self.history_search_previous_keys)
|
||||
|| matches!(key_event.code, KeyCode::Up)
|
||||
{
|
||||
let result = self.history_search_in_direction(HistorySearchDirection::Older);
|
||||
return (result, true);
|
||||
}
|
||||
|
||||
if Self::is_history_search_forward_key(&key_event)
|
||||
if Self::is_history_search_forward_key(&key_event, &self.history_search_next_keys)
|
||||
|| matches!(key_event.code, KeyCode::Down)
|
||||
{
|
||||
let result = self.history_search_in_direction(HistorySearchDirection::Newer);
|
||||
|
||||
@@ -78,6 +78,7 @@ pub(crate) struct FooterProps {
|
||||
pub(crate) context_window_used_tokens: Option<i64>,
|
||||
pub(crate) status_line_value: Option<Line<'static>>,
|
||||
pub(crate) status_line_enabled: bool,
|
||||
pub(crate) key_hints: FooterKeyHints,
|
||||
/// Active thread label shown when the footer is rendering contextual information instead of an
|
||||
/// instructional hint.
|
||||
///
|
||||
@@ -106,6 +107,36 @@ pub(crate) enum GoalStatusIndicator {
|
||||
const MODE_CYCLE_HINT: &str = "shift+tab to cycle";
|
||||
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>,
|
||||
}
|
||||
|
||||
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('.'))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CollaborationModeIndicator {
|
||||
fn label(self, show_cycle_hint: bool) -> String {
|
||||
let suffix = if show_cycle_hint {
|
||||
@@ -284,21 +315,28 @@ struct LeftSideState {
|
||||
fn left_side_line(
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
state: LeftSideState,
|
||||
key_hints: FooterKeyHints,
|
||||
) -> Line<'static> {
|
||||
let mut line = Line::from("");
|
||||
match state.hint {
|
||||
SummaryHintKind::None => {}
|
||||
SummaryHintKind::Shortcuts => {
|
||||
line.push_span(key_hint::plain(KeyCode::Char('?')));
|
||||
line.push_span(" for shortcuts".dim());
|
||||
if let Some(key) = key_hints.toggle_shortcuts {
|
||||
line.push_span(key);
|
||||
line.push_span(" for shortcuts".dim());
|
||||
}
|
||||
}
|
||||
SummaryHintKind::QueueMessage => {
|
||||
line.push_span(key_hint::plain(KeyCode::Tab));
|
||||
line.push_span(" to queue message".dim());
|
||||
if let Some(key) = key_hints.queue {
|
||||
line.push_span(key);
|
||||
line.push_span(" to queue message".dim());
|
||||
}
|
||||
}
|
||||
SummaryHintKind::QueueShort => {
|
||||
line.push_span(key_hint::plain(KeyCode::Tab));
|
||||
line.push_span(" to queue".dim());
|
||||
if let Some(key) = key_hints.queue {
|
||||
line.push_span(key);
|
||||
line.push_span(" to queue".dim());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -327,6 +365,7 @@ pub(crate) fn single_line_footer_layout(
|
||||
show_cycle_hint: bool,
|
||||
show_shortcuts_hint: bool,
|
||||
show_queue_hint: bool,
|
||||
key_hints: FooterKeyHints,
|
||||
) -> (SummaryLeft, bool) {
|
||||
let hint_kind = if show_queue_hint {
|
||||
SummaryHintKind::QueueMessage
|
||||
@@ -339,7 +378,7 @@ pub(crate) fn single_line_footer_layout(
|
||||
hint: hint_kind,
|
||||
show_cycle_hint,
|
||||
};
|
||||
let default_line = left_side_line(collaboration_mode_indicator, default_state);
|
||||
let default_line = left_side_line(collaboration_mode_indicator, default_state, key_hints);
|
||||
let default_width = default_line.width() as u16;
|
||||
if default_width > 0 && can_show_left_with_context(area, default_width, context_width) {
|
||||
return (SummaryLeft::Default, true);
|
||||
@@ -349,7 +388,7 @@ pub(crate) fn single_line_footer_layout(
|
||||
if state == default_state {
|
||||
default_line.clone()
|
||||
} else {
|
||||
left_side_line(collaboration_mode_indicator, state)
|
||||
left_side_line(collaboration_mode_indicator, state, key_hints)
|
||||
}
|
||||
};
|
||||
let state_width = |state: LeftSideState| -> u16 { state_line(state).width() as u16 };
|
||||
@@ -457,8 +496,12 @@ pub(crate) fn single_line_footer_layout(
|
||||
};
|
||||
// Compute the width without going through `state_line` so we do not
|
||||
// depend on `default_state` (which may still be a queue variant).
|
||||
let mode_only_width =
|
||||
left_side_line(Some(collaboration_mode_indicator), mode_only_state).width() as u16;
|
||||
let mode_only_width = left_side_line(
|
||||
Some(collaboration_mode_indicator),
|
||||
mode_only_state,
|
||||
key_hints,
|
||||
)
|
||||
.width() as u16;
|
||||
if !context_requires_cycle_hint
|
||||
&& can_show_left_with_context(area, mode_only_width, context_width)
|
||||
{
|
||||
@@ -466,6 +509,7 @@ pub(crate) fn single_line_footer_layout(
|
||||
SummaryLeft::Custom(left_side_line(
|
||||
Some(collaboration_mode_indicator),
|
||||
mode_only_state,
|
||||
key_hints,
|
||||
)),
|
||||
true, // show_context
|
||||
);
|
||||
@@ -475,6 +519,7 @@ pub(crate) fn single_line_footer_layout(
|
||||
SummaryLeft::Custom(left_side_line(
|
||||
Some(collaboration_mode_indicator),
|
||||
mode_only_state,
|
||||
key_hints,
|
||||
)),
|
||||
false, // show_context
|
||||
);
|
||||
@@ -637,6 +682,7 @@ fn footer_from_props_lines(
|
||||
show_shortcuts_hint: bool,
|
||||
show_queue_hint: bool,
|
||||
) -> Vec<Line<'static>> {
|
||||
let key_hints = props.key_hints;
|
||||
// Passive footer context can come from the configurable status line, the
|
||||
// active agent label, or both combined.
|
||||
if let Some(status_line) = passive_footer_status_line(props) {
|
||||
@@ -656,7 +702,11 @@ fn footer_from_props_lines(
|
||||
},
|
||||
show_cycle_hint,
|
||||
};
|
||||
vec![left_side_line(collaboration_mode_indicator, state)]
|
||||
vec![left_side_line(
|
||||
collaboration_mode_indicator,
|
||||
state,
|
||||
key_hints,
|
||||
)]
|
||||
}
|
||||
FooterMode::ShortcutOverlay => {
|
||||
let state = ShortcutsState {
|
||||
@@ -664,6 +714,7 @@ fn footer_from_props_lines(
|
||||
esc_backtrack_hint: props.esc_backtrack_hint,
|
||||
is_wsl: props.is_wsl,
|
||||
collaboration_modes_enabled: props.collaboration_modes_enabled,
|
||||
key_hints,
|
||||
};
|
||||
shortcut_overlay_lines(state)
|
||||
}
|
||||
@@ -679,7 +730,11 @@ fn footer_from_props_lines(
|
||||
},
|
||||
show_cycle_hint,
|
||||
};
|
||||
vec![left_side_line(collaboration_mode_indicator, state)]
|
||||
vec![left_side_line(
|
||||
collaboration_mode_indicator,
|
||||
state,
|
||||
key_hints,
|
||||
)]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -781,6 +836,7 @@ struct ShortcutsState {
|
||||
esc_backtrack_hint: bool,
|
||||
is_wsl: bool,
|
||||
collaboration_modes_enabled: bool,
|
||||
key_hints: FooterKeyHints,
|
||||
}
|
||||
|
||||
fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> {
|
||||
@@ -856,10 +912,15 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
|
||||
if change_mode.width() > 0 {
|
||||
ordered.push(change_mode);
|
||||
}
|
||||
ordered.push(Line::from(""));
|
||||
ordered.push(show_transcript);
|
||||
|
||||
build_columns(ordered)
|
||||
let mut lines = build_columns(ordered);
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
"customize shortcuts with ".into(),
|
||||
"/keymap".cyan(),
|
||||
]));
|
||||
lines
|
||||
}
|
||||
|
||||
fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
||||
@@ -987,8 +1048,23 @@ impl ShortcutDescriptor {
|
||||
}
|
||||
|
||||
fn overlay_entry(&self, state: ShortcutsState) -> Option<Line<'static>> {
|
||||
let binding = self.binding_for(state)?;
|
||||
let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]);
|
||||
let key = match self.id {
|
||||
ShortcutId::InsertNewline => state.key_hints.insert_newline,
|
||||
ShortcutId::QueueMessageTab => state.key_hints.queue,
|
||||
ShortcutId::ExternalEditor => state.key_hints.external_editor,
|
||||
ShortcutId::EditPrevious => state.key_hints.edit_previous,
|
||||
ShortcutId::ShowTranscript => state.key_hints.show_transcript,
|
||||
ShortcutId::HistorySearch => state.key_hints.history_search,
|
||||
ShortcutId::ReasoningDown => state.key_hints.reasoning_down,
|
||||
ShortcutId::ReasoningUp => state.key_hints.reasoning_up,
|
||||
ShortcutId::Commands
|
||||
| ShortcutId::ShellCommands
|
||||
| ShortcutId::FilePaths
|
||||
| ShortcutId::PasteImage
|
||||
| ShortcutId::Quit
|
||||
| ShortcutId::ChangeMode => self.binding_for(state).map(|binding| binding.key),
|
||||
}?;
|
||||
let mut line = Line::from(vec![self.prefix.into(), key.into()]);
|
||||
match self.id {
|
||||
ShortcutId::EditPrevious => {
|
||||
if state.esc_backtrack_hint {
|
||||
@@ -996,7 +1072,7 @@ impl ShortcutDescriptor {
|
||||
} else {
|
||||
line.extend(vec![
|
||||
" ".into(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
key.into(),
|
||||
" to edit previous message".into(),
|
||||
]);
|
||||
}
|
||||
@@ -1287,6 +1363,7 @@ mod tests {
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
props.key_hints,
|
||||
);
|
||||
match summary_left {
|
||||
SummaryLeft::Default => {
|
||||
@@ -1374,6 +1451,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1392,6 +1470,10 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints {
|
||||
insert_newline: Some(key_hint::shift(KeyCode::Enter)),
|
||||
..FooterKeyHints::default_bindings()
|
||||
},
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1410,6 +1492,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1428,6 +1511,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1446,6 +1530,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1464,6 +1549,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1482,6 +1568,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1500,6 +1587,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1518,6 +1606,7 @@ mod tests {
|
||||
context_window_used_tokens: Some(123_456),
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1536,6 +1625,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1552,6 +1642,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1581,6 +1672,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1603,6 +1695,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1620,6 +1713,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1637,6 +1731,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1654,6 +1749,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None, // command timed out / empty
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1676,6 +1772,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1698,6 +1795,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1723,6 +1821,7 @@ mod tests {
|
||||
"Status line content that should truncate before the mode indicator".to_string(),
|
||||
)),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1745,6 +1844,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: Some("Robie [explorer]".to_string()),
|
||||
};
|
||||
|
||||
@@ -1762,6 +1862,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: Some("Robie [explorer]".to_string()),
|
||||
};
|
||||
|
||||
@@ -1785,6 +1886,7 @@ mod tests {
|
||||
.to_string(),
|
||||
)),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1838,6 +1940,7 @@ mod tests {
|
||||
esc_backtrack_hint: false,
|
||||
is_wsl,
|
||||
collaboration_modes_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
})
|
||||
.expect("shortcut binding")
|
||||
.key;
|
||||
|
||||
@@ -16,6 +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::key_hint::KeyBinding;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::ListKeymap;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
|
||||
@@ -265,6 +267,7 @@ pub(crate) struct ListSelectionView {
|
||||
|
||||
/// Called when the picker is dismissed via Esc/Ctrl+C without selecting.
|
||||
on_cancel: OnCancelCallback,
|
||||
keymap: ListKeymap,
|
||||
}
|
||||
|
||||
impl ListSelectionView {
|
||||
@@ -275,7 +278,11 @@ impl ListSelectionView {
|
||||
/// When search is enabled, rows without `search_value` will disappear as
|
||||
/// soon as the query is non-empty, which can look like dropped data unless
|
||||
/// callers intentionally populate that field.
|
||||
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
|
||||
pub fn new(
|
||||
params: SelectionViewParams,
|
||||
app_event_tx: AppEventSender,
|
||||
keymap: ListKeymap,
|
||||
) -> Self {
|
||||
let mut header = params.header;
|
||||
if params.title.is_some() || params.subtitle.is_some() {
|
||||
let title = params.title.map(|title| Line::from(title.bold()));
|
||||
@@ -330,6 +337,7 @@ impl ListSelectionView {
|
||||
preserve_side_content_bg: params.preserve_side_content_bg,
|
||||
on_selection_changed: params.on_selection_changed,
|
||||
on_cancel: params.on_cancel,
|
||||
keymap,
|
||||
};
|
||||
s.apply_filter();
|
||||
if s.tabs_enabled() && !has_initial_selected_idx && s.state.selected_idx.is_none() {
|
||||
@@ -800,23 +808,25 @@ impl ListSelectionView {
|
||||
|
||||
impl BottomPaneView for ListSelectionView {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
// Some terminals (or configurations) send Control key chords as
|
||||
// C0 control characters without reporting the CONTROL modifier.
|
||||
// Handle fallbacks for Ctrl-P/N here so navigation works everywhere.
|
||||
let is_plain_text_char = matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('p'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
code: KeyCode::Char(ch),
|
||||
modifiers,
|
||||
..
|
||||
} if !ch.is_ascii_control()
|
||||
&& !modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !modifiers.contains(KeyModifiers::ALT)
|
||||
);
|
||||
let allow_plain_char_navigation = !self.is_searchable || !is_plain_text_char;
|
||||
|
||||
match key_event {
|
||||
_ if allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event) => {
|
||||
self.move_up()
|
||||
}
|
||||
_ if allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event) => {
|
||||
self.move_down()
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('\u{0010}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} /* ^P */ => self.move_up(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
..
|
||||
@@ -825,30 +835,6 @@ impl BottomPaneView for ListSelectionView {
|
||||
code: KeyCode::Right,
|
||||
..
|
||||
} if self.tabs_enabled() => self.switch_tab(/*step*/ 1),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('k'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} if !self.is_searchable => self.move_up(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('n'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('\u{000e}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} /* ^N */ => self.move_down(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('j'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} if !self.is_searchable => self.move_down(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
@@ -872,11 +858,14 @@ impl BottomPaneView for ListSelectionView {
|
||||
} if self.is_searchable
|
||||
&& self.search_query.is_empty()
|
||||
&& self.selected_item_has_toggle_placeholder() => {}
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
_ if self.keymap.cancel.is_pressed(key_event) => {
|
||||
self.on_ctrl_c();
|
||||
}
|
||||
_ if self.keymap.accept.is_pressed(key_event) => self.accept(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
..
|
||||
} if c.is_ascii_control() => {}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
@@ -914,11 +903,6 @@ impl BottomPaneView for ListSelectionView {
|
||||
self.accept();
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.accept(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -951,6 +935,10 @@ impl BottomPaneView for ListSelectionView {
|
||||
ListSelectionView::active_tab_id(self)
|
||||
}
|
||||
|
||||
fn prefer_esc_to_handle_key_event(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if let Some(cb) = &self.on_cancel {
|
||||
cb(&self.app_event_tx);
|
||||
@@ -1252,6 +1240,8 @@ mod tests {
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -1303,6 +1293,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn new_view(params: SelectionViewParams, tx: AppEventSender) -> ListSelectionView {
|
||||
ListSelectionView::new(params, tx, crate::keymap::RuntimeKeymap::defaults().list)
|
||||
}
|
||||
|
||||
fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
@@ -1322,7 +1316,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
ListSelectionView::new(
|
||||
new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
subtitle: subtitle.map(str::to_string),
|
||||
@@ -1402,6 +1396,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, width);
|
||||
@@ -1439,7 +1434,7 @@ mod tests {
|
||||
Some(&codex_home),
|
||||
Some(94),
|
||||
);
|
||||
let view = ListSelectionView::new(params, tx);
|
||||
let view = new_view(params, tx);
|
||||
|
||||
let rendered = render_lines_in_area(&view, /*width*/ 94, /*height*/ 35);
|
||||
assert!(rendered.contains("Move up/down to live preview themes"));
|
||||
@@ -1481,6 +1476,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
let area = Rect::new(0, 0, 120, 35);
|
||||
let mut buf = Buffer::empty(area);
|
||||
@@ -1517,7 +1513,7 @@ mod tests {
|
||||
"Use /setup-default-sandbox".cyan(),
|
||||
" to allow network access.".dim(),
|
||||
]);
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
footer_note: Some(footer_note),
|
||||
@@ -1544,7 +1540,7 @@ mod tests {
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}];
|
||||
let mut view = ListSelectionView::new(
|
||||
let mut view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
@@ -1597,6 +1593,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
view.set_search_query("beta".to_string());
|
||||
|
||||
@@ -1659,6 +1656,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
assert_eq!(view.active_tab_id(), Some("beta"));
|
||||
@@ -1690,6 +1688,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
view.set_search_query("plugin".to_string());
|
||||
|
||||
@@ -1729,6 +1728,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
let (wrapped_tx_raw, _wrapped_rx) = unbounded_channel::<AppEvent>();
|
||||
let wrapped_tx = AppEventSender::new(wrapped_tx_raw);
|
||||
@@ -1747,6 +1747,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
wrapped_tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let rendered = render_lines_with_width(&single_line_view, /*width*/ 36);
|
||||
@@ -1802,6 +1803,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
auto_tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
let (widened_tx_raw, _widened_rx) = unbounded_channel::<AppEvent>();
|
||||
let widened_tx = AppEventSender::new(widened_tx_raw);
|
||||
@@ -1814,6 +1816,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
widened_tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let auto_rendered = render_lines_with_width(&auto_view, /*width*/ 48);
|
||||
@@ -1845,6 +1848,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
view.set_search_query("no-matches".to_string());
|
||||
|
||||
@@ -1875,6 +1879,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
@@ -1921,6 +1926,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
assert_eq!(view.selected_actual_idx(), Some(1));
|
||||
@@ -1944,6 +1950,100 @@ mod tests {
|
||||
assert_eq!(view.take_last_selected_index(), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c0_ctrl_p_respects_unbound_list_move_up() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut keymap = crate::keymap::RuntimeKeymap::defaults().list;
|
||||
keymap.move_up.clear();
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "First".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Second".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
initial_selected_idx: Some(1),
|
||||
is_searchable: true,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
keymap,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(view.selected_actual_idx(), Some(1));
|
||||
assert_eq!(view.search_query, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c0_ctrl_n_respects_unbound_list_move_down() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut keymap = crate::keymap::RuntimeKeymap::defaults().list;
|
||||
keymap.move_down.clear();
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "First".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Second".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
is_searchable: true,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
keymap,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('\u{000e}'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(view.selected_actual_idx(), Some(0));
|
||||
assert_eq!(view.search_query, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c0_ctrl_p_respects_remapped_list_move_down() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut keymap = crate::keymap::RuntimeKeymap::defaults().list;
|
||||
keymap.move_up.clear();
|
||||
keymap.move_down = vec![crate::key_hint::ctrl(KeyCode::Char('p'))];
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "First".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Second".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
is_searchable: true,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
keymap,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(view.selected_actual_idx(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_long_option_without_overflowing_columns() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -1960,7 +2060,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Approval".to_string()),
|
||||
items,
|
||||
@@ -2018,7 +2118,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Model and Effort".to_string()),
|
||||
items,
|
||||
@@ -2052,7 +2152,7 @@ mod tests {
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items,
|
||||
@@ -2100,7 +2200,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Model and Effort".to_string()),
|
||||
items,
|
||||
@@ -2127,7 +2227,7 @@ mod tests {
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items,
|
||||
@@ -2178,6 +2278,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, /*width*/ 96);
|
||||
@@ -2212,6 +2313,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, width);
|
||||
@@ -2253,6 +2355,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let content_width: u16 = 120;
|
||||
@@ -2280,6 +2383,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
assert_eq!(view.side_layout_width(/*content_width*/ 80), None);
|
||||
@@ -2310,6 +2414,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let rendered = render_lines_with_width(&view, /*width*/ 70);
|
||||
@@ -2344,6 +2449,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let width = 120;
|
||||
@@ -2403,6 +2509,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let width = 120;
|
||||
|
||||
@@ -24,11 +24,12 @@ use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals;
|
||||
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::render::renderable::FlexRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableItem;
|
||||
use crate::tui::FrameRequester;
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
pub(crate) use bottom_pane_view::BottomPaneView;
|
||||
use bottom_pane_view::ViewCompletion;
|
||||
use codex_core_skills::model::SkillMetadata;
|
||||
use codex_features::Features;
|
||||
@@ -99,6 +100,8 @@ pub(crate) use footer::GoalStatusIndicator;
|
||||
#[cfg(test)]
|
||||
pub(crate) use footer::goal_status_indicator_line;
|
||||
pub(crate) use list_selection_view::ColumnWidthMode;
|
||||
#[cfg(test)]
|
||||
pub(crate) use list_selection_view::ListSelectionView;
|
||||
pub(crate) use list_selection_view::SelectionRowDisplay;
|
||||
pub(crate) use list_selection_view::SelectionToggle;
|
||||
pub(crate) use list_selection_view::SelectionViewParams;
|
||||
@@ -220,6 +223,7 @@ pub(crate) struct BottomPane {
|
||||
pending_thread_approvals: PendingThreadApprovals,
|
||||
context_window_percent: Option<i64>,
|
||||
context_window_used_tokens: Option<i64>,
|
||||
keymap: RuntimeKeymap,
|
||||
}
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
@@ -253,6 +257,8 @@ impl BottomPane {
|
||||
disable_paste_burst,
|
||||
);
|
||||
composer.set_frame_requester(frame_requester.clone());
|
||||
let keymap = RuntimeKeymap::defaults();
|
||||
composer.set_keymap_bindings(&keymap);
|
||||
composer.set_skill_mentions(skills);
|
||||
Self {
|
||||
composer,
|
||||
@@ -273,6 +279,7 @@ impl BottomPane {
|
||||
animations_enabled,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
keymap,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +327,19 @@ impl BottomPane {
|
||||
self.composer.record_pending_slash_command_history();
|
||||
}
|
||||
|
||||
/// Replace all bottom-pane keymap caches from one resolved runtime keymap.
|
||||
///
|
||||
/// The bottom pane owns several input surfaces: composer, overlays, and
|
||||
/// selection views. Applying one snapshot through this method keeps those
|
||||
/// surfaces synchronized after config reloads or interactive remaps. Callers
|
||||
/// should not update the composer directly unless they deliberately want
|
||||
/// overlays and selection views to continue using the previous bindings.
|
||||
pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
|
||||
self.keymap = keymap.clone();
|
||||
self.composer.set_keymap_bindings(keymap);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
|
||||
pub(crate) fn drain_pending_submission_state(&mut self) {
|
||||
let _ = self.take_recent_submission_images_with_placeholders();
|
||||
@@ -393,7 +413,7 @@ 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: KeyBinding) {
|
||||
pub(crate) fn set_queued_message_edit_binding(&mut self, binding: Option<KeyBinding>) {
|
||||
self.pending_input_preview.set_edit_binding(binding);
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -491,8 +511,13 @@ impl BottomPane {
|
||||
let Some(first) = self.delayed_approval_requests.pop_front() else {
|
||||
return;
|
||||
};
|
||||
let mut modal =
|
||||
ApprovalOverlay::new(first.request, self.app_event_tx.clone(), first.features);
|
||||
let mut modal = ApprovalOverlay::new(
|
||||
first.request,
|
||||
self.app_event_tx.clone(),
|
||||
first.features,
|
||||
self.keymap.approval.clone(),
|
||||
self.keymap.list.clone(),
|
||||
);
|
||||
while let Some(delayed) = self.delayed_approval_requests.pop_back() {
|
||||
modal.enqueue_request(delayed.request);
|
||||
}
|
||||
@@ -933,16 +958,32 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
/// Show a generic list selection view with the provided items.
|
||||
pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) {
|
||||
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
|
||||
pub(crate) fn show_selection_view(
|
||||
&mut self,
|
||||
mut params: list_selection_view::SelectionViewParams,
|
||||
) {
|
||||
self.apply_standard_popup_hint(&mut params);
|
||||
let view = list_selection_view::ListSelectionView::new(
|
||||
params,
|
||||
self.app_event_tx.clone(),
|
||||
self.keymap.list.clone(),
|
||||
);
|
||||
self.push_view(Box::new(view));
|
||||
}
|
||||
|
||||
fn apply_standard_popup_hint(&self, params: &mut list_selection_view::SelectionViewParams) {
|
||||
if params.footer_hint.is_none()
|
||||
|| params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line())
|
||||
{
|
||||
params.footer_hint = Some(self.standard_popup_hint_line());
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the active selection view when it matches `view_id`.
|
||||
pub(crate) fn replace_selection_view_if_active(
|
||||
&mut self,
|
||||
view_id: &'static str,
|
||||
params: list_selection_view::SelectionViewParams,
|
||||
mut params: list_selection_view::SelectionViewParams,
|
||||
) -> bool {
|
||||
let is_match = self
|
||||
.view_stack
|
||||
@@ -953,7 +994,50 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
self.view_stack.pop();
|
||||
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
|
||||
self.apply_standard_popup_hint(&mut params);
|
||||
let view = list_selection_view::ListSelectionView::new(
|
||||
params,
|
||||
self.app_event_tx.clone(),
|
||||
self.keymap.list.clone(),
|
||||
);
|
||||
self.push_view(Box::new(view));
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn standard_popup_hint_line(&self) -> Line<'static> {
|
||||
popup_consts::standard_popup_hint_line_for_keymap(&self.keymap.list)
|
||||
}
|
||||
|
||||
/// Replace one or more active views whose IDs are in `view_ids` with a
|
||||
/// generic list selection view.
|
||||
pub(crate) fn replace_active_views_with_selection_view(
|
||||
&mut self,
|
||||
view_ids: &[&'static str],
|
||||
mut params: list_selection_view::SelectionViewParams,
|
||||
) -> bool {
|
||||
let is_match = self
|
||||
.view_stack
|
||||
.last()
|
||||
.and_then(|view| view.view_id())
|
||||
.is_some_and(|view_id| view_ids.contains(&view_id));
|
||||
if !is_match {
|
||||
return false;
|
||||
}
|
||||
|
||||
while self
|
||||
.view_stack
|
||||
.last()
|
||||
.and_then(|view| view.view_id())
|
||||
.is_some_and(|view_id| view_ids.contains(&view_id))
|
||||
{
|
||||
self.view_stack.pop();
|
||||
}
|
||||
self.apply_standard_popup_hint(&mut params);
|
||||
let view = list_selection_view::ListSelectionView::new(
|
||||
params,
|
||||
self.app_event_tx.clone(),
|
||||
self.keymap.list.clone(),
|
||||
);
|
||||
self.push_view(Box::new(view));
|
||||
true
|
||||
}
|
||||
@@ -1031,11 +1115,15 @@ impl BottomPane {
|
||||
.is_some_and(bottom_pane_view::BottomPaneView::terminal_title_requires_action)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn has_active_view(&self) -> bool {
|
||||
!self.view_stack.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn active_view_id(&self) -> Option<&'static str> {
|
||||
self.view_stack.last().and_then(|view| view.view_id())
|
||||
}
|
||||
|
||||
/// Return true when the pane is in the regular composer state without any
|
||||
/// overlays or popups and not running a task. This is the safe context to
|
||||
/// use Esc-Esc for backtracking from the main view.
|
||||
@@ -1087,7 +1175,13 @@ impl BottomPane {
|
||||
self.maybe_show_delayed_approval_requests_at(now);
|
||||
} else {
|
||||
// No recent composer activity, so show the approval modal immediately.
|
||||
let modal = ApprovalOverlay::new(request, self.app_event_tx.clone(), features.clone());
|
||||
let modal = ApprovalOverlay::new(
|
||||
request,
|
||||
self.app_event_tx.clone(),
|
||||
features.clone(),
|
||||
self.keymap.approval.clone(),
|
||||
self.keymap.list.clone(),
|
||||
);
|
||||
self.pause_status_timer_for_modal();
|
||||
self.push_view(Box::new(modal));
|
||||
}
|
||||
@@ -2414,6 +2508,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_view_esc_respects_remapped_list_cancel() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = test_pane(tx);
|
||||
let mut keymap = RuntimeKeymap::defaults();
|
||||
keymap.list.cancel = vec![crate::key_hint::plain(KeyCode::Char('q'))];
|
||||
pane.set_keymap_bindings(&keymap);
|
||||
pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Agents".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Main".to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
on_cancel: Some(Box::new(|tx: &_| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert!(pane.active_view().is_some());
|
||||
assert!(rx.try_recv().is_err());
|
||||
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
|
||||
|
||||
assert!(pane.no_modal_or_popup_active());
|
||||
assert!(matches!(rx.try_recv(), Ok(AppEvent::OpenApprovalsPopup)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_routes_to_handle_key_event_when_requested() {
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -26,7 +26,7 @@ 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: key_hint::KeyBinding,
|
||||
edit_binding: Option<key_hint::KeyBinding>,
|
||||
}
|
||||
|
||||
const PREVIEW_LINE_LIMIT: usize = 3;
|
||||
@@ -37,14 +37,14 @@ impl PendingInputPreview {
|
||||
pending_steers: Vec::new(),
|
||||
rejected_steers: Vec::new(),
|
||||
queued_messages: Vec::new(),
|
||||
edit_binding: key_hint::alt(KeyCode::Up),
|
||||
edit_binding: Some(key_hint::alt(KeyCode::Up)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: key_hint::KeyBinding) {
|
||||
pub(crate) fn set_edit_binding(&mut self, binding: Option<key_hint::KeyBinding>) {
|
||||
self.edit_binding = binding;
|
||||
}
|
||||
|
||||
@@ -145,11 +145,13 @@ impl PendingInputPreview {
|
||||
}
|
||||
}
|
||||
|
||||
if !self.queued_messages.is_empty() {
|
||||
if !self.queued_messages.is_empty()
|
||||
&& let Some(edit_binding) = self.edit_binding
|
||||
{
|
||||
lines.push(
|
||||
Line::from(vec![
|
||||
" ".into(),
|
||||
self.edit_binding.into(),
|
||||
edit_binding.into(),
|
||||
" edit last queued message".into(),
|
||||
])
|
||||
.dim(),
|
||||
@@ -208,7 +210,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(key_hint::shift(KeyCode::Left));
|
||||
queue.set_edit_binding(Some(key_hint::shift(KeyCode::Left)));
|
||||
let width = 40;
|
||||
let height = queue.desired_height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Shared popup-related constants for bottom pane widgets.
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use ratatui::text::Line;
|
||||
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::keymap::ListKeymap;
|
||||
use crate::keymap::primary_binding;
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
/// Maximum number of rows any popup should attempt to display.
|
||||
/// Keep this consistent across all popups for a uniform feel.
|
||||
@@ -19,3 +22,40 @@ pub(crate) fn standard_popup_hint_line() -> Line<'static> {
|
||||
" to go back".into(),
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn standard_popup_hint_line_for_keymap(list_keymap: &ListKeymap) -> Line<'static> {
|
||||
accept_cancel_hint_line(
|
||||
primary_binding(&list_keymap.accept),
|
||||
"to confirm",
|
||||
primary_binding(&list_keymap.cancel),
|
||||
"to go back",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn accept_cancel_hint_line(
|
||||
accept: Option<KeyBinding>,
|
||||
accept_label: &'static str,
|
||||
cancel: Option<KeyBinding>,
|
||||
cancel_label: &'static str,
|
||||
) -> Line<'static> {
|
||||
match (accept, cancel) {
|
||||
(Some(accept), Some(cancel)) => Line::from(vec![
|
||||
"Press ".into(),
|
||||
accept.into(),
|
||||
format!(" {accept_label} or ").into(),
|
||||
cancel.into(),
|
||||
format!(" {cancel_label}").into(),
|
||||
]),
|
||||
(Some(accept), None) => Line::from(vec![
|
||||
"Press ".into(),
|
||||
accept.into(),
|
||||
format!(" {accept_label}").into(),
|
||||
]),
|
||||
(None, Some(cancel)) => Line::from(vec![
|
||||
"Press ".into(),
|
||||
cancel.into(),
|
||||
format!(" {cancel_label}").into(),
|
||||
]),
|
||||
(None, None) => Line::from(""),
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -12,6 +12,6 @@ expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))"
|
||||
› 1. Yes, grant these permissions for this turn (y)
|
||||
2. Yes, grant for this turn with strict auto review (r)
|
||||
3. Yes, grant these permissions for this session (a)
|
||||
4. No, continue without permissions (n)
|
||||
4. No, continue without permissions (d)
|
||||
|
||||
Press enter to confirm or esc to cancel
|
||||
|
||||
+3
-1
@@ -16,4 +16,6 @@ expression: terminal.backend()
|
||||
" ctrl + g to edit in external editor esc again to edit previous message "
|
||||
" ctrl + r search history ctrl + c to exit "
|
||||
" ⌥ + , reasoning down ⌥ + . reasoning up "
|
||||
" ctrl + t to view transcript "
|
||||
" ctrl + t to view transcript "
|
||||
" "
|
||||
" customize shortcuts with /keymap "
|
||||
|
||||
+3
-2
@@ -8,5 +8,6 @@ expression: terminal.backend()
|
||||
" 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 "
|
||||
" shift + tab to change mode "
|
||||
" ctrl + t to view transcript "
|
||||
" shift + tab to change mode ctrl + t to view transcript "
|
||||
" "
|
||||
" customize shortcuts with /keymap "
|
||||
|
||||
+3
-1
@@ -8,4 +8,6 @@ expression: terminal.backend()
|
||||
" ctrl + g to edit in external editor esc again to edit previous message "
|
||||
" ctrl + r search history ctrl + c to exit "
|
||||
" ⌥ + , reasoning down ⌥ + . reasoning up "
|
||||
" ctrl + t to view transcript "
|
||||
" ctrl + t to view transcript "
|
||||
" "
|
||||
" customize shortcuts with /keymap "
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
//! This module does not implement an Emacs-style multi-entry kill ring. It keeps only the most
|
||||
//! recent killed span.
|
||||
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::key_hint::is_altgr;
|
||||
use crate::keymap::EditorKeymap;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement as UserTextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -92,6 +95,7 @@ pub(crate) struct TextArea {
|
||||
elements: Vec<TextElement>,
|
||||
next_element_id: u64,
|
||||
kill_buffer: String,
|
||||
editor_keymap: EditorKeymap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -116,9 +120,20 @@ impl TextArea {
|
||||
elements: Vec::new(),
|
||||
next_element_id: 1,
|
||||
kill_buffer: String::new(),
|
||||
editor_keymap: RuntimeKeymap::defaults().editor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the editor keymap used by subsequent text-editing input.
|
||||
///
|
||||
/// This method intentionally swaps only the keymap cache. It does not
|
||||
/// reinterpret pending input, move the cursor, or mutate the kill buffer, so
|
||||
/// callers can safely apply a live config update while preserving the
|
||||
/// current draft exactly as typed.
|
||||
pub fn set_keymap_bindings(&mut self, keymap: &EditorKeymap) {
|
||||
self.editor_keymap = keymap.clone();
|
||||
}
|
||||
|
||||
/// Replace the visible textarea text and clear any existing text elements.
|
||||
///
|
||||
/// This is the "fresh buffer" path for callers that want plain text with no placeholder
|
||||
@@ -319,243 +334,123 @@ impl TextArea {
|
||||
if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
// Some terminals (or configurations) send Control key chords as
|
||||
// C0 control characters without reporting the CONTROL modifier.
|
||||
// Handle common fallbacks for Ctrl-B/F/P/N here so they don't get
|
||||
// inserted as literal control bytes.
|
||||
KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => {
|
||||
self.move_cursor_left();
|
||||
}
|
||||
KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => {
|
||||
self.move_cursor_right();
|
||||
}
|
||||
KeyEvent { code: KeyCode::Char('\u{0010}'), modifiers: KeyModifiers::NONE, .. } /* ^P */ => {
|
||||
self.move_cursor_up();
|
||||
}
|
||||
KeyEvent { code: KeyCode::Char('\u{000e}'), modifiers: KeyModifiers::NONE, .. } /* ^N */ => {
|
||||
self.move_cursor_down();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
// Insert plain characters (and Shift-modified). Do NOT insert when ALT is held,
|
||||
// because many terminals map Option/Meta combos to ALT+<char> (e.g. ESC f/ESC b)
|
||||
// for word navigation. Those are handled explicitly below.
|
||||
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
|
||||
..
|
||||
} => self.insert_str(&c.to_string()),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('j' | 'm'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
..
|
||||
} => self.insert_str("\n"),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('h'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||
self.delete_backward_word()
|
||||
},
|
||||
// Windows AltGr generates ALT|CONTROL; treat as a plain character input unless
|
||||
// we match a specific Control+Alt binding above.
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} if is_altgr(modifiers) => self.insert_str(&c.to_string()),
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => self.delete_backward_word(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('h'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => self.delete_backward(/*n*/ 1),
|
||||
KeyEvent {
|
||||
code: KeyCode::Delete,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => self.delete_forward_word(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Delete,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => self.delete_forward(/*n*/ 1),
|
||||
let keymap = self.editor_keymap.clone();
|
||||
self.input_with_keymap(event, &keymap);
|
||||
}
|
||||
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('w'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.delete_backward_word();
|
||||
}
|
||||
// Meta-b -> move to beginning of previous word
|
||||
// Meta-f -> move to end of next word
|
||||
// Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT).
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('b'),
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.beginning_of_previous_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('f'),
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.end_of_next_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('u'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.kill_to_beginning_of_line();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('k'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.kill_to_end_of_line();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('y'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.yank();
|
||||
}
|
||||
|
||||
// Cursor movement
|
||||
KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_left();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_right();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('b'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_left();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('f'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_right();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('p'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_up();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('n'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_down();
|
||||
}
|
||||
// Some terminals send Alt+Arrow for word-wise movement:
|
||||
// Option/Left -> Alt+Left (previous word start)
|
||||
// Option/Right -> Alt+Right (next word end)
|
||||
KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.beginning_of_previous_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.end_of_next_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
} => {
|
||||
self.move_cursor_up();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_down();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Home,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ false);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ true);
|
||||
}
|
||||
|
||||
KeyEvent {
|
||||
code: KeyCode::End, ..
|
||||
} => {
|
||||
self.move_cursor_to_end_of_line(/*move_down_at_eol*/ false);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('e'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_to_end_of_line(/*move_down_at_eol*/ true);
|
||||
}
|
||||
_ => {}
|
||||
pub fn input_with_keymap(&mut self, event: KeyEvent, keymap: &EditorKeymap) {
|
||||
if keymap.insert_newline.is_pressed(event) {
|
||||
self.insert_str("\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if keymap.delete_backward_word.is_pressed(event) {
|
||||
self.delete_backward_word();
|
||||
return;
|
||||
}
|
||||
|
||||
// Windows AltGr generates ALT|CONTROL. Preserve typed characters for AltGr users
|
||||
// unless a specific shortcut already matched above.
|
||||
if let KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} = event
|
||||
&& is_altgr(modifiers)
|
||||
{
|
||||
self.insert_str(&c.to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
if keymap.delete_backward.is_pressed(event) {
|
||||
self.delete_backward(/*n*/ 1);
|
||||
return;
|
||||
}
|
||||
if keymap.delete_forward_word.is_pressed(event) {
|
||||
self.delete_forward_word();
|
||||
return;
|
||||
}
|
||||
if keymap.delete_forward.is_pressed(event) {
|
||||
self.delete_forward(/*n*/ 1);
|
||||
return;
|
||||
}
|
||||
if keymap.kill_line_start.is_pressed(event) {
|
||||
self.kill_to_beginning_of_line();
|
||||
return;
|
||||
}
|
||||
if keymap.kill_line_end.is_pressed(event) {
|
||||
self.kill_to_end_of_line();
|
||||
return;
|
||||
}
|
||||
if keymap.yank.is_pressed(event) {
|
||||
self.yank();
|
||||
return;
|
||||
}
|
||||
if keymap.move_word_left.is_pressed(event) {
|
||||
self.set_cursor(self.beginning_of_previous_word());
|
||||
return;
|
||||
}
|
||||
if keymap.move_word_right.is_pressed(event) {
|
||||
self.set_cursor(self.end_of_next_word());
|
||||
return;
|
||||
}
|
||||
if keymap.move_left.is_pressed(event) {
|
||||
self.move_cursor_left();
|
||||
return;
|
||||
}
|
||||
if keymap.move_right.is_pressed(event) {
|
||||
self.move_cursor_right();
|
||||
return;
|
||||
}
|
||||
if keymap.move_up.is_pressed(event) {
|
||||
self.move_cursor_up();
|
||||
return;
|
||||
}
|
||||
if keymap.move_down.is_pressed(event) {
|
||||
self.move_cursor_down();
|
||||
return;
|
||||
}
|
||||
if keymap.move_line_start.is_pressed(event) {
|
||||
let move_up_at_bol = matches!(
|
||||
event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}
|
||||
);
|
||||
self.move_cursor_to_beginning_of_line(move_up_at_bol);
|
||||
return;
|
||||
}
|
||||
if keymap.move_line_end.is_pressed(event) {
|
||||
let move_down_at_eol = matches!(
|
||||
event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('e'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}
|
||||
);
|
||||
self.move_cursor_to_end_of_line(move_down_at_eol);
|
||||
return;
|
||||
}
|
||||
|
||||
if let KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
// Insert plain characters (and Shift-modified). Do not insert when ALT is held,
|
||||
// because many terminals map Option/Meta combos to ALT+<char>.
|
||||
if c.is_ascii_control() {
|
||||
return;
|
||||
}
|
||||
self.insert_str(&c.to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = event;
|
||||
}
|
||||
|
||||
// ####### Input Functions #######
|
||||
@@ -1941,6 +1836,37 @@ mod tests {
|
||||
assert_eq!(t.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c0_control_chars_respect_unbound_editor_movement() {
|
||||
let mut t = ta_with("a\nb");
|
||||
t.set_cursor(/*pos*/ 2);
|
||||
let mut keymap = RuntimeKeymap::defaults().editor;
|
||||
keymap.move_up.clear();
|
||||
|
||||
t.input_with_keymap(
|
||||
KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE),
|
||||
&keymap,
|
||||
);
|
||||
|
||||
assert_eq!(t.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c0_control_chars_respect_remapped_editor_movement() {
|
||||
let mut t = ta_with("a\nb");
|
||||
t.set_cursor(/*pos*/ 0);
|
||||
let mut keymap = RuntimeKeymap::defaults().editor;
|
||||
keymap.move_up.clear();
|
||||
keymap.move_down = vec![crate::key_hint::ctrl(KeyCode::Char('p'))];
|
||||
|
||||
t.input_with_keymap(
|
||||
KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE),
|
||||
&keymap,
|
||||
);
|
||||
|
||||
assert_eq!(t.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_backward_word_alt_keys() {
|
||||
// Test the custom Alt+Ctrl+h binding
|
||||
|
||||
Reference in New Issue
Block a user