feat(tui): wire runtime keymap into event handling

This commit is contained in:
Josh McKinney
2026-02-19 16:24:18 -08:00
parent dc1d5a0831
commit 6970958332
15 changed files with 1216 additions and 682 deletions
+1
View File
@@ -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:
+76 -53
View File
@@ -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<AtomicBool>,
@@ -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<dyn HistoryCell>,
];
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;
+44 -51
View File
@@ -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<bool> {
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();
}
+243 -80
View File
@@ -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<dyn Renderable>,
_features: &Features,
features: &Features,
approval_keymap: &ApprovalKeymap,
) -> (Vec<ApprovalOption>, 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<KeyBinding>,
additional_shortcuts: Vec<KeyBinding>,
}
impl ApprovalOption {
fn shortcuts(&self) -> impl Iterator<Item = KeyBinding> + '_ {
self.display_shortcut
.into_iter()
.chain(self.additional_shortcuts.iter().copied())
}
shortcuts: Vec<KeyBinding>,
}
fn exec_options(
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
network_approval_context: Option<&NetworkApprovalContext>,
_features: &Features,
keymap: &ApprovalKeymap,
) -> Vec<ApprovalOption> {
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<ApprovalOption> {
fn patch_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
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<ApprovalOption> {
/// 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<ApprovalOption> {
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<KeyBinding> = 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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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<String> = 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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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!(
+75 -5
View File
@@ -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<Line<'static>>,
status_line_enabled: bool,
submit_keys: Vec<KeyBinding>,
queue_keys: Vec<KeyBinding>,
toggle_shortcuts_keys: Vec<KeyBinding>,
editor_keymap: EditorKeymap,
footer_external_editor_key: Option<KeyBinding>,
footer_edit_previous_key: Option<KeyBinding>,
footer_show_transcript_key: Option<KeyBinding>,
}
#[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<CollaborationModeIndicator>,
@@ -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::<AppEvent>();
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]
@@ -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<usize>,
header: Box<dyn Renderable>,
initial_selected_idx: Option<usize>,
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::<AppEvent>();
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);
+28 -3
View File
@@ -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<i64>,
context_window_used_tokens: Option<i64>,
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));
}
+141 -213
View File
@@ -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<TextElement>,
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+<char> (e.g. ESC f/ESC b)
// for word navigation. Those are handled explicitly below.
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
..
} => self.insert_str(&c.to_string()),
KeyEvent {
code: KeyCode::Char('j' | 'm'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Enter,
..
} => self.insert_str("\n"),
KeyEvent {
code: KeyCode::Char('h'),
modifiers,
..
} if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => {
self.delete_backward_word()
},
// Windows AltGr generates ALT|CONTROL; treat as a plain character input unless
// we match a specific Control+Alt binding above.
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if is_altgr(modifiers) => self.insert_str(&c.to_string()),
KeyEvent {
code: KeyCode::Backspace,
modifiers: KeyModifiers::ALT,
..
} => self.delete_backward_word(),
KeyEvent {
code: KeyCode::Backspace,
..
}
| KeyEvent {
code: KeyCode::Char('h'),
modifiers: KeyModifiers::CONTROL,
..
} => self.delete_backward(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+<char>.
self.insert_str(&c.to_string());
return;
}
#[cfg(feature = "debug-logs")]
tracing::debug!("Unhandled key event in TextArea: {:?}", event);
}
// ####### Input Functions #######
+10
View File
@@ -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));
+147 -90
View File
@@ -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<String>,
pub forced_login_method: Option<ForcedLoginMethod>,
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<Line> = 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)
}
@@ -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);
+139 -31
View File
@@ -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<Step>,
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<Step> = 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);
}
}
+23 -13
View File
@@ -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<TrustDirectorySelection>,
pub highlighted: TrustDirectorySelection,
pub error: Option<String>,
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");
+54 -7
View File
@@ -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<KeyBinding>,
layout_area: Cell<Option<Rect>>,
}
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"
);
}
}
+170 -83
View File
@@ -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<Arc<dyn HistoryCell>>) -> Self {
Self::Transcript(TranscriptOverlay::new(cells))
pub(crate) fn new_transcript(cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
Self::Transcript(TranscriptOverlay::new(cells, keymap))
}
pub(crate) fn new_static_with_lines(lines: Vec<Line<'static>>, title: String) -> Self {
Self::Static(StaticOverlay::with_title(lines, title))
pub(crate) fn new_static_with_lines(
lines: Vec<Line<'static>>,
title: String,
keymap: PagerKeymap,
) -> Self {
Self::Static(StaticOverlay::with_title(lines, title, keymap))
}
pub(crate) fn new_static_with_renderables(
renderables: Vec<Box<dyn Renderable>>,
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<KeyBinding> {
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<KeyBinding>, &str)]) {
let mut spans: Vec<Span<'static>> = vec![" ".into()];
let mut first = true;
for (keys, desc) in pairs {
@@ -136,6 +116,7 @@ struct PagerView {
renderables: Vec<Box<dyn Renderable>>,
scroll_offset: usize,
title: String,
keymap: PagerKeymap,
last_content_height: Option<usize>,
last_rendered_height: Option<usize>,
/// If set, on next render ensure this chunk is visible.
@@ -143,11 +124,17 @@ struct PagerView {
}
impl PagerView {
fn new(renderables: Vec<Box<dyn Renderable>>, title: String, scroll_offset: usize) -> Self {
fn new(
renderables: Vec<Box<dyn Renderable>>,
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<Arc<dyn HistoryCell>>) -> Self {
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>, 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<KeyBinding>, &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<Line<'static>>, title: String) -> Self {
pub(crate) fn with_title(
lines: Vec<Line<'static>>,
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<Box<dyn Renderable>>, title: String) -> Self {
pub(crate) fn with_renderables(
renderables: Vec<Box<dyn Renderable>>,
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<KeyBinding>, &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<dyn Renderable>
}
fn default_pager_keymap() -> crate::keymap::PagerKeymap {
crate::keymap::RuntimeKeymap::defaults().pager
}
fn transcript_overlay(cells: Vec<Arc<dyn HistoryCell>>) -> TranscriptOverlay {
TranscriptOverlay::new(cells, default_pager_keymap())
}
fn static_overlay(lines: Vec<Line<'static>>, title: &str) -> StaticOverlay {
StaticOverlay::with_title(lines, title.to_string(), default_pager_keymap())
}
fn pager_view(
renderables: Vec<Box<dyn Renderable>>,
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<dyn HistoryCell> = 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);