diff --git a/AGENTS.md b/AGENTS.md index fa2a4bbaba..6d2f032996 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ In the codex-rs folder where the rust code lives: - After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught locally before CI. - Do not create small helper methods that are referenced only once. +- 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 a46a927fbc..d4bd9d00ce 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -20,6 +20,8 @@ use crate::history_cell; use crate::history_cell::HistoryCell; #[cfg(not(debug_assertions))] use crate::history_cell::UpdateAvailableHistoryCell; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::RuntimeKeymap; use crate::model_migration::ModelMigrationOutcome; use crate::model_migration::migration_copy_for_models; use crate::model_migration::run_model_migration_prompt; @@ -71,7 +73,6 @@ use codex_protocol::protocol::SessionConfiguredEvent; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; -use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use ratatui::style::Stylize; @@ -538,6 +539,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, @@ -1183,6 +1185,13 @@ impl App { .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); let file_search = FileSearchManager::new(config.cwd.clone(), 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); @@ -1200,6 +1209,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(), @@ -1686,6 +1696,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(); } @@ -2479,6 +2490,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, .. } => { @@ -2488,6 +2500,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::McpElicitation { @@ -2505,6 +2518,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(), )); } }, @@ -2784,63 +2798,60 @@ impl App { } async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { - match key_event { - KeyEvent { - code: KeyCode::Char('t'), - modifiers: crossterm::event::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(); - } - KeyEvent { - code: KeyCode::Char('g'), - modifiers: crossterm::event::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); - } + 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. - 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); - } + 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); - } + 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 { kind: KeyEventKind::Press | KeyEventKind::Repeat, .. @@ -2848,7 +2859,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); @@ -2919,6 +2932,7 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; + use crossterm::event::KeyCode; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; @@ -3166,6 +3180,8 @@ mod tests { let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); let otel_manager = test_otel_manager(&config, model.as_str()); + let keymap = RuntimeKeymap::from_config(&config.tui_keymap) + .expect("test config should always produce a valid runtime keymap"); App { server, @@ -3180,6 +3196,7 @@ mod tests { runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, file_search, + keymap, transcript_cells: Vec::new(), overlay: None, deferred_history_lines: Vec::new(), @@ -3223,6 +3240,8 @@ mod tests { let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); let otel_manager = test_otel_manager(&config, model.as_str()); + let keymap = RuntimeKeymap::from_config(&config.tui_keymap) + .expect("test config should always produce a valid runtime keymap"); ( App { @@ -3238,6 +3257,7 @@ mod tests { runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, file_search, + keymap, transcript_cells: Vec::new(), overlay: None, deferred_history_lines: Vec::new(), @@ -3934,7 +3954,10 @@ mod tests { 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; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b9b5be2b5f..e59d8cfca4 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -31,6 +31,7 @@ use crate::app::App; use crate::app_event::AppEvent; 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; @@ -41,8 +42,6 @@ use codex_core::protocol::Op; 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. @@ -110,60 +109,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 567f18f332..c88982163f 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; @@ -13,6 +26,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; @@ -27,7 +43,6 @@ use codex_protocol::mcp::RequestId; 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; @@ -70,20 +85,30 @@ 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, current_variant: 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 @@ -98,15 +123,18 @@ impl ApprovalOverlay { let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request); self.current_variant = Some(variant.clone()); self.current_complete = false; - let (options, params) = Self::build_options(variant, header, &self.features); + let (options, params) = + Self::build_options(variant, header, &self.features, &self.approval_keymap); 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( variant: ApprovalVariant, header: Box, - _features: &Features, + features: &Features, + approval_keymap: &ApprovalKeymap, ) -> (Vec, SelectionViewParams) { let (options, title) = match &variant { ApprovalVariant::Exec { @@ -117,6 +145,8 @@ impl ApprovalOverlay { exec_options( proposed_execpolicy_amendment.clone(), network_approval_context.as_ref(), + features, + approval_keymap, ), network_approval_context.as_ref().map_or_else( || "Would you like to run the following command?".to_string(), @@ -129,11 +159,11 @@ impl ApprovalOverlay { ), ), ApprovalVariant::ApplyPatch { .. } => ( - patch_options(), + patch_options(approval_keymap), "Would you like to make the following edits?".to_string(), ), ApprovalVariant::McpElicitation { server_name, .. } => ( - elicitation_options(), + elicitation_options(approval_keymap), format!("{server_name} needs your approval."), ), }; @@ -148,9 +178,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() }) @@ -243,34 +271,29 @@ 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 - } - } - 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 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 } } } @@ -452,41 +475,31 @@ enum ApprovalDecision { 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( proposed_execpolicy_amendment: Option, network_approval_context: Option<&NetworkApprovalContext>, + _features: &Features, + keymap: &ApprovalKeymap, ) -> Vec { if network_approval_context.is_some() { return vec![ ApprovalOption { label: "Yes, just this once".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 allow this host for 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(), }, 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(), }, ]; } @@ -494,8 +507,7 @@ fn exec_options( 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(), }] .into_iter() .chain(proposed_execpolicy_amendment.and_then(|prefix| { @@ -511,61 +523,74 @@ fn exec_options( decision: ApprovalDecision::Review(ReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment: prefix, }), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + shortcuts: keymap.approve_for_prefix.clone(), }) })) .chain([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() } -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 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, }, ] } @@ -575,9 +600,41 @@ mod tests { use super::*; use crate::app_event::AppEvent; use codex_core::protocol::NetworkApprovalProtocol; + use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; + 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 { id: "test".to_string(), @@ -588,11 +645,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()); @@ -603,7 +668,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 CodexOp message in the queue. @@ -621,7 +686,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 { id: "test".to_string(), command: vec!["echo".to_string()], @@ -669,7 +734,7 @@ mod tests { proposed_execpolicy_amendment: 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, 80, view.desired_height(80))); view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf); @@ -694,9 +759,12 @@ mod tests { host: "example.com".to_string(), protocol: NetworkApprovalProtocol::Https, }; + let keymap = crate::keymap::RuntimeKeymap::defaults(); let options = exec_options( Some(ExecPolicyAmendment::new(vec!["curl".to_string()])), Some(&network_context), + &Features::with_defaults(), + &keymap.approval, ); let labels: Vec = options.into_iter().map(|option| option.label).collect(); @@ -725,7 +793,7 @@ mod tests { proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec!["curl".into()])), }; - 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(100))); view.render(Rect::new(0, 0, 100, view.desired_height(100)), &mut buf); @@ -749,6 +817,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![ @@ -776,11 +865,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 91dfa00a52..92643784d1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -169,6 +169,10 @@ 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; @@ -336,6 +340,13 @@ pub(crate) struct ChatComposer { windows_degraded_sandbox_active: bool, status_line_value: Option>, status_line_enabled: bool, + 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)] @@ -436,6 +447,16 @@ impl ChatComposer { windows_degraded_sandbox_active: false, status_line_value: None, status_line_enabled: false, + 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); @@ -495,6 +516,17 @@ impl ChatComposer { self.connectors_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, @@ -1369,7 +1401,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)); @@ -1426,7 +1458,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 @@ -2521,6 +2553,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'), @@ -2738,7 +2779,7 @@ impl ChatComposer { Some(self.textarea.element_payloads()) }; - self.textarea.input(input); + self.textarea.input_with_keymap(input, &self.editor_keymap); if let Some(elements_before) = elements_before { self.reconcile_deleted_elements(elements_before); @@ -2806,13 +2847,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(); @@ -4535,6 +4581,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 14f94ca6f4..9e87b51ffb 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; @@ -120,6 +122,7 @@ pub(crate) struct ListSelectionView { last_selected_actual_idx: Option, header: Box, initial_selected_idx: Option, + keymap: ListKeymap, } impl ListSelectionView { @@ -130,7 +133,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())); @@ -161,6 +168,7 @@ impl ListSelectionView { last_selected_actual_idx: None, header, initial_selected_idx: params.initial_selected_idx, + keymap, }; s.apply_filter(); s @@ -365,47 +373,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::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, .. @@ -413,9 +420,7 @@ impl BottomPaneView for ListSelectionView { self.search_query.pop(); self.apply_filter(); } - KeyEvent { - code: KeyCode::Esc, .. - } => { + _ if self.keymap.cancel.is_pressed(key_event) => { self.on_ctrl_c(); } KeyEvent { @@ -451,11 +456,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(), _ => {} } } @@ -686,6 +687,10 @@ mod tests { use ratatui::layout::Rect; use tokio::sync::mpsc::unbounded_channel; + 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); @@ -705,7 +710,7 @@ mod tests { ..Default::default() }, ]; - ListSelectionView::new( + new_view( SelectionViewParams { title: Some("Select Approval Mode".to_string()), subtitle: subtitle.map(str::to_string), @@ -782,6 +787,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let before_scroll = render_lines_with_width(&view, width); @@ -824,7 +830,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), @@ -851,7 +857,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()), @@ -887,7 +893,7 @@ mod tests { ..Default::default() }, ]; - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Approval".to_string()), items, @@ -945,7 +951,7 @@ mod tests { ..Default::default() }, ]; - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Select Model and Effort".to_string()), items, @@ -979,7 +985,7 @@ mod tests { ..Default::default() }) .collect(); - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Debug".to_string()), items, @@ -1027,7 +1033,7 @@ mod tests { ..Default::default() }, ]; - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Select Model and Effort".to_string()), items, @@ -1054,7 +1060,7 @@ mod tests { ..Default::default() }) .collect(); - let view = ListSelectionView::new( + let view = new_view( SelectionViewParams { title: Some("Debug".to_string()), items, @@ -1105,6 +1111,7 @@ mod tests { ..Default::default() }, tx, + crate::keymap::RuntimeKeymap::defaults().list, ); let before_scroll = render_lines_with_width(&view, 96); @@ -1139,6 +1146,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 43aecb4940..c608be8421 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -21,6 +21,7 @@ use crate::bottom_pane::queued_user_messages::QueuedUserMessages; 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; @@ -168,6 +169,7 @@ pub(crate) struct BottomPane { queued_user_messages: QueuedUserMessages, context_window_percent: Option, context_window_used_tokens: Option, + keymap: RuntimeKeymap, } pub(crate) struct BottomPaneParams { @@ -200,6 +202,8 @@ impl BottomPane { placeholder_text, disable_paste_burst, ); + let keymap = RuntimeKeymap::defaults(); + composer.set_keymap_bindings(&keymap); composer.set_skill_mentions(skills); Self { @@ -218,6 +222,7 @@ impl BottomPane { animations_enabled, context_window_percent: None, context_window_used_tokens: None, + keymap, } } @@ -247,6 +252,12 @@ impl BottomPane { self.composer.take_recent_submission_mention_bindings() } + pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { + self.keymap = keymap.clone(); + self.composer.set_keymap_bindings(keymap); + self.request_redraw(); + } + /// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text. pub(crate) fn drain_pending_submission_state(&mut self) { let _ = self.take_recent_submission_images_with_placeholders(); @@ -676,7 +687,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)); } @@ -695,7 +710,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 } @@ -781,7 +800,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 2caa45602f..ceac7bc407 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -1,4 +1,7 @@ +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; @@ -45,6 +48,7 @@ pub(crate) struct TextArea { elements: Vec, next_element_id: u64, kill_buffer: String, + editor_keymap: EditorKeymap, } #[derive(Debug, Clone)] @@ -69,9 +73,14 @@ impl TextArea { elements: Vec::new(), next_element_id: 1, kill_buffer: String::new(), + editor_keymap: RuntimeKeymap::defaults().editor, } } + pub fn set_keymap_bindings(&mut self, keymap: &EditorKeymap) { + self.editor_keymap = keymap.clone(); + } + /// Replace the textarea text and clear any existing text elements. pub fn set_text_clearing_elements(&mut self, text: &str) { self.set_text_inner(text, None); @@ -256,241 +265,160 @@ impl TextArea { } pub fn input(&mut self, event: KeyEvent) { + 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(1), - KeyEvent { - code: KeyCode::Delete, - modifiers: KeyModifiers::ALT, - .. - } => self.delete_forward_word(), - KeyEvent { - code: KeyCode::Delete, - .. - } - | KeyEvent { - code: KeyCode::Char('d'), - modifiers: KeyModifiers::CONTROL, - .. - } => self.delete_forward(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(); + return; } - // 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(false); - } - KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_to_beginning_of_line(true); - } - - KeyEvent { - code: KeyCode::End, .. - } => { - self.move_cursor_to_end_of_line(false); - } - KeyEvent { - code: KeyCode::Char('e'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_to_end_of_line(true); - } - _o => { - #[cfg(feature = "debug-logs")] - tracing::debug!("Unhandled key event in TextArea: {:?}", _o); - } + _ => {} } + + 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); } // ####### Input Functions ####### diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9e746b4c57..8a734b1d27 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -199,6 +199,7 @@ use crate::history_cell::PlainHistoryCell; use crate::history_cell::WebSearchCell; use crate::key_hint; use crate::key_hint::KeyBinding; +use crate::keymap::RuntimeKeymap; use crate::markdown::append_markdown; use crate::multi_agents; use crate::render::Insets; @@ -2690,6 +2691,9 @@ impl ChatWidget { }; 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_steer_enabled(widget.config.features.enabled(Feature::Steer)); @@ -2853,6 +2857,9 @@ impl ChatWidget { }; 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_steer_enabled(widget.config.features.enabled(Feature::Steer)); @@ -3005,6 +3012,9 @@ impl ChatWidget { }; 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_steer_enabled(widget.config.features.enabled(Feature::Steer)); diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index c639d24a08..d4cb145e60 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_core::AuthManager; @@ -35,6 +42,11 @@ use codex_protocol::config_types::ForcedLoginMethod; use std::sync::RwLock; 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; @@ -100,55 +112,58 @@ impl KeyboardHandler for AuthModeWidget { return; } - match key_event.code { - KeyCode::Up | KeyCode::Char('k') => { - self.move_highlight(-1); - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_highlight(1); - } - KeyCode::Char('1') => { - self.select_option_by_index(0); - } - KeyCode::Char('2') => { - self.select_option_by_index(1); - } - KeyCode::Char('3') => { - self.select_option_by_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); } - } - KeyCode::Esc => { - tracing::info!("Esc pressed"); - let mut sign_in_state = self.sign_in_state.write().unwrap(); - match &*sign_in_state { - SignInState::ChatGptContinueInBrowser(_) => { - *sign_in_state = SignInState::PickMode; - drop(sign_in_state); - self.request_frame.schedule_frame(); - } - SignInState::ChatGptDeviceCode(state) => { - if let Some(cancel) = &state.cancel { - cancel.notify_one(); - } - *sign_in_state = SignInState::PickMode; - drop(sign_in_state); - self.request_frame.schedule_frame(); - } - _ => {} + SignInState::ChatGptSuccessMessage => { + *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; } + _ => {} + } + return; + } + if self.onboarding_keymap.cancel.is_pressed(key_event) { + tracing::info!("Cancel onboarding auth step"); + let mut sign_in_state = self.sign_in_state.write().unwrap(); + match &*sign_in_state { + SignInState::ChatGptContinueInBrowser(_) => { + *sign_in_state = SignInState::PickMode; + drop(sign_in_state); + self.request_frame.schedule_frame(); + } + SignInState::ChatGptDeviceCode(state) => { + if let Some(cancel) = &state.cancel { + cancel.notify_one(); + } + *sign_in_state = SignInState::PickMode; + drop(sign_in_state); + self.request_frame.schedule_frame(); + } + _ => {} } - _ => {} } } @@ -170,9 +185,32 @@ pub(crate) struct AuthModeWidget { pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, pub animations_enabled: bool, + pub onboarding_keymap: OnboardingKeymap, } impl AuthModeWidget { + /// 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)) } @@ -342,11 +380,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 { lines.push("".into()); lines.push(err.as_str().red().into()); @@ -381,14 +419,20 @@ 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(), ])); lines.push("".into()); } - 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); @@ -417,7 +461,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) @@ -496,8 +544,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 { footer_lines.push("".into()); @@ -515,46 +571,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.error = None; + if self.onboarding_keymap.cancel.is_pressed(*key_event) { + *guard = SignInState::PickMode; + self.error = 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.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.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.error = 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.error = None; + should_request_frame = true; } - self.error = 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.error = None; - should_request_frame = true; - } - _ => {} } // handled; let guard drop before potential save } else { @@ -812,6 +868,7 @@ mod tests { forced_chatgpt_workspace_id: None, forced_login_method: Some(ForcedLoginMethod::Chatgpt), animations_enabled: true, + 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 f4949fe13f..9b33c68879 100644 --- a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs +++ b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs @@ -181,7 +181,11 @@ pub(super) fn render_device_code_login( lines.push("".into()); } - 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 127ac9f667..21a8b44d41 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,3 +1,15 @@ +//! 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_core::AuthManager; use codex_core::config::Config; #[cfg(target_os = "windows")] @@ -7,6 +19,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; @@ -17,6 +30,9 @@ use ratatui::widgets::WidgetRef; use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::OnboardingKeymap; +use crate::keymap::RuntimeKeymap; use crate::onboarding::auth::AuthModeWidget; use crate::onboarding::auth::SignInOption; use crate::onboarding::auth::SignInState; @@ -56,6 +72,7 @@ pub(crate) trait StepStateProvider { pub(crate) struct OnboardingScreen { request_frame: FrameRequester, steps: Vec, + onboarding_keymap: OnboardingKeymap, is_done: bool, should_exit: bool, } @@ -73,6 +90,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) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self { let OnboardingScreenArgs { @@ -87,11 +112,22 @@ impl OnboardingScreen { let forced_login_method = config.forced_login_method; let codex_home = config.codex_home.clone(); let cli_auth_credentials_store_mode = config.cli_auth_credentials_store_mode; + 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 { @@ -110,6 +146,7 @@ impl OnboardingScreen { forced_chatgpt_workspace_id, forced_login_method, animations_enabled: config.animations, + onboarding_keymap: onboarding_keymap.clone(), })) } #[cfg(target_os = "windows")] @@ -127,12 +164,14 @@ impl OnboardingScreen { selection: None, highlighted, error: None, + onboarding_keymap: onboarding_keymap.clone(), })) } // TODO: add git warning. Self { request_frame: tui.frame_requester(), steps, + onboarding_keymap, is_done: false, should_exit: false, } @@ -199,45 +238,39 @@ impl OnboardingScreen { self.should_exit } - 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() { // If the user cancels the auth menu, exit the app rather than @@ -282,6 +315,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) { Clear.render(area, buf); @@ -461,3 +512,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 87cdac5236..dc83975a29 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -15,6 +15,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)] @@ -88,7 +92,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 { @@ -108,20 +114,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(), - }, - _ => {} + } } } } @@ -183,6 +191,7 @@ mod tests { selection: None, highlighted: TrustDirectorySelection::Quit, error: None, + onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding, }; let release = KeyEvent { @@ -208,6 +217,7 @@ mod tests { selection: None, highlighted: TrustDirectorySelection::Trust, error: None, + onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding, }; let mut terminal = Terminal::new(VT100Backend::new(70, 14)).expect("terminal"); diff --git a/codex-rs/tui/src/onboarding/welcome.rs b/codex-rs/tui/src/onboarding/welcome.rs index bdf4f91408..11833b72ec 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; @@ -27,17 +28,21 @@ pub(crate) struct WelcomeWidget { pub is_logged_in: bool, animation: AsciiAnimation, animations_enabled: bool, + 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(); @@ -50,11 +55,13 @@ 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, + toggle_animation_keys: onboarding_keymap.toggle_animation, layout_area: Cell::new(None), } } @@ -108,6 +115,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; @@ -128,7 +137,12 @@ mod tests { #[test] fn welcome_renders_animation_on_first_draw() { - let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true); + let widget = WelcomeWidget::new( + false, + FrameRequester::test_dummy(), + true, + crate::keymap::RuntimeKeymap::defaults().onboarding, + ); let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT); let mut buf = Buffer::empty(area); let frame_lines = widget.animation.current_frame().lines().count() as u16; @@ -140,7 +154,12 @@ mod tests { #[test] fn welcome_skips_animation_below_height_breakpoint() { - let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true); + let widget = WelcomeWidget::new( + false, + FrameRequester::test_dummy(), + 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); (&widget).render(area, &mut buf); @@ -155,6 +174,9 @@ mod tests { is_logged_in: false, animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0), animations_enabled: true, + toggle_animation_keys: crate::keymap::RuntimeKeymap::defaults() + .onboarding + .toggle_animation, layout_area: Cell::new(None), }; @@ -167,4 +189,29 @@ 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, + 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 f04310cd86..398154ab08 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; } _ => { @@ -453,12 +437,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, None), "T R A N S C R I P T".to_string(), usize::MAX, + keymap, ), cells: transcript_cells, highlight_cell: None, @@ -656,15 +641,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); } @@ -682,7 +703,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(()) } @@ -713,14 +736,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, 0), + view: PagerView::new(renderables, title, 0, keymap), is_done: false, } } @@ -728,8 +763,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); } @@ -746,7 +808,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(()) } @@ -839,9 +901,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")], })]); @@ -859,7 +946,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)); @@ -879,7 +966,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")], }), @@ -898,7 +985,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( @@ -919,7 +1006,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")], })]); @@ -1012,7 +1099,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); @@ -1026,7 +1113,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 { @@ -1053,7 +1140,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 { @@ -1078,9 +1165,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())) @@ -1116,7 +1203,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 { @@ -1184,9 +1271,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())) @@ -1196,9 +1283,9 @@ mod tests { #[test] fn pager_view_content_height_counts_renderables() { - let pv = PagerView::new( + let pv = pager_view( vec![paragraph_block("a", 2), paragraph_block("b", 3)], - "T".to_string(), + "T", 0, ); @@ -1207,13 +1294,13 @@ 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", 1), paragraph_block("b", 3), paragraph_block("c", 3), ], - "T".to_string(), + "T", 0, ); let area = Rect::new(0, 0, 20, 8); @@ -1242,13 +1329,13 @@ 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", 2), paragraph_block("b", 3), paragraph_block("c", 3), ], - "T".to_string(), + "T", 0, ); let area = Rect::new(0, 0, 20, 3); @@ -1261,7 +1348,7 @@ mod tests { #[test] fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() { - let mut pv = PagerView::new(vec![paragraph_block("a", 10)], "T".to_string(), 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);