From 1655c2e37f307f7fda7123b5cf7fc3a166e7c605 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Tue, 3 Feb 2026 23:36:08 -0800 Subject: [PATCH] feat(tui): wire runtime keymap into event handling --- AGENTS.md | 1 + codex-rs/tui/src/app.rs | 10 + codex-rs/tui/src/app/event_dispatch.rs | 5 + codex-rs/tui/src/app/input.rs | 116 +-- codex-rs/tui/src/app/test_support.rs | 3 + codex-rs/tui/src/app/tests.rs | 10 +- codex-rs/tui/src/app_backtrack.rs | 95 +-- .../tui/src/bottom_pane/approval_overlay.rs | 408 ++++++--- codex-rs/tui/src/bottom_pane/chat_composer.rs | 83 +- .../src/bottom_pane/list_selection_view.rs | 112 +-- codex-rs/tui/src/bottom_pane/mod.rs | 31 +- codex-rs/tui/src/bottom_pane/textarea.rs | 774 +++++++++++++----- codex-rs/tui/src/chatwidget.rs | 6 +- codex-rs/tui/src/onboarding/auth.rs | 207 +++-- .../onboarding/auth/headless_chatgpt_login.rs | 6 +- .../tui/src/onboarding/onboarding_screen.rs | 176 +++- .../tui/src/onboarding/trust_directory.rs | 36 +- codex-rs/tui/src/onboarding/welcome.rs | 50 +- codex-rs/tui/src/pager_overlay.rs | 268 +++--- 19 files changed, 1659 insertions(+), 738 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 23f0bab387..6e03345e01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ In the codex-rs folder where the rust code lives: - Avoid adding new standalone methods to `codex-rs/tui/src/chatwidget.rs` unless the change is trivial; prefer new modules/files and keep `chatwidget.rs` focused on orchestration. - When running Rust commands (e.g. `just fix` or `cargo test`) be patient with the command and never try to kill them using the PID. Rust lock can make the execution slow, this is expected. +- For TUI keybind changes, do not mutate old preset defaults; add a new preset version, keep `latest` as an explicit pointer (currently to `v1`), and update `docs/default-keymap.toml` plus `docs/config.md` with migration notes (`TODO(docs): mirror this on developers.openai.com`). Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests: diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8a90924412..29d7899120 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -41,6 +41,7 @@ use crate::history_cell; use crate::history_cell::HistoryCell; #[cfg(not(debug_assertions))] use crate::history_cell::UpdateAvailableHistoryCell; +use crate::keymap::RuntimeKeymap; use crate::legacy_core::append_message_history_entry; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; @@ -510,6 +511,7 @@ pub(crate) struct App { has_emitted_history_lines: bool, pub(crate) enhanced_keys_supported: bool, + pub(crate) keymap: RuntimeKeymap, /// Controls the animation thread that sends CommitTick events. pub(crate) commit_anim_running: Arc, @@ -873,6 +875,13 @@ impl App { .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); + let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).map_err(|err| { + color_eyre::eyre::eyre!( + "Invalid `tui.keymap` configuration: {err}\n\ +Fix the config and retry.\n\ +Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml" + ) + })?; #[cfg(not(debug_assertions))] let upgrade_version = crate::updates::get_upgrade_version(&config); @@ -889,6 +898,7 @@ impl App { runtime_sandbox_policy_override: None, file_search, enhanced_keys_supported, + keymap: runtime_keymap, transcript_cells: Vec::new(), overlay: None, deferred_history_lines: Vec::new(), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 2b9e3c91b4..b1c5a2824b 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -271,6 +271,7 @@ impl App { self.overlay = Some(Overlay::new_static_with_lines( pager_lines, "D I F F".to_string(), + self.keymap.pager.clone(), )); tui.frame_requester().schedule_frame(); } @@ -1494,6 +1495,7 @@ impl App { self.overlay = Some(Overlay::new_static_with_renderables( vec![diff_summary.into()], "P A T C H".to_string(), + self.keymap.pager.clone(), )); } ApprovalRequest::Exec { command, .. } => { @@ -1503,6 +1505,7 @@ impl App { self.overlay = Some(Overlay::new_static_with_lines( full_cmd_lines, "E X E C".to_string(), + self.keymap.pager.clone(), )); } ApprovalRequest::Permissions { @@ -1527,6 +1530,7 @@ impl App { self.overlay = Some(Overlay::new_static_with_renderables( vec![Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))], "P E R M I S S I O N S".to_string(), + self.keymap.pager.clone(), )); } ApprovalRequest::McpElicitation { @@ -1544,6 +1548,7 @@ impl App { self.overlay = Some(Overlay::new_static_with_renderables( vec![Box::new(paragraph)], "E L I C I T A T I O N".to_string(), + self.keymap.pager.clone(), )); } }, diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 36d4c5611b..a7015c4cf5 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -4,6 +4,7 @@ //! entry, Ctrl-L clear, external editor launch, and agent navigation shortcuts. use super::*; +use crate::key_hint::KeyBindingListExt; impl App { pub(super) async fn launch_external_editor(&mut self, tui: &mut tui::Tui) { @@ -122,18 +123,62 @@ impl App { return; } - match key_event { - KeyEvent { - code: KeyCode::Char('t'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - // Enter alternate screen and set viewport to full size. - let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); - tui.frame_requester().schedule_frame(); + if self.keymap.app.open_transcript.is_pressed(key_event) { + // Enter alternate screen and set viewport to full size. + let _ = tui.enter_alt_screen(); + self.overlay = Some(Overlay::new_transcript( + self.transcript_cells.clone(), + self.keymap.pager.clone(), + )); + tui.frame_requester().schedule_frame(); + return; + } + + if self.keymap.app.open_external_editor.is_pressed(key_event) { + // Only launch the external editor if there is no overlay and the + // bottom pane is not in use. Note that it can be launched while a + // task is running to enable editing while the previous turn is + // ongoing. + if self.overlay.is_none() + && self.chat_widget.can_launch_external_editor() + && self.chat_widget.external_editor_state() == ExternalEditorState::Closed + { + self.request_external_editor_launch(tui); } + return; + } + + if self.keymap.chat.edit_previous_message.is_pressed(key_event) { + // Esc primes/advances backtracking only in normal (not working) mode + // with the composer focused and empty. In any other state, forward + // Esc so the active UI (e.g. status indicator, modals, popups) + // handles it. + if self.chat_widget.is_normal_backtrack_mode() && self.chat_widget.composer_is_empty() { + self.handle_backtrack_esc_key(tui); + } else { + self.chat_widget.handle_key_event(key_event); + } + return; + } + + if key_event.kind == KeyEventKind::Press + && self + .keymap + .chat + .confirm_edit_previous_message + .is_pressed(key_event) + && self.backtrack.primed + && self.backtrack.nth_user_message != usize::MAX + && self.chat_widget.composer_is_empty() + { + // Confirm backtrack when primed + count > 0. + if let Some(selection) = self.confirm_backtrack_from_main() { + self.apply_backtrack_selection(tui, selection); + } + return; + } + + match key_event { KeyEvent { code: KeyCode::Char('l'), modifiers: KeyModifiers::CONTROL, @@ -153,51 +198,6 @@ impl App { tui.frame_requester().schedule_frame(); } } - KeyEvent { - code: KeyCode::Char('g'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - // Only launch the external editor if there is no overlay and the bottom pane is not in use. - // Note that it can be launched while a task is running to enable editing while the previous turn is ongoing. - if self.overlay.is_none() - && self.chat_widget.can_launch_external_editor() - && self.chat_widget.external_editor_state() == ExternalEditorState::Closed - { - self.request_external_editor_launch(tui); - } - } - // Esc primes/advances backtracking only in normal (not working) mode - // with the composer focused and empty. In any other state, forward - // Esc so the active UI (e.g. status indicator, modals, popups) - // handles it. - KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - if self.chat_widget.is_normal_backtrack_mode() - && self.chat_widget.composer_is_empty() - { - self.handle_backtrack_esc_key(tui); - } else { - self.chat_widget.handle_key_event(key_event); - } - } - // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. - KeyEvent { - code: KeyCode::Enter, - kind: KeyEventKind::Press, - .. - } if self.backtrack.primed - && self.backtrack.nth_user_message != usize::MAX - && self.chat_widget.composer_is_empty() => - { - if let Some(selection) = self.confirm_backtrack_from_main() { - self.apply_backtrack_selection(tui, selection); - } - } KeyEvent { kind: KeyEventKind::Press | KeyEventKind::Repeat, .. @@ -205,7 +205,9 @@ impl App { // Any non-Esc key press should cancel a primed backtrack. // This avoids stale "Esc-primed" state after the user starts typing // (even if they later backspace to empty). - if key_event.code != KeyCode::Esc && self.backtrack.primed { + if !self.keymap.chat.edit_previous_message.is_pressed(key_event) + && self.backtrack.primed + { self.reset_backtrack_state(); } self.chat_widget.handle_key_event(key_event); diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 4dc724ee5e..88a3f4079a 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -13,6 +13,8 @@ pub(super) async fn make_test_app() -> App { let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); + let keymap = RuntimeKeymap::from_config(&config.tui_keymap) + .expect("test config should always produce a valid runtime keymap"); App { model_catalog: chat_widget.model_catalog(), @@ -26,6 +28,7 @@ pub(super) async fn make_test_app() -> App { runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, file_search, + keymap, transcript_cells: Vec::new(), overlay: None, deferred_history_lines: Vec::new(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index bb6f8dbe66..d45c75bd7d 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4570,7 +4570,10 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() { /*is_first_line*/ false, )) as Arc, ]; - app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone())); + app.overlay = Some(Overlay::new_transcript( + app.transcript_cells.clone(), + app.keymap.pager.clone(), + )); app.deferred_history_lines = vec![Line::from("stale buffered line")]; app.backtrack.overlay_preview_active = true; app.backtrack.nth_user_message = 1; @@ -4796,7 +4799,10 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { local_image_paths: Vec::new(), remote_image_urls: Vec::new(), }) as Arc]; - app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone())); + app.overlay = Some(Overlay::new_transcript( + app.transcript_cells.clone(), + app.keymap.pager.clone(), + )); app.deferred_history_lines = vec![Line::from("stale buffered line")]; app.has_emitted_history_lines = true; app.backtrack.primed = true; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index cc99a791df..fc4f08103b 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -34,14 +34,13 @@ use crate::app_event::AppEvent; use crate::history_cell::AgentMessageCell; use crate::history_cell::SessionInfoCell; use crate::history_cell::UserHistoryCell; +use crate::key_hint::KeyBindingListExt; use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; use codex_protocol::ThreadId; use codex_protocol::user_input::TextElement; use color_eyre::eyre::Result; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; /// Aggregates all backtrack-related state used by the App. @@ -109,60 +108,51 @@ impl App { tui: &mut tui::Tui, event: TuiEvent, ) -> Result { - if self.backtrack.overlay_preview_active { - match event { - TuiEvent::Key(KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => { - self.overlay_step_backtrack(tui, event)?; - Ok(true) + if let TuiEvent::Key(key_event) = event { + let is_repeat = matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat); + if self.backtrack.overlay_preview_active && is_repeat { + if self + .keymap + .pager + .edit_previous_message + .is_pressed(key_event) + { + self.overlay_step_backtrack(tui, TuiEvent::Key(key_event))?; + return Ok(true); } - TuiEvent::Key(KeyEvent { - code: KeyCode::Left, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => { - self.overlay_step_backtrack(tui, event)?; - Ok(true) + if self.keymap.pager.edit_next_message.is_pressed(key_event) { + self.overlay_step_backtrack_forward(tui, TuiEvent::Key(key_event))?; + return Ok(true); } - TuiEvent::Key(KeyEvent { - code: KeyCode::Right, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => { - self.overlay_step_backtrack_forward(tui, event)?; - Ok(true) - } - TuiEvent::Key(KeyEvent { - code: KeyCode::Enter, - kind: KeyEventKind::Press, - .. - }) => { + if key_event.kind == KeyEventKind::Press + && self.keymap.pager.confirm_edit_message.is_pressed(key_event) + { self.overlay_confirm_backtrack(tui); - Ok(true) - } - // Catchall: forward any other events to the overlay widget. - _ => { - self.overlay_forward_event(tui, event)?; - Ok(true) + return Ok(true); } + } else if !self.backtrack.overlay_preview_active + && is_repeat + && self + .keymap + .pager + .edit_previous_message + .is_pressed(key_event) + { + // First press of the edit-previous key in transcript overlay: + // begin backtrack preview at latest user message. + self.begin_overlay_backtrack_preview(tui); + return Ok(true); } - } else if let TuiEvent::Key(KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) = event - { - // First Esc in transcript overlay: begin backtrack preview at latest user message. - self.begin_overlay_backtrack_preview(tui); - Ok(true) - } else { - // Not in backtrack mode: forward events to the overlay widget. - self.overlay_forward_event(tui, event)?; - Ok(true) } + + if self.backtrack.overlay_preview_active { + // In backtrack mode, any non-matching key is forwarded to overlay. + self.overlay_forward_event(tui, event)?; + return Ok(true); + } + // Not in backtrack mode: forward events to the overlay widget. + self.overlay_forward_event(tui, event)?; + Ok(true) } /// Handle global Esc presses for backtracking when no overlay is present. @@ -230,7 +220,10 @@ impl App { /// Open transcript overlay (enters alternate screen and shows full transcript). pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) { let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); + self.overlay = Some(Overlay::new_transcript( + self.transcript_cells.clone(), + self.keymap.pager.clone(), + )); tui.frame_requester().schedule_frame(); } diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 1f7e30c371..4219fc492b 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -1,3 +1,16 @@ +//! Approval modal rendering and decision routing for high-risk operations. +//! +//! This module converts agent approval requests (exec/apply-patch/MCP +//! elicitation) into a list-selection view with action-specific options and +//! shortcuts. It owns two important contracts: +//! +//! 1. Selection always emits an explicit decision event back to the app. +//! 2. MCP elicitation keeps `Esc` mapped to `Cancel`, even with custom +//! keybindings, so dismissal never silently becomes "continue without info". +//! +//! This module does not evaluate whether an action is safe to run; it only +//! presents choices and routes user decisions. + use std::collections::HashMap; use std::path::PathBuf; @@ -14,6 +27,9 @@ use crate::exec_command::strip_bash_lc_and_escape; use crate::history_cell; use crate::key_hint; use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::ApprovalKeymap; +use crate::keymap::ListKeymap; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; @@ -38,7 +54,6 @@ use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; -use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; @@ -143,19 +158,29 @@ pub(crate) struct ApprovalOverlay { current_complete: bool, done: bool, features: Features, + approval_keymap: ApprovalKeymap, + list_keymap: ListKeymap, } impl ApprovalOverlay { - pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender, features: Features) -> Self { + pub fn new( + request: ApprovalRequest, + app_event_tx: AppEventSender, + features: Features, + approval_keymap: ApprovalKeymap, + list_keymap: ListKeymap, + ) -> Self { let mut view = Self { current_request: None, queue: Vec::new(), app_event_tx: app_event_tx.clone(), - list: ListSelectionView::new(Default::default(), app_event_tx), + list: ListSelectionView::new(Default::default(), app_event_tx, list_keymap.clone()), options: Vec::new(), current_complete: false, done: false, features, + approval_keymap, + list_keymap, }; view.set_current(request); view @@ -185,16 +210,19 @@ impl ApprovalOverlay { fn set_current(&mut self, request: ApprovalRequest) { self.current_complete = false; let header = build_header(&request); - let (options, params) = Self::build_options(&request, header, &self.features); + let (options, params) = + Self::build_options(&request, header, &self.features, &self.approval_keymap); self.current_request = Some(request); self.options = options; - self.list = ListSelectionView::new(params, self.app_event_tx.clone()); + self.list = + ListSelectionView::new(params, self.app_event_tx.clone(), self.list_keymap.clone()); } fn build_options( request: &ApprovalRequest, header: Box, _features: &Features, + approval_keymap: &ApprovalKeymap, ) -> (Vec, SelectionViewParams) { let (options, title) = match request { ApprovalRequest::Exec { @@ -207,6 +235,7 @@ impl ApprovalOverlay { available_decisions, network_approval_context.as_ref(), additional_permissions.as_ref(), + approval_keymap, ), network_approval_context.as_ref().map_or_else( || "Would you like to run the following command?".to_string(), @@ -219,15 +248,15 @@ impl ApprovalOverlay { ), ), ApprovalRequest::Permissions { .. } => ( - permissions_options(), + permissions_options(approval_keymap), "Would you like to grant these permissions?".to_string(), ), ApprovalRequest::ApplyPatch { .. } => ( - patch_options(), + patch_options(approval_keymap), "Would you like to make the following edits?".to_string(), ), ApprovalRequest::McpElicitation { server_name, .. } => ( - elicitation_options(), + elicitation_options(approval_keymap), format!("{server_name} needs your approval."), ), }; @@ -242,9 +271,7 @@ impl ApprovalOverlay { .iter() .map(|opt| SelectionItem { name: opt.label.clone(), - display_shortcut: opt - .display_shortcut - .or_else(|| opt.additional_shortcuts.first().copied()), + display_shortcut: opt.shortcuts.first().copied(), dismiss_on_select: false, ..Default::default() }) @@ -411,51 +438,39 @@ impl ApprovalOverlay { } } + /// Apply approval-specific shortcuts before delegating to list navigation. + /// + /// `open_fullscreen` is handled here because it is orthogonal to list item + /// selection and should work regardless of current highlighted row. fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool { - match key_event { - KeyEvent { - kind: KeyEventKind::Press, - code: KeyCode::Char('a'), - modifiers, - .. - } if modifiers.contains(KeyModifiers::CONTROL) => { - if let Some(request) = self.current_request.as_ref() { - self.app_event_tx - .send(AppEvent::FullScreenApprovalRequest(request.clone())); - true - } else { - false - } - } - KeyEvent { - kind: KeyEventKind::Press, - code: KeyCode::Char('o'), - .. - } => { - if let Some(request) = self.current_request.as_ref() { - if request.thread_label().is_some() { - self.app_event_tx - .send(AppEvent::SelectAgentThread(request.thread_id())); - true - } else { - false - } - } else { - false - } - } - e => { - if let Some(idx) = self - .options - .iter() - .position(|opt| opt.shortcuts().any(|s| s.is_press(*e))) - { - self.apply_selection(idx); - true - } else { - false - } - } + if key_event.kind == KeyEventKind::Press + && self.approval_keymap.open_fullscreen.is_pressed(*key_event) + && let Some(request) = self.current_request.as_ref() + { + self.app_event_tx + .send(AppEvent::FullScreenApprovalRequest(request.clone())); + return true; + } + + if key_event.kind == KeyEventKind::Press + && matches!(key_event.code, KeyCode::Char('o')) + && let Some(request) = self.current_request.as_ref() + && request.thread_label().is_some() + { + self.app_event_tx + .send(AppEvent::SelectAgentThread(request.thread_id())); + return true; + } + + if let Some(idx) = self + .options + .iter() + .position(|opt| opt.shortcuts.iter().any(|s| s.is_press(*key_event))) + { + self.apply_selection(idx); + true + } else { + false } } } @@ -704,22 +719,14 @@ enum PermissionsDecision { struct ApprovalOption { label: String, decision: ApprovalDecision, - display_shortcut: Option, - additional_shortcuts: Vec, -} - -impl ApprovalOption { - fn shortcuts(&self) -> impl Iterator + '_ { - self.display_shortcut - .into_iter() - .chain(self.additional_shortcuts.iter().copied()) - } + shortcuts: Vec, } fn exec_options( available_decisions: &[ReviewDecision], network_approval_context: Option<&NetworkApprovalContext>, additional_permissions: Option<&PermissionProfile>, + keymap: &ApprovalKeymap, ) -> Vec { available_decisions .iter() @@ -731,8 +738,7 @@ fn exec_options( "Yes, proceed".to_string() }, decision: ApprovalDecision::Review(ReviewDecision::Approved), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + shortcuts: keymap.approve.clone(), }), ReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment, @@ -752,8 +758,7 @@ fn exec_options( proposed_execpolicy_amendment: proposed_execpolicy_amendment.clone(), }, ), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + shortcuts: keymap.approve_for_prefix.clone(), }) } ReviewDecision::ApprovedForSession => Some(ApprovalOption { @@ -765,20 +770,18 @@ fn exec_options( "Yes, and don't ask again for this command in this session".to_string() }, decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], + shortcuts: keymap.approve_for_session.clone(), }), ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => { - let (label, shortcut) = match network_policy_amendment.action { - NetworkPolicyRuleAction::Allow => ( - "Yes, and allow this host in the future".to_string(), - KeyCode::Char('p'), - ), + let (label, shortcuts) = match network_policy_amendment.action { + NetworkPolicyRuleAction::Allow => { + ("Yes, and allow this host in the future".to_string(), keymap.approve_for_prefix.clone()) + } NetworkPolicyRuleAction::Deny => ( "No, and block this host in the future".to_string(), - KeyCode::Char('d'), + Vec::new(), ), }; Some(ApprovalOption { @@ -786,22 +789,19 @@ fn exec_options( decision: ApprovalDecision::Review(ReviewDecision::NetworkPolicyAmendment { network_policy_amendment: network_policy_amendment.clone(), }), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(shortcut)], + shortcuts, }) } ReviewDecision::Denied => Some(ApprovalOption { label: "No, continue without running it".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Denied), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('d'))], + shortcuts: Vec::new(), }), ReviewDecision::TimedOut => None, ReviewDecision::Abort => Some(ApprovalOption { label: "No, and tell Codex what to do differently".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Abort), - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + shortcuts: keymap.decline.clone(), }), }) .collect() @@ -893,79 +893,90 @@ fn path_label(base: &str, subpath: &Option) -> String { } } -fn patch_options() -> Vec { +fn patch_options(keymap: &ApprovalKeymap) -> Vec { vec![ ApprovalOption { label: "Yes, proceed".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Approved), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + shortcuts: keymap.approve.clone(), }, ApprovalOption { label: "Yes, and don't ask again for these files".to_string(), decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], + shortcuts: keymap.approve_for_session.clone(), }, ApprovalOption { label: "No, and tell Codex what to do differently".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Abort), - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + shortcuts: keymap.decline.clone(), }, ] } -fn permissions_options() -> Vec { +fn permissions_options(keymap: &ApprovalKeymap) -> Vec { vec![ ApprovalOption { label: "Yes, grant these permissions for this turn".to_string(), decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForTurn), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + shortcuts: keymap.approve.clone(), }, ApprovalOption { label: "Yes, grant for this turn with strict auto review".to_string(), decision: ApprovalDecision::Permissions( PermissionsDecision::GrantForTurnWithStrictAutoReview, ), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('r'))], + shortcuts: vec![key_hint::plain(KeyCode::Char('r'))], }, ApprovalOption { label: "Yes, grant these permissions for this session".to_string(), decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForSession), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], + shortcuts: keymap.approve_for_session.clone(), }, ApprovalOption { label: "No, continue without permissions".to_string(), decision: ApprovalDecision::Permissions(PermissionsDecision::Deny), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + shortcuts: keymap.decline.clone(), }, ] } -fn elicitation_options() -> Vec { +/// Build MCP elicitation options with stable cancellation semantics. +/// +/// `Esc` is always treated as cancel for elicitation prompts, even if users +/// customize `decline`/`cancel` bindings. We keep this as a hard contract so +/// dismissal remains a safe abort path and never silently maps to "continue +/// without requested info." Any decline/cancel overlap is removed from the +/// decline option in elicitation mode to preserve this invariant. +fn elicitation_options(keymap: &ApprovalKeymap) -> Vec { + let mut cancel_shortcuts = vec![key_hint::plain(KeyCode::Esc)]; + for shortcut in &keymap.cancel { + if !cancel_shortcuts.contains(shortcut) { + cancel_shortcuts.push(*shortcut); + } + } + + let decline_shortcuts: Vec = keymap + .decline + .iter() + .copied() + .filter(|shortcut| !cancel_shortcuts.contains(shortcut)) + .collect(); + vec![ ApprovalOption { label: "Yes, provide the requested info".to_string(), decision: ApprovalDecision::McpElicitation(ElicitationAction::Accept), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + shortcuts: keymap.approve.clone(), }, ApprovalOption { label: "No, but continue without it".to_string(), decision: ApprovalDecision::McpElicitation(ElicitationAction::Decline), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + shortcuts: decline_shortcuts, }, ApprovalOption { label: "Cancel this request".to_string(), decision: ApprovalDecision::McpElicitation(ElicitationAction::Cancel), - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('c'))], + shortcuts: cancel_shortcuts, }, ] } @@ -983,6 +994,7 @@ mod tests { use codex_protocol::protocol::NetworkApprovalProtocol; use codex_protocol::protocol::NetworkPolicyAmendment; use codex_utils_absolute_path::AbsolutePathBuf; + use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; @@ -1018,6 +1030,37 @@ mod tests { }) } + fn make_overlay( + request: ApprovalRequest, + app_event_tx: AppEventSender, + features: Features, + ) -> ApprovalOverlay { + let keymap = crate::keymap::RuntimeKeymap::defaults(); + make_overlay_with_keymap( + request, + app_event_tx, + features, + keymap.approval, + keymap.list, + ) + } + + fn make_overlay_with_keymap( + request: ApprovalRequest, + app_event_tx: AppEventSender, + features: Features, + approval_keymap: ApprovalKeymap, + list_keymap: ListKeymap, + ) -> ApprovalOverlay { + ApprovalOverlay::new( + request, + app_event_tx, + features, + approval_keymap, + list_keymap, + ) + } + fn make_exec_request() -> ApprovalRequest { ApprovalRequest::Exec { thread_id: ThreadId::new(), @@ -1049,11 +1092,19 @@ mod tests { } } + fn make_elicitation_request() -> ApprovalRequest { + ApprovalRequest::McpElicitation { + server_name: "test-server".to_string(), + request_id: RequestId::String("request-1".to_string()), + message: "Need more information".to_string(), + } + } + #[test] fn ctrl_c_aborts_and_clears_queue() { let (tx, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); + let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults()); view.enqueue_request(make_exec_request()); assert_eq!(CancellationEvent::Handled, view.on_ctrl_c()); assert!(view.queue.is_empty()); @@ -1064,7 +1115,7 @@ mod tests { fn shortcut_triggers_selection() { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); + let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults()); assert!(!view.is_complete()); view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); // We expect at least one thread-scoped approval op message in the queue. @@ -1104,7 +1155,7 @@ mod tests { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); let thread_id = ThreadId::new(); - let mut view = ApprovalOverlay::new( + let mut view = make_overlay( ApprovalRequest::Exec { thread_id, thread_label: Some("Robie [explorer]".to_string()), @@ -1132,7 +1183,7 @@ mod tests { fn cross_thread_footer_hint_mentions_o_shortcut() { let (tx, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let view = ApprovalOverlay::new( + let view = make_overlay( ApprovalRequest::Exec { thread_id: ThreadId::new(), thread_label: Some("Robie [explorer]".to_string()), @@ -1157,7 +1208,7 @@ mod tests { fn exec_prefix_option_emits_execpolicy_amendment() { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new( + let mut view = make_overlay( ApprovalRequest::Exec { thread_id: ThreadId::new(), thread_label: None, @@ -1209,7 +1260,7 @@ mod tests { fn network_deny_forever_shortcut_is_not_bound() { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new( + let mut view = make_overlay( ApprovalRequest::Exec { thread_id: ThreadId::new(), thread_label: None, @@ -1260,12 +1311,9 @@ mod tests { additional_permissions: None, }; - let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); - let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(/*width*/ 80))); - view.render( - Rect::new(0, 0, 80, view.desired_height(/*width*/ 80)), - &mut buf, - ); + let view = make_overlay(exec_request, tx, Features::with_defaults()); + let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80))); + view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf); let rendered: Vec = (0..buf.area.height) .map(|row| { @@ -1288,6 +1336,7 @@ mod tests { host: "example.com".to_string(), protocol: NetworkApprovalProtocol::Https, }; + let keymap = crate::keymap::RuntimeKeymap::defaults(); let options = exec_options( &[ ReviewDecision::Approved, @@ -1302,6 +1351,7 @@ mod tests { ], Some(&network_context), /*additional_permissions*/ None, + &keymap.approval, ); let labels: Vec = options.into_iter().map(|option| option.label).collect(); @@ -1318,6 +1368,7 @@ mod tests { #[test] fn generic_exec_options_can_offer_allow_for_session() { + let keymap = crate::keymap::RuntimeKeymap::defaults(); let options = exec_options( &[ ReviewDecision::Approved, @@ -1326,6 +1377,7 @@ mod tests { ], /*network_approval_context*/ None, /*additional_permissions*/ None, + &keymap.approval, ); let labels: Vec = options.into_iter().map(|option| option.label).collect(); @@ -1341,6 +1393,7 @@ mod tests { #[test] fn additional_permissions_exec_options_hide_execpolicy_amendment() { + let keymap = crate::keymap::RuntimeKeymap::defaults(); let additional_permissions = PermissionProfile { file_system: Some(FileSystemPermissions::from_read_write_roots( Some(vec![absolute_path("/tmp/readme.txt")]), @@ -1352,6 +1405,7 @@ mod tests { &[ReviewDecision::Approved, ReviewDecision::Abort], /*network_approval_context*/ None, Some(&additional_permissions), + &keymap.approval, ); let labels: Vec = options.into_iter().map(|option| option.label).collect(); @@ -1366,7 +1420,8 @@ mod tests { #[test] fn permissions_options_use_expected_labels() { - let labels: Vec = permissions_options() + let keymap = crate::keymap::RuntimeKeymap::defaults(); + let labels: Vec = permissions_options(&keymap.approval) .into_iter() .map(|option| option.label) .collect(); @@ -1414,8 +1469,7 @@ mod tests { fn permissions_session_shortcut_submits_session_scope() { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = - ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults()); + let mut view = make_overlay(make_permissions_request(), tx, Features::with_defaults()); view.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); @@ -1488,12 +1542,9 @@ mod tests { }), }; - let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); - let mut buf = Buffer::empty(Rect::new(0, 0, 120, view.desired_height(/*width*/ 120))); - view.render( - Rect::new(0, 0, 120, view.desired_height(/*width*/ 120)), - &mut buf, - ); + let view = make_overlay(exec_request, tx, Features::with_defaults()); + let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(100))); + view.render(Rect::new(0, 0, 100, view.desired_height(100)), &mut buf); let rendered: Vec = (0..buf.area.height) .map(|row| { @@ -1538,7 +1589,7 @@ mod tests { }), }; - let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); + let view = make_overlay(exec_request, tx, Features::with_defaults()); assert_snapshot!( "approval_overlay_additional_permissions_prompt", normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120)) @@ -1549,7 +1600,7 @@ mod tests { fn permissions_prompt_snapshot() { let (tx, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let view = ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults()); + let view = make_overlay(make_permissions_request(), tx, Features::with_defaults()); assert_snapshot!( "approval_overlay_permissions_prompt", normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120)) @@ -1584,7 +1635,7 @@ mod tests { additional_permissions: None, }; - let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); + let view = make_overlay(exec_request, tx, Features::with_defaults()); let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(/*width*/ 100))); view.render( Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)), @@ -1616,6 +1667,27 @@ mod tests { ); } + #[test] + fn ctrl_shift_a_opens_fullscreen() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults()); + + view.handle_key_event(KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )); + + let mut saw_fullscreen = false; + while let Ok(ev) = rx.try_recv() { + if matches!(ev, AppEvent::FullScreenApprovalRequest(_)) { + saw_fullscreen = true; + break; + } + } + assert!(saw_fullscreen, "expected ctrl+shift+a to open fullscreen"); + } + #[test] fn exec_history_cell_wraps_with_two_space_indent() { let command = vec![ @@ -1647,11 +1719,85 @@ mod tests { assert_eq!(rendered, expected); } + #[test] + fn esc_cancels_mcp_elicitation() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = make_overlay(make_elicitation_request(), tx, Features::with_defaults()); + + view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let mut decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::CodexOp(Op::ResolveElicitation { decision: d, .. }) = ev { + decision = Some(d); + break; + } + } + assert_eq!(decision, Some(ElicitationAction::Cancel)); + } + + #[test] + fn esc_still_cancels_elicitation_with_custom_overlap() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.approval.decline = vec![ + key_hint::plain(KeyCode::Esc), + key_hint::plain(KeyCode::Char('n')), + ]; + keymap.approval.cancel = vec![key_hint::plain(KeyCode::Char('x'))]; + + let mut view = make_overlay_with_keymap( + make_elicitation_request(), + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let mut esc_decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::CodexOp(Op::ResolveElicitation { decision, .. }) = ev { + esc_decision = Some(decision); + break; + } + } + assert_eq!(esc_decision, Some(ElicitationAction::Cancel)); + + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.approval.decline = vec![ + key_hint::plain(KeyCode::Esc), + key_hint::plain(KeyCode::Char('n')), + ]; + keymap.approval.cancel = vec![key_hint::plain(KeyCode::Char('x'))]; + + let mut view = make_overlay_with_keymap( + make_elicitation_request(), + tx, + Features::with_defaults(), + keymap.approval, + keymap.list, + ); + view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + let mut n_decision = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::CodexOp(Op::ResolveElicitation { decision, .. }) = ev { + n_decision = Some(decision); + break; + } + } + assert_eq!(n_decision, Some(ElicitationAction::Decline)); + } + #[test] fn enter_sets_last_selected_index_without_dismissing() { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); + let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults()); view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!( diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index a529d169c4..e389aa420d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -183,6 +183,13 @@ 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::bottom_pane::prompt_args::prompt_argument_names; +use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders; +use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; +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; @@ -386,6 +393,13 @@ pub(crate) struct ChatComposer { // Agent label injected into the footer's contextual row when multi-agent mode is active. active_agent_label: Option, history_search: Option, + submit_keys: Vec, + queue_keys: Vec, + toggle_shortcuts_keys: Vec, + editor_keymap: EditorKeymap, + footer_external_editor_key: Option, + footer_edit_previous_key: Option, + footer_show_transcript_key: Option, } #[derive(Clone, Debug)] @@ -533,6 +547,16 @@ 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('?')), + ], + editor_keymap: RuntimeKeymap::defaults().editor, + footer_external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))), + footer_edit_previous_key: Some(key_hint::plain(KeyCode::Esc)), + footer_show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))), }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -606,6 +630,17 @@ impl ChatComposer { self.fast_command_enabled = enabled; } + 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.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_edit_previous_key = primary_binding(&keymap.chat.edit_previous_message); + self.footer_show_transcript_key = primary_binding(&keymap.app.open_transcript); + } + pub fn set_collaboration_mode_indicator( &mut self, indicator: Option, @@ -1606,7 +1641,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)); @@ -1663,7 +1698,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 @@ -2846,6 +2881,15 @@ 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 { + return self.handle_submission(true); + } + + if self.submit_keys.is_pressed(key_event) { + let should_queue = !self.steer_enabled; + return self.handle_submission(should_queue); + } + match key_event { KeyEvent { code: KeyCode::Char('d'), @@ -3070,7 +3114,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 { @@ -3146,13 +3190,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(); @@ -5219,6 +5268,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::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + 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] diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index c4e7e1c706..92b223010f 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -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() { @@ -746,23 +754,46 @@ impl ListSelectionView { impl BottomPaneView for ListSelectionView { fn handle_key_event(&mut self, key_event: KeyEvent) { + let is_plain_text_char = matches!( + key_event, + KeyEvent { + code: KeyCode::Char(_), + modifiers, + .. + } if !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) + ); + let allow_plain_char_navigation = !self.is_searchable || !is_plain_text_char; + let c0_ctrl_p = matches!( + key_event, + KeyEvent { + code: KeyCode::Char('\u{0010}'), + modifiers: KeyModifiers::NONE, + .. + } + ); + let c0_ctrl_n = matches!( + key_event, + KeyEvent { + code: KeyCode::Char('\u{000e}'), + modifiers: KeyModifiers::NONE, + .. + } + ); + 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. - KeyEvent { - code: KeyCode::Up, .. + _ if c0_ctrl_p + || (allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event)) => + { + self.move_up() } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. + _ if c0_ctrl_n + || (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, .. @@ -771,30 +802,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, .. @@ -818,9 +825,7 @@ 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(); } KeyEvent { @@ -866,11 +871,7 @@ impl BottomPaneView for ListSelectionView { self.accept(); } } - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => self.accept(), + _ if self.keymap.accept.is_pressed(key_event) => self.accept(), _ => {} } } @@ -1255,6 +1256,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::(); let tx = AppEventSender::new(tx_raw); @@ -1274,7 +1279,7 @@ mod tests { ..Default::default() }, ]; - ListSelectionView::new( + new_view( SelectionViewParams { title: Some("Select Approval Mode".to_string()), subtitle: subtitle.map(str::to_string), @@ -1354,6 +1359,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let before_scroll = render_lines_with_width(&view, width); @@ -1469,7 +1475,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), @@ -1496,7 +1502,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()), @@ -1855,7 +1861,7 @@ mod tests { ..Default::default() }, ]; - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Approval".to_string()), items, @@ -1913,7 +1919,7 @@ mod tests { ..Default::default() }, ]; - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Select Model and Effort".to_string()), items, @@ -1947,7 +1953,7 @@ mod tests { ..Default::default() }) .collect(); - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Debug".to_string()), items, @@ -1995,7 +2001,7 @@ mod tests { ..Default::default() }, ]; - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Select Model and Effort".to_string()), items, @@ -2022,7 +2028,7 @@ mod tests { ..Default::default() }) .collect(); - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Debug".to_string()), items, @@ -2073,6 +2079,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let before_scroll = render_lines_with_width(&view, /*width*/ 96); @@ -2107,6 +2114,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let before_scroll = render_lines_with_width(&view, width); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c9dc4db263..cb2e7792fe 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -23,6 +23,7 @@ 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; @@ -203,6 +204,7 @@ pub(crate) struct BottomPane { pending_thread_approvals: PendingThreadApprovals, context_window_percent: Option, context_window_used_tokens: Option, + keymap: RuntimeKeymap, } pub(crate) struct BottomPaneParams { @@ -236,6 +238,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, @@ -254,6 +258,7 @@ impl BottomPane { animations_enabled, context_window_percent: None, context_window_used_tokens: None, + keymap, } } @@ -301,6 +306,12 @@ impl BottomPane { self.composer.record_pending_slash_command_history(); } + 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(); @@ -840,7 +851,11 @@ 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()); + let view = list_selection_view::ListSelectionView::new( + params, + self.app_event_tx.clone(), + self.keymap.list.clone(), + ); self.push_view(Box::new(view)); } @@ -859,7 +874,11 @@ impl BottomPane { } self.view_stack.pop(); - let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone()); + let view = list_selection_view::ListSelectionView::new( + params, + self.app_event_tx.clone(), + self.keymap.list.clone(), + ); self.push_view(Box::new(view)); true } @@ -977,7 +996,13 @@ impl BottomPane { }; // Otherwise create a new approval modal overlay. - 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)); } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 2af4b3145f..b500e35050 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -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,10 @@ pub(crate) struct TextArea { elements: Vec, next_element_id: u64, kill_buffer: String, + vim_enabled: bool, + vim_mode: VimMode, + vim_operator: Option, + editor_keymap: EditorKeymap, } #[derive(Debug, Clone)] @@ -106,6 +113,31 @@ pub(crate) struct TextAreaState { scroll: u16, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimMode { + Normal, + Insert, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimOperator { + Delete, + Yank, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimMotion { + Left, + Right, + Up, + Down, + WordForward, + WordBackward, + WordEnd, + LineStart, + LineEnd, +} + impl TextArea { pub fn new() -> Self { Self { @@ -116,9 +148,17 @@ impl TextArea { elements: Vec::new(), next_element_id: 1, kill_buffer: String::new(), + vim_enabled: false, + vim_mode: VimMode::Insert, + vim_operator: None, + editor_keymap: RuntimeKeymap::defaults().editor, } } + 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 @@ -170,6 +210,38 @@ impl TextArea { self.preferred_col = None; } + pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { + self.vim_enabled = enabled; + self.vim_operator = None; + self.vim_mode = if enabled { + VimMode::Normal + } else { + VimMode::Insert + }; + } + + pub(crate) fn is_vim_enabled(&self) -> bool { + self.vim_enabled + } + + pub(crate) fn allows_paste_burst(&self) -> bool { + !self.vim_enabled || self.vim_mode == VimMode::Insert + } + + pub(crate) fn is_vim_insert(&self) -> bool { + self.vim_enabled && self.vim_mode == VimMode::Insert + } + + pub(crate) fn vim_mode_label(&self) -> Option<&'static str> { + if !self.vim_enabled { + return None; + } + Some(match self.vim_mode { + VimMode::Normal => "Normal", + VimMode::Insert => "Insert", + }) + } + pub fn text(&self) -> &str { &self.text } @@ -319,243 +391,464 @@ impl TextArea { if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return; } + if self.vim_enabled { + self.handle_vim_input(event); + } else { + let keymap = self.editor_keymap.clone(); + self.input_with_keymap(event, &keymap); + } + } + + pub fn input_with_keymap(&mut self, event: KeyEvent, keymap: &EditorKeymap) { 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(); - } + // Keep these as compatibility fallbacks. 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+ (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), - - 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, + code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. - } => { + } /* ^B */ => { self.move_cursor_left(); + return; } KeyEvent { - code: KeyCode::Right, + code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. - } => { + } /* ^F */ => { self.move_cursor_right(); + return; } KeyEvent { - code: KeyCode::Char('b'), - modifiers: KeyModifiers::CONTROL, + code: KeyCode::Char('\u{0010}'), + modifiers: KeyModifiers::NONE, .. - } => { - self.move_cursor_left(); - } - KeyEvent { - code: KeyCode::Char('f'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_right(); - } - KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } => { + } /* ^P */ => { self.move_cursor_up(); + return; } KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, + code: KeyCode::Char('\u{000e}'), + modifiers: KeyModifiers::NONE, .. - } => { + } /* ^N */ => { 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); + return; } _ => {} } + + 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(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(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+. + self.insert_str(&c.to_string()); + return; + } + + #[cfg(feature = "debug-logs")] + tracing::debug!("Unhandled key event in TextArea: {:?}", event); + } + + fn handle_vim_input(&mut self, event: KeyEvent) { + match self.vim_mode { + VimMode::Insert => self.handle_vim_insert(event), + VimMode::Normal => self.handle_vim_normal(event), + } + } + + fn handle_vim_insert(&mut self, event: KeyEvent) { + if matches!(event.code, KeyCode::Esc) { + self.vim_mode = VimMode::Normal; + self.vim_operator = None; + self.preferred_col = None; + return; + } + let keymap = self.editor_keymap.clone(); + self.input_with_keymap(event, &keymap); + } + + fn handle_vim_normal(&mut self, event: KeyEvent) { + if let Some(op) = self.vim_operator.take() + && self.handle_vim_operator(op, event) + { + return; + } + + match event { + KeyEvent { + code: KeyCode::Char('i'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::NONE, + .. + } => { + let next = self.next_atomic_boundary(self.cursor_pos); + self.set_cursor(next); + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('A'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + self.set_cursor(self.end_of_current_line()); + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('I'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + self.set_cursor(self.beginning_of_current_line()); + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::NONE, + .. + } => { + let eol = self.end_of_current_line(); + let insert_at = if eol < self.text.len() { eol + 1 } else { eol }; + self.insert_str_at(insert_at, "\n"); + self.set_cursor(insert_at + 1); + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('O'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + let bol = self.beginning_of_current_line(); + self.insert_str_at(bol, "\n"); + self.set_cursor(bol); + self.vim_mode = VimMode::Insert; + } + KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_cursor_left(), + KeyEvent { + code: KeyCode::Char('l'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_cursor_right(), + KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_cursor_down(), + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_cursor_up(), + KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::NONE, + .. + } => self.set_cursor(self.beginning_of_next_word()), + KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::NONE, + .. + } => self.set_cursor(self.beginning_of_previous_word()), + KeyEvent { + code: KeyCode::Char('e'), + modifiers: KeyModifiers::NONE, + .. + } => self.set_cursor(self.end_of_next_word()), + KeyEvent { + code: KeyCode::Char('0'), + modifiers: KeyModifiers::NONE, + .. + } => self.set_cursor(self.beginning_of_current_line()), + KeyEvent { + code: KeyCode::Char('$'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + self.set_cursor(self.end_of_current_line()); + } + KeyEvent { + code: KeyCode::Char('x'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.delete_forward_kill(1); + } + KeyEvent { + code: KeyCode::Char('D'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + self.kill_to_end_of_line(); + } + KeyEvent { + code: KeyCode::Char('Y'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + self.yank_current_line(); + } + KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::NONE, + .. + } => self.paste_after_cursor(), + KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.vim_operator = Some(VimOperator::Delete); + } + KeyEvent { + code: KeyCode::Char('y'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.vim_operator = Some(VimOperator::Yank); + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.vim_operator = None; + } + _ => {} + } + } + + fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool { + match event { + KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::NONE, + .. + } if op == VimOperator::Delete => { + self.delete_current_line(); + true + } + KeyEvent { + code: KeyCode::Char('y'), + modifiers: KeyModifiers::NONE, + .. + } if op == VimOperator::Yank => { + self.yank_current_line(); + true + } + _ => { + if let Some(motion) = self.vim_motion_for_event(event) { + self.apply_vim_operator(op, motion); + return true; + } + false + } + } + } + + fn vim_motion_for_event(&self, event: KeyEvent) -> Option { + match event { + KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::Left), + KeyEvent { + code: KeyCode::Char('l'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::Right), + KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::Down), + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::Up), + KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::WordForward), + KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::WordBackward), + KeyEvent { + code: KeyCode::Char('e'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::WordEnd), + KeyEvent { + code: KeyCode::Char('0'), + modifiers: KeyModifiers::NONE, + .. + } => Some(VimMotion::LineStart), + KeyEvent { + code: KeyCode::Char('$'), + modifiers, + .. + } if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => { + Some(VimMotion::LineEnd) + } + _ => None, + } + } + + fn apply_vim_operator(&mut self, op: VimOperator, motion: VimMotion) { + let Some(range) = self.range_for_motion(motion) else { + return; + }; + match op { + VimOperator::Delete => self.kill_range(range), + VimOperator::Yank => self.yank_range(range), + } + } + + fn range_for_motion(&mut self, motion: VimMotion) -> Option> { + let start = self.cursor_pos; + let target = self.target_for_motion(motion); + if start == target { + return None; + } + let (range_start, range_end) = if target < start { + (target, start) + } else { + (start, target) + }; + Some(range_start..range_end) + } + + fn target_for_motion(&mut self, motion: VimMotion) -> usize { + let original_cursor = self.cursor_pos; + let original_preferred = self.preferred_col; + match motion { + VimMotion::Left => self.move_cursor_left(), + VimMotion::Right => self.move_cursor_right(), + VimMotion::Up => self.move_cursor_up(), + VimMotion::Down => self.move_cursor_down(), + VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()), + VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()), + VimMotion::WordEnd => self.set_cursor(self.end_of_next_word()), + VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()), + VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()), + } + let target = self.cursor_pos; + self.cursor_pos = original_cursor; + self.preferred_col = original_preferred; + target } // ####### Input Functions ####### @@ -587,6 +880,20 @@ impl TextArea { self.replace_range(self.cursor_pos..target, ""); } + pub fn delete_forward_kill(&mut self, n: usize) { + if n == 0 || self.cursor_pos >= self.text.len() { + return; + } + let mut target = self.cursor_pos; + for _ in 0..n { + target = self.next_atomic_boundary(target); + if target >= self.text.len() { + break; + } + } + self.kill_range(self.cursor_pos..target); + } + pub fn delete_backward_word(&mut self) { let start = self.beginning_of_previous_word(); self.kill_range(start..self.cursor_pos); @@ -668,6 +975,45 @@ impl TextArea { self.replace_range_raw(range, ""); } + fn yank_range(&mut self, range: Range) { + let range = self.expand_range_to_element_boundaries(range); + if range.start >= range.end { + return; + } + let removed = self.text[range].to_string(); + if removed.is_empty() { + return; + } + self.kill_buffer = removed; + } + + fn paste_after_cursor(&mut self) { + if self.kill_buffer.is_empty() { + return; + } + let insert_at = self.next_atomic_boundary(self.cursor_pos); + self.set_cursor(insert_at); + let text = self.kill_buffer.clone(); + self.insert_str(&text); + } + + fn yank_current_line(&mut self) { + let range = self.current_line_range_with_newline(); + self.yank_range(range); + } + + fn delete_current_line(&mut self) { + let range = self.current_line_range_with_newline(); + self.kill_range(range); + } + + fn current_line_range_with_newline(&self) -> Range { + let bol = self.beginning_of_current_line(); + let eol = self.end_of_current_line(); + let end = if eol < self.text.len() { eol + 1 } else { eol }; + bol..end + } + /// Move the cursor left by a single grapheme cluster. pub fn move_cursor_left(&mut self) { self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 91a2e7555e..99db085b7d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -353,7 +353,7 @@ use crate::history_cell::PlainHistoryCell; use crate::history_cell::WebSearchCell; use crate::key_hint; use crate::key_hint::KeyBinding; -#[cfg(test)] +use crate::keymap::RuntimeKeymap; use crate::markdown::append_markdown; use crate::render::Insets; use crate::render::renderable::ColumnRenderable; @@ -5289,6 +5289,10 @@ impl ChatWidget { last_non_retry_error: None, }; + widget.prefetch_rate_limits(); + if let Ok(keymap) = RuntimeKeymap::from_config(&widget.config.tui_keymap) { + widget.bottom_pane.set_keymap_bindings(&keymap); + } widget .bottom_pane .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 1e55b5c5d2..d793a7a934 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1,3 +1,10 @@ +//! Authentication step UI and state transitions used by onboarding. +//! +//! This module owns the auth-step state machine (ChatGPT login/device-code/API +//! key), renders the corresponding UI, and handles auth-scoped keyboard input. +//! It intentionally does not decide onboarding flow completion; the enclosing +//! onboarding screen coordinates step progression. + #![allow(clippy::unwrap_used)] use codex_app_server_client::AppServerRequestHandle; @@ -37,6 +44,11 @@ use std::sync::RwLock; use uuid::Uuid; use crate::LoginStatus; +use crate::key_hint; +use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::OnboardingKeymap; +use crate::keymap::primary_binding; use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::shimmer::shimmer_spans; @@ -185,39 +197,42 @@ impl KeyboardHandler for AuthModeWidget { return; } - match key_event.code { - KeyCode::Up | KeyCode::Char('k') => { - self.move_highlight(/*delta*/ -1); - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_highlight(/*delta*/ 1); - } - KeyCode::Char('1') => { - self.select_option_by_index(/*index*/ 0); - } - KeyCode::Char('2') => { - self.select_option_by_index(/*index*/ 1); - } - KeyCode::Char('3') => { - self.select_option_by_index(/*index*/ 2); - } - KeyCode::Enter => { - let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; - match sign_in_state { - SignInState::PickMode => { - self.handle_sign_in_option(self.highlighted_mode); - } - SignInState::ChatGptSuccessMessage => { - *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; - } - _ => {} + if self.onboarding_keymap.move_up.is_pressed(key_event) { + self.move_highlight(-1); + return; + } + if self.onboarding_keymap.move_down.is_pressed(key_event) { + self.move_highlight(1); + return; + } + if self.onboarding_keymap.select_first.is_pressed(key_event) { + self.select_option_by_index(0); + return; + } + if self.onboarding_keymap.select_second.is_pressed(key_event) { + self.select_option_by_index(1); + return; + } + if self.onboarding_keymap.select_third.is_pressed(key_event) { + self.select_option_by_index(2); + return; + } + if self.onboarding_keymap.confirm.is_pressed(key_event) { + let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; + match sign_in_state { + SignInState::PickMode => { + self.handle_sign_in_option(self.highlighted_mode); } + SignInState::ChatGptSuccessMessage => { + *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; + } + _ => {} } - KeyCode::Esc => { - tracing::info!("Esc pressed"); - self.cancel_active_attempt(); - } - _ => {} + return; + } + if self.onboarding_keymap.cancel.is_pressed(key_event) { + tracing::info!("Cancel onboarding auth step"); + self.cancel_active_attempt(); } } @@ -238,6 +253,7 @@ pub(crate) struct AuthModeWidget { pub forced_login_method: Option, pub animations_enabled: bool, pub animations_suppressed: Cell, + pub onboarding_keymap: OnboardingKeymap, } impl AuthModeWidget { @@ -286,6 +302,28 @@ impl AuthModeWidget { self.error.read().unwrap().clone() } + /// Returns whether the auth flow is currently in API-key entry mode. + pub(crate) fn is_api_key_entry_active(&self) -> bool { + self.sign_in_state + .read() + .is_ok_and(|guard| matches!(&*guard, SignInState::ApiKeyEntry(_))) + } + + /// Returns whether the API-key entry field currently contains any text. + pub(crate) fn api_key_entry_has_text(&self) -> bool { + self.sign_in_state.read().is_ok_and( + |guard| matches!(&*guard, SignInState::ApiKeyEntry(state) if !state.value.is_empty()), + ) + } + + fn confirm_binding(&self) -> KeyBinding { + primary_binding(&self.onboarding_keymap.confirm).unwrap_or(key_hint::plain(KeyCode::Enter)) + } + + fn cancel_binding(&self) -> KeyBinding { + primary_binding(&self.onboarding_keymap.cancel).unwrap_or(key_hint::plain(KeyCode::Esc)) + } + fn is_api_login_allowed(&self) -> bool { !matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) } @@ -455,11 +493,11 @@ impl AuthModeWidget { ); lines.push("".into()); } - lines.push( - // AE: Following styles.md, this should probably be Cyan because it's a user input tip. - // But leaving this for a future cleanup. - " Press Enter to continue".dim().into(), - ); + lines.push(Line::from(vec![ + " Press ".dim(), + self.confirm_binding().into(), + " to continue".dim(), + ])); if let Some(err) = self.error_message() { lines.push("".into()); lines.push(err.red().into()); @@ -494,7 +532,9 @@ impl AuthModeWidget { ])); lines.push("".into()); lines.push(Line::from(vec![ - " On a remote or headless machine? Press Esc and choose ".into(), + " On a remote or headless machine? Press ".into(), + self.cancel_binding().into(), + " and choose ".into(), "Sign in with Device Code".cyan(), ".".into(), ])); @@ -504,7 +544,11 @@ impl AuthModeWidget { None }; - lines.push(" Press Esc to cancel".dim().into()); + lines.push(Line::from(vec![ + " Press ".dim(), + self.cancel_binding().into(), + " to cancel".dim(), + ])); Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); @@ -539,7 +583,11 @@ impl AuthModeWidget { ]) .dim(), "".into(), - " Press Enter to continue".fg(Color::Cyan).into(), + Line::from(vec![ + " Press ".fg(Color::Cyan), + self.confirm_binding().into(), + " to continue".fg(Color::Cyan), + ]), ]; Paragraph::new(lines) @@ -618,8 +666,16 @@ impl AuthModeWidget { .render(input_area, buf); let mut footer_lines: Vec = vec![ - " Press Enter to save".dim().into(), - " Press Esc to go back".dim().into(), + Line::from(vec![ + " Press ".dim(), + self.confirm_binding().into(), + " to save".dim(), + ]), + Line::from(vec![ + " Press ".dim(), + self.cancel_binding().into(), + " to go back".dim(), + ]), ]; if let Some(error) = self.error_message() { footer_lines.push("".into()); @@ -637,46 +693,46 @@ impl AuthModeWidget { { let mut guard = self.sign_in_state.write().unwrap(); if let SignInState::ApiKeyEntry(state) = &mut *guard { - match key_event.code { - KeyCode::Esc => { - *guard = SignInState::PickMode; - self.set_error(/*message*/ None); + if self.onboarding_keymap.cancel.is_pressed(*key_event) { + *guard = SignInState::PickMode; + self.set_error(/*message*/ None); + should_request_frame = true; + } else if self.onboarding_keymap.confirm.is_pressed(*key_event) { + let trimmed = state.value.trim().to_string(); + if trimmed.is_empty() { + self.set_error(Some("API key cannot be empty".to_string())); should_request_frame = true; + } else { + should_save = Some(trimmed); } - KeyCode::Enter => { - let trimmed = state.value.trim().to_string(); - if trimmed.is_empty() { - self.set_error(Some("API key cannot be empty".to_string())); + } else { + match key_event.code { + KeyCode::Backspace => { + if state.prepopulated_from_env { + state.value.clear(); + state.prepopulated_from_env = false; + } else { + state.value.pop(); + } + self.set_error(/*message*/ None); should_request_frame = true; - } else { - should_save = Some(trimmed); } - } - KeyCode::Backspace => { - if state.prepopulated_from_env { - state.value.clear(); - state.prepopulated_from_env = false; - } else { - state.value.pop(); + KeyCode::Char(c) + if key_event.kind == KeyEventKind::Press + && !key_event.modifiers.contains(KeyModifiers::SUPER) + && !key_event.modifiers.contains(KeyModifiers::CONTROL) + && !key_event.modifiers.contains(KeyModifiers::ALT) => + { + if state.prepopulated_from_env { + state.value.clear(); + state.prepopulated_from_env = false; + } + state.value.push(c); + self.set_error(/*message*/ None); + should_request_frame = true; } - self.set_error(/*message*/ None); - should_request_frame = true; + _ => {} } - KeyCode::Char(c) - if key_event.kind == KeyEventKind::Press - && !key_event.modifiers.contains(KeyModifiers::SUPER) - && !key_event.modifiers.contains(KeyModifiers::CONTROL) - && !key_event.modifiers.contains(KeyModifiers::ALT) => - { - if state.prepopulated_from_env { - state.value.clear(); - state.prepopulated_from_env = false; - } - state.value.push(c); - self.set_error(/*message*/ None); - should_request_frame = true; - } - _ => {} } // handled; let guard drop before potential save } else { @@ -1013,6 +1069,7 @@ mod tests { forced_login_method: Some(ForcedLoginMethod::Chatgpt), animations_enabled: true, animations_suppressed: std::cell::Cell::new(false), + onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding, }; (widget, codex_home) } diff --git a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs index a3e82e6cd8..2282649fd0 100644 --- a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs +++ b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs @@ -137,7 +137,11 @@ pub(super) fn render_device_code_login( None }; - lines.push(" Press Esc to cancel".dim().into()); + lines.push(Line::from(vec![ + " Press ".dim(), + widget.cancel_binding().into(), + " to cancel".dim(), + ])); Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 0c7ebda080..625bbaac57 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,6 +1,15 @@ -use crate::legacy_core::config::Config; -#[cfg(target_os = "windows")] -use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; +//! Onboarding screen orchestration and top-level keyboard routing. +//! +//! The onboarding flow is a small state machine over visible steps +//! (welcome/auth/trust). This module decides which step receives key/paste +//! events and enforces flow-level safety rules that cut across individual step +//! widgets. +//! +//! In particular, onboarding quit handling has a text-entry guard for API-key +//! input: printable quit bindings are treated as text input while the user is +//! editing a non-empty API-key field, while control/alt chords remain available +//! as explicit exit shortcuts. + use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ServerNotification; @@ -11,6 +20,7 @@ use codex_protocol::config_types::WindowsSandboxLevel; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; @@ -22,6 +32,12 @@ use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::app_server_session::AppServerSession; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::OnboardingKeymap; +use crate::keymap::RuntimeKeymap; +use crate::legacy_core::config::Config; +#[cfg(target_os = "windows")] +use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; use crate::onboarding::auth::AuthModeWidget; use crate::onboarding::auth::SignInOption; use crate::onboarding::auth::SignInState; @@ -61,6 +77,7 @@ pub(crate) trait StepStateProvider { pub(crate) struct OnboardingScreen { request_frame: FrameRequester, steps: Vec, + onboarding_keymap: OnboardingKeymap, is_done: bool, should_exit: bool, } @@ -78,6 +95,14 @@ pub(crate) struct OnboardingResult { pub should_exit: bool, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct ApiKeyEntryContext { + /// True when onboarding is currently rendering the API-key entry state. + active: bool, + /// True when the API-key input field currently contains user text. + has_text: bool, +} + impl OnboardingScreen { pub(crate) async fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self { let OnboardingScreenArgs { @@ -90,11 +115,22 @@ impl OnboardingScreen { let cwd = config.cwd.to_path_buf(); let codex_home = config.codex_home.to_path_buf(); let forced_login_method = config.forced_login_method; + let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).unwrap_or_else(|err| { + tracing::warn!( + "Invalid `tui.keymap` configuration during onboarding: {err}. \ +Falling back to default onboarding keymap values. \ +Fix `~/.codex/config.toml`.\n\ +Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml" + ); + RuntimeKeymap::defaults() + }); + let onboarding_keymap = runtime_keymap.onboarding; let mut steps: Vec = Vec::new(); steps.push(Step::Welcome(WelcomeWidget::new( !matches!(login_status, LoginStatus::NotAuthenticated), tui.frame_requester(), config.animations, + onboarding_keymap.clone(), ))); if show_login_screen { let highlighted_mode = match forced_login_method { @@ -112,6 +148,7 @@ impl OnboardingScreen { forced_login_method, animations_enabled: config.animations, animations_suppressed: std::cell::Cell::new(false), + onboarding_keymap: onboarding_keymap.clone(), })); } else { tracing::warn!("skipping onboarding login step without app-server request handle"); @@ -137,11 +174,13 @@ impl OnboardingScreen { selection: None, highlighted, error: None, + onboarding_keymap: onboarding_keymap.clone(), })) } Self { request_frame: tui.frame_requester(), steps, + onboarding_keymap, is_done: false, should_exit: false, } @@ -248,45 +287,39 @@ impl OnboardingScreen { } } - fn is_api_key_entry_active(&self) -> bool { - self.steps.iter().any(|step| { - if let Step::Auth(widget) = step { - return widget - .sign_in_state - .read() - .is_ok_and(|g| matches!(&*g, SignInState::ApiKeyEntry(_))); - } - false - }) + fn api_key_entry_context(&self) -> ApiKeyEntryContext { + self.steps + .iter() + .find_map(|step| { + if let Step::Auth(widget) = step { + Some(ApiKeyEntryContext { + active: widget.is_api_key_entry_active(), + has_text: widget.api_key_entry_has_text(), + }) + } else { + None + } + }) + .unwrap_or_default() } } impl KeyboardHandler for OnboardingScreen { + /// Route key events to onboarding steps while preserving text-entry safety. + /// + /// In API-key entry mode, printable quit bindings are suppressed only after + /// the user has started typing in the API-key field. This keeps custom + /// printable quit keys usable on an empty field while protecting in-progress + /// text entry from accidental exits. Control/alt quit chords still work as + /// emergency exits. fn handle_key_event(&mut self, key_event: KeyEvent) { if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return; } - let is_api_key_entry_active = self.is_api_key_entry_active(); - let should_quit = match key_event { - KeyEvent { - code: KeyCode::Char('d'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } - | KeyEvent { - code: KeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => true, - KeyEvent { - code: KeyCode::Char('q'), - kind: KeyEventKind::Press, - .. - } => !is_api_key_entry_active, - _ => false, - }; + let api_key_entry_context = self.api_key_entry_context(); + let should_quit = key_event.kind == KeyEventKind::Press + && self.onboarding_keymap.quit.is_pressed(key_event) + && !suppress_quit_while_typing_api_key(key_event, api_key_entry_context); if should_quit { if self.is_auth_in_progress() { self.cancel_auth_if_active(); @@ -332,6 +365,24 @@ impl KeyboardHandler for OnboardingScreen { } } +/// Returns `true` when a quit shortcut should be ignored as text input. +/// +/// This only applies while API-key entry is active and the key is a printable +/// character without control/alt modifiers and there is already text in the +/// input field. Empty input intentionally does not trigger suppression so +/// printable custom quit bindings can still exit onboarding. +fn suppress_quit_while_typing_api_key( + key_event: KeyEvent, + api_key_entry_context: ApiKeyEntryContext, +) -> bool { + api_key_entry_context.active + && api_key_entry_context.has_text + && matches!(key_event.code, KeyCode::Char(_)) + && !key_event + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) +} + impl WidgetRef for &OnboardingScreen { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let suppress_animations = self.should_suppress_animations(); @@ -544,3 +595,60 @@ pub(crate) async fn run_onboarding_app( should_exit: onboarding_screen.should_exit(), }) } + +#[cfg(test)] +mod tests { + use super::ApiKeyEntryContext; + use super::suppress_quit_while_typing_api_key; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + #[test] + fn suppresses_printable_custom_quit_key_during_api_key_entry() { + let suppressed = suppress_quit_while_typing_api_key( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ApiKeyEntryContext { + active: true, + has_text: true, + }, + ); + assert!(suppressed); + } + + #[test] + fn does_not_suppress_printable_quit_key_when_api_key_input_is_empty() { + let suppressed = suppress_quit_while_typing_api_key( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ApiKeyEntryContext { + active: true, + has_text: false, + }, + ); + assert!(!suppressed); + } + + #[test] + fn does_not_suppress_control_quit_key_during_api_key_entry() { + let suppressed = suppress_quit_while_typing_api_key( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ApiKeyEntryContext { + active: true, + has_text: true, + }, + ); + assert!(!suppressed); + } + + #[test] + fn does_not_suppress_when_not_in_api_key_entry() { + let suppressed = suppress_quit_while_typing_api_key( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ApiKeyEntryContext { + active: false, + has_text: true, + }, + ); + assert!(!suppressed); + } +} diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index 5a1626fda4..01c3191f16 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -14,6 +14,9 @@ use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use crate::key_hint; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::OnboardingKeymap; +use crate::keymap::primary_binding; use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::render::Insets; @@ -32,6 +35,7 @@ pub(crate) struct TrustDirectoryWidget { pub selection: Option, pub highlighted: TrustDirectorySelection, pub error: Option, + pub onboarding_keymap: OnboardingKeymap, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -112,7 +116,9 @@ impl WidgetRef for &TrustDirectoryWidget { column.push( Line::from(vec![ "Press ".dim(), - key_hint::plain(KeyCode::Enter).into(), + primary_binding(&self.onboarding_keymap.confirm) + .unwrap_or(key_hint::plain(KeyCode::Enter)) + .into(), if self.show_windows_create_sandbox_hint { " to continue and create a sandbox...".dim() } else { @@ -134,20 +140,22 @@ impl KeyboardHandler for TrustDirectoryWidget { return; } - match key_event.code { - KeyCode::Up | KeyCode::Char('k') => { - self.highlighted = TrustDirectorySelection::Trust; - } - KeyCode::Down | KeyCode::Char('j') => { - self.highlighted = TrustDirectorySelection::Quit; - } - KeyCode::Char('1') | KeyCode::Char('y') => self.handle_trust(), - KeyCode::Char('2') | KeyCode::Char('n') => self.handle_quit(), - KeyCode::Enter => match self.highlighted { + if self.onboarding_keymap.move_up.is_pressed(key_event) { + self.highlighted = TrustDirectorySelection::Trust; + } else if self.onboarding_keymap.move_down.is_pressed(key_event) { + self.highlighted = TrustDirectorySelection::Quit; + } else if self.onboarding_keymap.select_first.is_pressed(key_event) { + self.handle_trust(); + } else if self.onboarding_keymap.select_second.is_pressed(key_event) + || self.onboarding_keymap.quit.is_pressed(key_event) + || self.onboarding_keymap.cancel.is_pressed(key_event) + { + self.handle_quit(); + } else if self.onboarding_keymap.confirm.is_pressed(key_event) { + match self.highlighted { TrustDirectorySelection::Trust => self.handle_trust(), TrustDirectorySelection::Quit => self.handle_quit(), - }, - _ => {} + } } } } @@ -209,6 +217,7 @@ mod tests { selection: None, highlighted: TrustDirectorySelection::Quit, error: None, + onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding, }; let release = KeyEvent { @@ -235,6 +244,7 @@ mod tests { selection: None, highlighted: TrustDirectorySelection::Trust, error: None, + onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding, }; let mut terminal = diff --git a/codex-rs/tui/src/onboarding/welcome.rs b/codex-rs/tui/src/onboarding/welcome.rs index acb12abaab..53953ab646 100644 --- a/codex-rs/tui/src/onboarding/welcome.rs +++ b/codex-rs/tui/src/onboarding/welcome.rs @@ -1,7 +1,5 @@ -use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; -use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; @@ -14,6 +12,9 @@ use ratatui::widgets::Wrap; use std::cell::Cell; use crate::ascii_animation::AsciiAnimation; +use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::OnboardingKeymap; use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::tui::FrameRequester; @@ -28,17 +29,21 @@ pub(crate) struct WelcomeWidget { animation: AsciiAnimation, animations_enabled: bool, animations_suppressed: Cell, + toggle_animation_keys: Vec, layout_area: Cell>, } impl KeyboardHandler for WelcomeWidget { + /// Rotate the welcome animation when the configured toggle shortcut fires. + /// + /// The key list comes from runtime keymap defaults/overrides and can + /// include compatibility variants for terminals that report modifier bits + /// differently. fn handle_key_event(&mut self, key_event: KeyEvent) { if !self.animations_enabled { return; } - if key_event.kind == KeyEventKind::Press - && key_event.code == KeyCode::Char('.') - && key_event.modifiers.contains(KeyModifiers::CONTROL) + if key_event.kind == KeyEventKind::Press && self.toggle_animation_keys.is_pressed(key_event) { tracing::warn!("Welcome background to press '.'"); let _ = self.animation.pick_random_variant(); @@ -51,12 +56,14 @@ impl WelcomeWidget { is_logged_in: bool, request_frame: FrameRequester, animations_enabled: bool, + onboarding_keymap: OnboardingKeymap, ) -> Self { Self { is_logged_in, animation: AsciiAnimation::new(request_frame), animations_enabled, animations_suppressed: Cell::new(false), + toggle_animation_keys: onboarding_keymap.toggle_animation, layout_area: Cell::new(None), } } @@ -115,6 +122,8 @@ impl StepStateProvider for WelcomeWidget { #[cfg(test)] mod tests { use super::*; + use crossterm::event::KeyCode; + use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -139,6 +148,7 @@ mod tests { /*is_logged_in*/ false, FrameRequester::test_dummy(), /*animations_enabled*/ true, + crate::keymap::RuntimeKeymap::defaults().onboarding, ); let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT); let mut buf = Buffer::empty(area); @@ -155,6 +165,7 @@ mod tests { /*is_logged_in*/ false, FrameRequester::test_dummy(), /*animations_enabled*/ true, + crate::keymap::RuntimeKeymap::defaults().onboarding, ); let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT - 1); let mut buf = Buffer::empty(area); @@ -175,6 +186,9 @@ mod tests { ), animations_enabled: true, animations_suppressed: Cell::new(false), + toggle_animation_keys: crate::keymap::RuntimeKeymap::defaults() + .onboarding + .toggle_animation, layout_area: Cell::new(None), }; @@ -187,4 +201,30 @@ mod tests { "expected ctrl+. to switch welcome animation variant" ); } + + #[test] + fn ctrl_shift_dot_changes_animation_variant() { + let mut widget = WelcomeWidget { + is_logged_in: false, + animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0), + animations_enabled: true, + animations_suppressed: Cell::new(false), + toggle_animation_keys: crate::keymap::RuntimeKeymap::defaults() + .onboarding + .toggle_animation, + layout_area: Cell::new(None), + }; + + let before = widget.animation.current_frame(); + widget.handle_key_event(KeyEvent::new( + KeyCode::Char('.'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )); + let after = widget.animation.current_frame(); + + assert_ne!( + before, after, + "expected ctrl+shift+. to switch welcome animation variant" + ); + } } diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index bca5f1f360..4a3e819631 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -21,15 +21,15 @@ use std::sync::Arc; use crate::chatwidget::ActiveCellTranscriptKey; use crate::history_cell::HistoryCell; use crate::history_cell::UserHistoryCell; -use crate::key_hint; use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::PagerKeymap; use crate::render::Insets; use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; use crate::style::user_message_style; use crate::tui; use crate::tui::TuiEvent; -use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::buffer::Cell; @@ -51,19 +51,24 @@ pub(crate) enum Overlay { } impl Overlay { - pub(crate) fn new_transcript(cells: Vec>) -> Self { - Self::Transcript(TranscriptOverlay::new(cells)) + pub(crate) fn new_transcript(cells: Vec>, keymap: PagerKeymap) -> Self { + Self::Transcript(TranscriptOverlay::new(cells, keymap)) } - pub(crate) fn new_static_with_lines(lines: Vec>, title: String) -> Self { - Self::Static(StaticOverlay::with_title(lines, title)) + pub(crate) fn new_static_with_lines( + lines: Vec>, + title: String, + keymap: PagerKeymap, + ) -> Self { + Self::Static(StaticOverlay::with_title(lines, title, keymap)) } pub(crate) fn new_static_with_renderables( renderables: Vec>, title: String, + keymap: PagerKeymap, ) -> Self { - Self::Static(StaticOverlay::with_renderables(renderables, title)) + Self::Static(StaticOverlay::with_renderables(renderables, title, keymap)) } pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { @@ -81,37 +86,12 @@ impl Overlay { } } -const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up); -const KEY_DOWN: KeyBinding = key_hint::plain(KeyCode::Down); -const KEY_K: KeyBinding = key_hint::plain(KeyCode::Char('k')); -const KEY_J: KeyBinding = key_hint::plain(KeyCode::Char('j')); -const KEY_PAGE_UP: KeyBinding = key_hint::plain(KeyCode::PageUp); -const KEY_PAGE_DOWN: KeyBinding = key_hint::plain(KeyCode::PageDown); -const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' ')); -const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' ')); -const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home); -const KEY_END: KeyBinding = key_hint::plain(KeyCode::End); -const KEY_LEFT: KeyBinding = key_hint::plain(KeyCode::Left); -const KEY_RIGHT: KeyBinding = key_hint::plain(KeyCode::Right); -const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f')); -const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d')); -const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b')); -const KEY_CTRL_U: KeyBinding = key_hint::ctrl(KeyCode::Char('u')); -const KEY_Q: KeyBinding = key_hint::plain(KeyCode::Char('q')); -const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc); -const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter); -const KEY_CTRL_T: KeyBinding = key_hint::ctrl(KeyCode::Char('t')); -const KEY_CTRL_C: KeyBinding = key_hint::ctrl(KeyCode::Char('c')); - -// Common pager navigation hints rendered on the first line -const PAGER_KEY_HINTS: &[(&[KeyBinding], &str)] = &[ - (&[KEY_UP, KEY_DOWN], "to scroll"), - (&[KEY_PAGE_UP, KEY_PAGE_DOWN], "to page"), - (&[KEY_HOME, KEY_END], "to jump"), -]; +fn first_or_empty(bindings: &[KeyBinding]) -> Vec { + bindings.first().copied().into_iter().collect() +} // Render a single line of key hints from (key(s), description) pairs. -fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&[KeyBinding], &str)]) { +fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec, &str)]) { let mut spans: Vec> = vec![" ".into()]; let mut first = true; for (keys, desc) in pairs { @@ -136,6 +116,7 @@ struct PagerView { renderables: Vec>, scroll_offset: usize, title: String, + keymap: PagerKeymap, last_content_height: Option, last_rendered_height: Option, /// If set, on next render ensure this chunk is visible. @@ -143,11 +124,17 @@ struct PagerView { } impl PagerView { - fn new(renderables: Vec>, title: String, scroll_offset: usize) -> Self { + fn new( + renderables: Vec>, + title: String, + scroll_offset: usize, + keymap: PagerKeymap, + ) -> Self { Self { renderables, scroll_offset, title, + keymap, last_content_height: None, last_rendered_height: None, pending_scroll_chunk: None, @@ -260,37 +247,34 @@ impl PagerView { fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> { match key_event { - e if KEY_UP.is_press(e) || KEY_K.is_press(e) => { + e if self.keymap.scroll_up.is_pressed(e) => { self.scroll_offset = self.scroll_offset.saturating_sub(1); } - e if KEY_DOWN.is_press(e) || KEY_J.is_press(e) => { + e if self.keymap.scroll_down.is_pressed(e) => { self.scroll_offset = self.scroll_offset.saturating_add(1); } - e if KEY_PAGE_UP.is_press(e) - || KEY_SHIFT_SPACE.is_press(e) - || KEY_CTRL_B.is_press(e) => - { + e if self.keymap.page_up.is_pressed(e) => { let page_height = self.page_height(tui.terminal.viewport_area); self.scroll_offset = self.scroll_offset.saturating_sub(page_height); } - e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) || KEY_CTRL_F.is_press(e) => { + e if self.keymap.page_down.is_pressed(e) => { let page_height = self.page_height(tui.terminal.viewport_area); self.scroll_offset = self.scroll_offset.saturating_add(page_height); } - e if KEY_CTRL_D.is_press(e) => { + e if self.keymap.half_page_down.is_pressed(e) => { let area = self.content_area(tui.terminal.viewport_area); let half_page = (area.height as usize).saturating_add(1) / 2; self.scroll_offset = self.scroll_offset.saturating_add(half_page); } - e if KEY_CTRL_U.is_press(e) => { + e if self.keymap.half_page_up.is_pressed(e) => { let area = self.content_area(tui.terminal.viewport_area); let half_page = (area.height as usize).saturating_add(1) / 2; self.scroll_offset = self.scroll_offset.saturating_sub(half_page); } - e if KEY_HOME.is_press(e) => { + e if self.keymap.jump_top.is_pressed(e) => { self.scroll_offset = 0; } - e if KEY_END.is_press(e) => { + e if self.keymap.jump_bottom.is_pressed(e) => { self.scroll_offset = usize::MAX; } _ => { @@ -454,12 +438,13 @@ impl TranscriptOverlay { /// /// This overlay does not own the "active cell"; callers may optionally append a live tail via /// `sync_live_tail` during draws to reflect in-flight activity. - pub(crate) fn new(transcript_cells: Vec>) -> Self { + pub(crate) fn new(transcript_cells: Vec>, keymap: PagerKeymap) -> Self { Self { view: PagerView::new( Self::render_cells(&transcript_cells, /*highlight_cell*/ None), "T R A N S C R I P T".to_string(), usize::MAX, + keymap, ), cells: transcript_cells, highlight_cell: None, @@ -668,15 +653,51 @@ impl TranscriptOverlay { fn render_hints(&self, area: Rect, buf: &mut Buffer) { let line1 = Rect::new(area.x, area.y, area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); - render_key_hints(line1, buf, PAGER_KEY_HINTS); + render_key_hints( + line1, + buf, + &[ + ( + first_or_empty(&self.view.keymap.scroll_up) + .into_iter() + .chain(first_or_empty(&self.view.keymap.scroll_down)) + .collect(), + "to scroll", + ), + ( + first_or_empty(&self.view.keymap.page_up) + .into_iter() + .chain(first_or_empty(&self.view.keymap.page_down)) + .collect(), + "to page", + ), + ( + first_or_empty(&self.view.keymap.jump_top) + .into_iter() + .chain(first_or_empty(&self.view.keymap.jump_bottom)) + .collect(), + "to jump", + ), + ], + ); - let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")]; + let mut pairs: Vec<(Vec, &str)> = + vec![(first_or_empty(&self.view.keymap.close), "to quit")]; if self.highlight_cell.is_some() { - pairs.push((&[KEY_ESC, KEY_LEFT], "to edit prev")); - pairs.push((&[KEY_RIGHT], "to edit next")); - pairs.push((&[KEY_ENTER], "to edit message")); + pairs.push(( + self.view.keymap.edit_previous_message.clone(), + "to edit prev", + )); + pairs.push((self.view.keymap.edit_next_message.clone(), "to edit next")); + pairs.push(( + self.view.keymap.confirm_edit_message.clone(), + "to edit message", + )); } else { - pairs.push((&[KEY_ESC], "to edit prev")); + pairs.push(( + first_or_empty(&self.view.keymap.edit_previous_message), + "to edit prev", + )); } render_key_hints(line2, buf, &pairs); } @@ -694,7 +715,9 @@ impl TranscriptOverlay { pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { match event { TuiEvent::Key(key_event) => match key_event { - e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) || KEY_CTRL_T.is_press(e) => { + e if self.view.keymap.close.is_pressed(e) + || self.view.keymap.close_transcript.is_pressed(e) => + { self.is_done = true; Ok(()) } @@ -725,14 +748,26 @@ pub(crate) struct StaticOverlay { } impl StaticOverlay { - pub(crate) fn with_title(lines: Vec>, title: String) -> Self { + pub(crate) fn with_title( + lines: Vec>, + title: String, + keymap: PagerKeymap, + ) -> Self { let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); - Self::with_renderables(vec![Box::new(CachedRenderable::new(paragraph))], title) + Self::with_renderables( + vec![Box::new(CachedRenderable::new(paragraph))], + title, + keymap, + ) } - pub(crate) fn with_renderables(renderables: Vec>, title: String) -> Self { + pub(crate) fn with_renderables( + renderables: Vec>, + title: String, + keymap: PagerKeymap, + ) -> Self { Self { - view: PagerView::new(renderables, title, /*scroll_offset*/ 0), + view: PagerView::new(renderables, title, 0, keymap), is_done: false, } } @@ -740,8 +775,35 @@ impl StaticOverlay { fn render_hints(&self, area: Rect, buf: &mut Buffer) { let line1 = Rect::new(area.x, area.y, area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); - render_key_hints(line1, buf, PAGER_KEY_HINTS); - let pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")]; + render_key_hints( + line1, + buf, + &[ + ( + first_or_empty(&self.view.keymap.scroll_up) + .into_iter() + .chain(first_or_empty(&self.view.keymap.scroll_down)) + .collect(), + "to scroll", + ), + ( + first_or_empty(&self.view.keymap.page_up) + .into_iter() + .chain(first_or_empty(&self.view.keymap.page_down)) + .collect(), + "to page", + ), + ( + first_or_empty(&self.view.keymap.jump_top) + .into_iter() + .chain(first_or_empty(&self.view.keymap.jump_bottom)) + .collect(), + "to jump", + ), + ], + ); + let pairs: Vec<(Vec, &str)> = + vec![(first_or_empty(&self.view.keymap.close), "to quit")]; render_key_hints(line2, buf, &pairs); } @@ -758,7 +820,7 @@ impl StaticOverlay { pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { match event { TuiEvent::Key(key_event) => match key_event { - e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) => { + e if self.view.keymap.close.is_pressed(e) => { self.is_done = true; Ok(()) } @@ -851,9 +913,34 @@ mod tests { Box::new(Paragraph::new(text)) as Box } + fn default_pager_keymap() -> crate::keymap::PagerKeymap { + crate::keymap::RuntimeKeymap::defaults().pager + } + + fn transcript_overlay(cells: Vec>) -> TranscriptOverlay { + TranscriptOverlay::new(cells, default_pager_keymap()) + } + + fn static_overlay(lines: Vec>, title: &str) -> StaticOverlay { + StaticOverlay::with_title(lines, title.to_string(), default_pager_keymap()) + } + + fn pager_view( + renderables: Vec>, + title: &str, + scroll_offset: usize, + ) -> PagerView { + PagerView::new( + renderables, + title.to_string(), + scroll_offset, + default_pager_keymap(), + ) + } + #[test] fn edit_prev_hint_is_visible() { - let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { lines: vec![Line::from("hello")], })]); @@ -871,7 +958,7 @@ mod tests { #[test] fn edit_next_hint_is_visible_when_highlighted() { - let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { lines: vec![Line::from("hello")], })]); overlay.set_highlight_cell(Some(0)); @@ -891,7 +978,7 @@ mod tests { #[test] fn transcript_overlay_snapshot_basic() { // Prepare a transcript overlay with a few lines - let mut overlay = TranscriptOverlay::new(vec![ + let mut overlay = transcript_overlay(vec![ Arc::new(TestCell { lines: vec![Line::from("alpha")], }), @@ -910,7 +997,7 @@ mod tests { #[test] fn transcript_overlay_renders_live_tail() { - let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { lines: vec![Line::from("alpha")], })]); overlay.sync_live_tail( @@ -931,7 +1018,7 @@ mod tests { #[test] fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() { - let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { + let mut overlay = transcript_overlay(vec![Arc::new(TestCell { lines: vec![Line::from("alpha")], })]); @@ -1027,7 +1114,7 @@ mod tests { let exec_cell: Arc = Arc::new(exec_cell); cells.push(exec_cell); - let mut overlay = TranscriptOverlay::new(cells); + let mut overlay = transcript_overlay(cells); let area = Rect::new(0, 0, 80, 12); let mut buf = Buffer::empty(area); @@ -1041,7 +1128,7 @@ mod tests { #[test] fn transcript_overlay_keeps_scroll_pinned_at_bottom() { - let mut overlay = TranscriptOverlay::new( + let mut overlay = transcript_overlay( (0..20) .map(|i| { Arc::new(TestCell { @@ -1068,7 +1155,7 @@ mod tests { #[test] fn transcript_overlay_preserves_manual_scroll_position() { - let mut overlay = TranscriptOverlay::new( + let mut overlay = transcript_overlay( (0..20) .map(|i| { Arc::new(TestCell { @@ -1093,9 +1180,9 @@ mod tests { #[test] fn static_overlay_snapshot_basic() { // Prepare a static overlay with a few lines and a title - let mut overlay = StaticOverlay::with_title( + let mut overlay = static_overlay( vec!["one".into(), "two".into(), "three".into()], - "S T A T I C".to_string(), + "S T A T I C", ); let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) @@ -1131,7 +1218,7 @@ mod tests { #[test] fn transcript_overlay_paging_is_continuous_and_round_trips() { - let mut overlay = TranscriptOverlay::new( + let mut overlay = transcript_overlay( (0..50) .map(|i| { Arc::new(TestCell { @@ -1199,9 +1286,9 @@ mod tests { #[test] fn static_overlay_wraps_long_lines() { - let mut overlay = StaticOverlay::with_title( + let mut overlay = static_overlay( vec!["a very long line that should wrap when rendered within a narrow pager overlay width".into()], - "S T A T I C".to_string(), + "S T A T I C", ); let mut term = Terminal::new(TestBackend::new(24, 8)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) @@ -1211,13 +1298,10 @@ mod tests { #[test] fn pager_view_content_height_counts_renderables() { - let pv = PagerView::new( - vec![ - paragraph_block("a", /*lines*/ 2), - paragraph_block("b", /*lines*/ 3), - ], - "T".to_string(), - /*scroll_offset*/ 0, + let pv = pager_view( + vec![paragraph_block("a", 2), paragraph_block("b", 3)], + "T", + 0, ); assert_eq!(pv.content_height(/*width*/ 80), 5); @@ -1225,14 +1309,14 @@ mod tests { #[test] fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() { - let mut pv = PagerView::new( + let mut pv = pager_view( vec![ paragraph_block("a", /*lines*/ 1), paragraph_block("b", /*lines*/ 3), paragraph_block("c", /*lines*/ 3), ], - "T".to_string(), - /*scroll_offset*/ 0, + "T", + 0, ); let area = Rect::new(0, 0, 20, 8); @@ -1260,14 +1344,14 @@ mod tests { #[test] fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() { - let mut pv = PagerView::new( + let mut pv = pager_view( vec![ paragraph_block("a", /*lines*/ 2), paragraph_block("b", /*lines*/ 3), paragraph_block("c", /*lines*/ 3), ], - "T".to_string(), - /*scroll_offset*/ 0, + "T", + 0, ); let area = Rect::new(0, 0, 20, 3); @@ -1279,11 +1363,7 @@ mod tests { #[test] fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() { - let mut pv = PagerView::new( - vec![paragraph_block("a", /*lines*/ 10)], - "T".to_string(), - /*scroll_offset*/ 0, - ); + let mut pv = pager_view(vec![paragraph_block("a", 10)], "T", 0); let area = Rect::new(0, 0, 20, 8); let mut buf = Buffer::empty(area);