feat(tui): wire runtime keymap into event handling

This commit is contained in:
Josh McKinney
2026-04-23 10:35:16 -03:00
committed by Felipe Coury
parent ef07eb7efd
commit 1655c2e37f
19 changed files with 1659 additions and 738 deletions
+1
View File
@@ -47,6 +47,7 @@ In the codex-rs folder where the rust code lives:
- Avoid adding new standalone methods to `codex-rs/tui/src/chatwidget.rs` unless the change is
trivial; prefer new modules/files and keep `chatwidget.rs` focused on orchestration.
- When running Rust commands (e.g. `just fix` or `cargo test`) be patient with the command and never try to kill them using the PID. Rust lock can make the execution slow, this is expected.
- For TUI keybind changes, do not mutate old preset defaults; add a new preset version, keep `latest` as an explicit pointer (currently to `v1`), and update `docs/default-keymap.toml` plus `docs/config.md` with migration notes (`TODO(docs): mirror this on developers.openai.com`).
Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests:
+10
View File
@@ -41,6 +41,7 @@ use crate::history_cell;
use crate::history_cell::HistoryCell;
#[cfg(not(debug_assertions))]
use crate::history_cell::UpdateAvailableHistoryCell;
use crate::keymap::RuntimeKeymap;
use crate::legacy_core::append_message_history_entry;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
@@ -510,6 +511,7 @@ pub(crate) struct App {
has_emitted_history_lines: bool,
pub(crate) enhanced_keys_supported: bool,
pub(crate) keymap: RuntimeKeymap,
/// Controls the animation thread that sends CommitTick events.
pub(crate) commit_anim_running: Arc<AtomicBool>,
@@ -873,6 +875,13 @@ impl App {
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).map_err(|err| {
color_eyre::eyre::eyre!(
"Invalid `tui.keymap` configuration: {err}\n\
Fix the config and retry.\n\
Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml"
)
})?;
#[cfg(not(debug_assertions))]
let upgrade_version = crate::updates::get_upgrade_version(&config);
@@ -889,6 +898,7 @@ impl App {
runtime_sandbox_policy_override: None,
file_search,
enhanced_keys_supported,
keymap: runtime_keymap,
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
+5
View File
@@ -271,6 +271,7 @@ impl App {
self.overlay = Some(Overlay::new_static_with_lines(
pager_lines,
"D I F F".to_string(),
self.keymap.pager.clone(),
));
tui.frame_requester().schedule_frame();
}
@@ -1494,6 +1495,7 @@ impl App {
self.overlay = Some(Overlay::new_static_with_renderables(
vec![diff_summary.into()],
"P A T C H".to_string(),
self.keymap.pager.clone(),
));
}
ApprovalRequest::Exec { command, .. } => {
@@ -1503,6 +1505,7 @@ impl App {
self.overlay = Some(Overlay::new_static_with_lines(
full_cmd_lines,
"E X E C".to_string(),
self.keymap.pager.clone(),
));
}
ApprovalRequest::Permissions {
@@ -1527,6 +1530,7 @@ impl App {
self.overlay = Some(Overlay::new_static_with_renderables(
vec![Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))],
"P E R M I S S I O N S".to_string(),
self.keymap.pager.clone(),
));
}
ApprovalRequest::McpElicitation {
@@ -1544,6 +1548,7 @@ impl App {
self.overlay = Some(Overlay::new_static_with_renderables(
vec![Box::new(paragraph)],
"E L I C I T A T I O N".to_string(),
self.keymap.pager.clone(),
));
}
},
+59 -57
View File
@@ -4,6 +4,7 @@
//! entry, Ctrl-L clear, external editor launch, and agent navigation shortcuts.
use super::*;
use crate::key_hint::KeyBindingListExt;
impl App {
pub(super) async fn launch_external_editor(&mut self, tui: &mut tui::Tui) {
@@ -122,18 +123,62 @@ impl App {
return;
}
match key_event {
KeyEvent {
code: KeyCode::Char('t'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
// Enter alternate screen and set viewport to full size.
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
tui.frame_requester().schedule_frame();
if self.keymap.app.open_transcript.is_pressed(key_event) {
// Enter alternate screen and set viewport to full size.
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(
self.transcript_cells.clone(),
self.keymap.pager.clone(),
));
tui.frame_requester().schedule_frame();
return;
}
if self.keymap.app.open_external_editor.is_pressed(key_event) {
// Only launch the external editor if there is no overlay and the
// bottom pane is not in use. Note that it can be launched while a
// task is running to enable editing while the previous turn is
// ongoing.
if self.overlay.is_none()
&& self.chat_widget.can_launch_external_editor()
&& self.chat_widget.external_editor_state() == ExternalEditorState::Closed
{
self.request_external_editor_launch(tui);
}
return;
}
if self.keymap.chat.edit_previous_message.is_pressed(key_event) {
// Esc primes/advances backtracking only in normal (not working) mode
// with the composer focused and empty. In any other state, forward
// Esc so the active UI (e.g. status indicator, modals, popups)
// handles it.
if self.chat_widget.is_normal_backtrack_mode() && self.chat_widget.composer_is_empty() {
self.handle_backtrack_esc_key(tui);
} else {
self.chat_widget.handle_key_event(key_event);
}
return;
}
if key_event.kind == KeyEventKind::Press
&& self
.keymap
.chat
.confirm_edit_previous_message
.is_pressed(key_event)
&& self.backtrack.primed
&& self.backtrack.nth_user_message != usize::MAX
&& self.chat_widget.composer_is_empty()
{
// Confirm backtrack when primed + count > 0.
if let Some(selection) = self.confirm_backtrack_from_main() {
self.apply_backtrack_selection(tui, selection);
}
return;
}
match key_event {
KeyEvent {
code: KeyCode::Char('l'),
modifiers: KeyModifiers::CONTROL,
@@ -153,51 +198,6 @@ impl App {
tui.frame_requester().schedule_frame();
}
}
KeyEvent {
code: KeyCode::Char('g'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
// Only launch the external editor if there is no overlay and the bottom pane is not in use.
// Note that it can be launched while a task is running to enable editing while the previous turn is ongoing.
if self.overlay.is_none()
&& self.chat_widget.can_launch_external_editor()
&& self.chat_widget.external_editor_state() == ExternalEditorState::Closed
{
self.request_external_editor_launch(tui);
}
}
// Esc primes/advances backtracking only in normal (not working) mode
// with the composer focused and empty. In any other state, forward
// Esc so the active UI (e.g. status indicator, modals, popups)
// handles it.
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
if self.chat_widget.is_normal_backtrack_mode()
&& self.chat_widget.composer_is_empty()
{
self.handle_backtrack_esc_key(tui);
} else {
self.chat_widget.handle_key_event(key_event);
}
}
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
KeyEvent {
code: KeyCode::Enter,
kind: KeyEventKind::Press,
..
} if self.backtrack.primed
&& self.backtrack.nth_user_message != usize::MAX
&& self.chat_widget.composer_is_empty() =>
{
if let Some(selection) = self.confirm_backtrack_from_main() {
self.apply_backtrack_selection(tui, selection);
}
}
KeyEvent {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
@@ -205,7 +205,9 @@ impl App {
// Any non-Esc key press should cancel a primed backtrack.
// This avoids stale "Esc-primed" state after the user starts typing
// (even if they later backspace to empty).
if key_event.code != KeyCode::Esc && self.backtrack.primed {
if !self.keymap.chat.edit_previous_message.is_pressed(key_event)
&& self.backtrack.primed
{
self.reset_backtrack_state();
}
self.chat_widget.handle_key_event(key_event);
+3
View File
@@ -13,6 +13,8 @@ pub(super) async fn make_test_app() -> App {
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
let session_telemetry = test_session_telemetry(&config, model.as_str());
let keymap = RuntimeKeymap::from_config(&config.tui_keymap)
.expect("test config should always produce a valid runtime keymap");
App {
model_catalog: chat_widget.model_catalog(),
@@ -26,6 +28,7 @@ pub(super) async fn make_test_app() -> App {
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
file_search,
keymap,
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
+8 -2
View File
@@ -4570,7 +4570,10 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() {
/*is_first_line*/ 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;
@@ -4796,7 +4799,10 @@ async fn clear_only_ui_reset_preserves_chat_session_state() {
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<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.has_emitted_history_lines = true;
app.backtrack.primed = true;
+44 -51
View File
@@ -34,14 +34,13 @@ use crate::app_event::AppEvent;
use crate::history_cell::AgentMessageCell;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::UserHistoryCell;
use crate::key_hint::KeyBindingListExt;
use crate::pager_overlay::Overlay;
use crate::tui;
use crate::tui::TuiEvent;
use codex_protocol::ThreadId;
use codex_protocol::user_input::TextElement;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
/// Aggregates all backtrack-related state used by the App.
@@ -109,60 +108,51 @@ impl App {
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<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();
}
+277 -131
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;
@@ -14,6 +27,9 @@ use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ApprovalKeymap;
use crate::keymap::ListKeymap;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
@@ -38,7 +54,6 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
@@ -143,19 +158,29 @@ pub(crate) struct ApprovalOverlay {
current_complete: bool,
done: bool,
features: Features,
approval_keymap: ApprovalKeymap,
list_keymap: ListKeymap,
}
impl ApprovalOverlay {
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender, features: Features) -> Self {
pub fn new(
request: ApprovalRequest,
app_event_tx: AppEventSender,
features: Features,
approval_keymap: ApprovalKeymap,
list_keymap: ListKeymap,
) -> Self {
let mut view = Self {
current_request: None,
queue: Vec::new(),
app_event_tx: app_event_tx.clone(),
list: ListSelectionView::new(Default::default(), app_event_tx),
list: ListSelectionView::new(Default::default(), app_event_tx, list_keymap.clone()),
options: Vec::new(),
current_complete: false,
done: false,
features,
approval_keymap,
list_keymap,
};
view.set_current(request);
view
@@ -185,16 +210,19 @@ impl ApprovalOverlay {
fn set_current(&mut self, request: ApprovalRequest) {
self.current_complete = false;
let header = build_header(&request);
let (options, params) = Self::build_options(&request, header, &self.features);
let (options, params) =
Self::build_options(&request, header, &self.features, &self.approval_keymap);
self.current_request = Some(request);
self.options = options;
self.list = ListSelectionView::new(params, self.app_event_tx.clone());
self.list =
ListSelectionView::new(params, self.app_event_tx.clone(), self.list_keymap.clone());
}
fn build_options(
request: &ApprovalRequest,
header: Box<dyn Renderable>,
_features: &Features,
approval_keymap: &ApprovalKeymap,
) -> (Vec<ApprovalOption>, SelectionViewParams) {
let (options, title) = match request {
ApprovalRequest::Exec {
@@ -207,6 +235,7 @@ impl ApprovalOverlay {
available_decisions,
network_approval_context.as_ref(),
additional_permissions.as_ref(),
approval_keymap,
),
network_approval_context.as_ref().map_or_else(
|| "Would you like to run the following command?".to_string(),
@@ -219,15 +248,15 @@ impl ApprovalOverlay {
),
),
ApprovalRequest::Permissions { .. } => (
permissions_options(),
permissions_options(approval_keymap),
"Would you like to grant these permissions?".to_string(),
),
ApprovalRequest::ApplyPatch { .. } => (
patch_options(),
patch_options(approval_keymap),
"Would you like to make the following edits?".to_string(),
),
ApprovalRequest::McpElicitation { server_name, .. } => (
elicitation_options(),
elicitation_options(approval_keymap),
format!("{server_name} needs your approval."),
),
};
@@ -242,9 +271,7 @@ impl ApprovalOverlay {
.iter()
.map(|opt| SelectionItem {
name: opt.label.clone(),
display_shortcut: opt
.display_shortcut
.or_else(|| opt.additional_shortcuts.first().copied()),
display_shortcut: opt.shortcuts.first().copied(),
dismiss_on_select: false,
..Default::default()
})
@@ -411,51 +438,39 @@ impl ApprovalOverlay {
}
}
/// Apply approval-specific shortcuts before delegating to list navigation.
///
/// `open_fullscreen` is handled here because it is orthogonal to list item
/// selection and should work regardless of current highlighted row.
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
match key_event {
KeyEvent {
kind: KeyEventKind::Press,
code: KeyCode::Char('a'),
modifiers,
..
} if modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(request) = self.current_request.as_ref() {
self.app_event_tx
.send(AppEvent::FullScreenApprovalRequest(request.clone()));
true
} else {
false
}
}
KeyEvent {
kind: KeyEventKind::Press,
code: KeyCode::Char('o'),
..
} => {
if let Some(request) = self.current_request.as_ref() {
if request.thread_label().is_some() {
self.app_event_tx
.send(AppEvent::SelectAgentThread(request.thread_id()));
true
} else {
false
}
} else {
false
}
}
e => {
if let Some(idx) = self
.options
.iter()
.position(|opt| opt.shortcuts().any(|s| s.is_press(*e)))
{
self.apply_selection(idx);
true
} else {
false
}
}
if key_event.kind == KeyEventKind::Press
&& self.approval_keymap.open_fullscreen.is_pressed(*key_event)
&& let Some(request) = self.current_request.as_ref()
{
self.app_event_tx
.send(AppEvent::FullScreenApprovalRequest(request.clone()));
return true;
}
if key_event.kind == KeyEventKind::Press
&& matches!(key_event.code, KeyCode::Char('o'))
&& let Some(request) = self.current_request.as_ref()
&& request.thread_label().is_some()
{
self.app_event_tx
.send(AppEvent::SelectAgentThread(request.thread_id()));
return true;
}
if let Some(idx) = self
.options
.iter()
.position(|opt| opt.shortcuts.iter().any(|s| s.is_press(*key_event)))
{
self.apply_selection(idx);
true
} else {
false
}
}
}
@@ -704,22 +719,14 @@ enum PermissionsDecision {
struct ApprovalOption {
label: String,
decision: ApprovalDecision,
display_shortcut: Option<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(
available_decisions: &[ReviewDecision],
network_approval_context: Option<&NetworkApprovalContext>,
additional_permissions: Option<&PermissionProfile>,
keymap: &ApprovalKeymap,
) -> Vec<ApprovalOption> {
available_decisions
.iter()
@@ -731,8 +738,7 @@ fn exec_options(
"Yes, proceed".to_string()
},
decision: ApprovalDecision::Review(ReviewDecision::Approved),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
shortcuts: keymap.approve.clone(),
}),
ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment,
@@ -752,8 +758,7 @@ fn exec_options(
proposed_execpolicy_amendment: proposed_execpolicy_amendment.clone(),
},
),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))],
shortcuts: keymap.approve_for_prefix.clone(),
})
}
ReviewDecision::ApprovedForSession => Some(ApprovalOption {
@@ -765,20 +770,18 @@ fn exec_options(
"Yes, and don't ask again for this command in this session".to_string()
},
decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
shortcuts: keymap.approve_for_session.clone(),
}),
ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment,
} => {
let (label, shortcut) = match network_policy_amendment.action {
NetworkPolicyRuleAction::Allow => (
"Yes, and allow this host in the future".to_string(),
KeyCode::Char('p'),
),
let (label, shortcuts) = match network_policy_amendment.action {
NetworkPolicyRuleAction::Allow => {
("Yes, and allow this host in the future".to_string(), keymap.approve_for_prefix.clone())
}
NetworkPolicyRuleAction::Deny => (
"No, and block this host in the future".to_string(),
KeyCode::Char('d'),
Vec::new(),
),
};
Some(ApprovalOption {
@@ -786,22 +789,19 @@ fn exec_options(
decision: ApprovalDecision::Review(ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment: network_policy_amendment.clone(),
}),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(shortcut)],
shortcuts,
})
}
ReviewDecision::Denied => Some(ApprovalOption {
label: "No, continue without running it".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Denied),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('d'))],
shortcuts: Vec::new(),
}),
ReviewDecision::TimedOut => None,
ReviewDecision::Abort => Some(ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Abort),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
shortcuts: keymap.decline.clone(),
}),
})
.collect()
@@ -893,79 +893,90 @@ fn path_label(base: &str, subpath: &Option<PathBuf>) -> String {
}
}
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 permissions_options() -> Vec<ApprovalOption> {
fn permissions_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Yes, grant these permissions for this turn".to_string(),
decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForTurn),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
shortcuts: keymap.approve.clone(),
},
ApprovalOption {
label: "Yes, grant for this turn with strict auto review".to_string(),
decision: ApprovalDecision::Permissions(
PermissionsDecision::GrantForTurnWithStrictAutoReview,
),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('r'))],
shortcuts: vec![key_hint::plain(KeyCode::Char('r'))],
},
ApprovalOption {
label: "Yes, grant these permissions for this session".to_string(),
decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForSession),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
shortcuts: keymap.approve_for_session.clone(),
},
ApprovalOption {
label: "No, continue without permissions".to_string(),
decision: ApprovalDecision::Permissions(PermissionsDecision::Deny),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
shortcuts: keymap.decline.clone(),
},
]
}
fn elicitation_options() -> Vec<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,
},
]
}
@@ -983,6 +994,7 @@ mod tests {
use codex_protocol::protocol::NetworkApprovalProtocol;
use codex_protocol::protocol::NetworkPolicyAmendment;
use codex_utils_absolute_path::AbsolutePathBuf;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
@@ -1018,6 +1030,37 @@ mod tests {
})
}
fn make_overlay(
request: ApprovalRequest,
app_event_tx: AppEventSender,
features: Features,
) -> ApprovalOverlay {
let keymap = crate::keymap::RuntimeKeymap::defaults();
make_overlay_with_keymap(
request,
app_event_tx,
features,
keymap.approval,
keymap.list,
)
}
fn make_overlay_with_keymap(
request: ApprovalRequest,
app_event_tx: AppEventSender,
features: Features,
approval_keymap: ApprovalKeymap,
list_keymap: ListKeymap,
) -> ApprovalOverlay {
ApprovalOverlay::new(
request,
app_event_tx,
features,
approval_keymap,
list_keymap,
)
}
fn make_exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
thread_id: ThreadId::new(),
@@ -1049,11 +1092,19 @@ mod tests {
}
}
fn make_elicitation_request() -> ApprovalRequest {
ApprovalRequest::McpElicitation {
server_name: "test-server".to_string(),
request_id: RequestId::String("request-1".to_string()),
message: "Need more information".to_string(),
}
}
#[test]
fn ctrl_c_aborts_and_clears_queue() {
let (tx, _rx) = unbounded_channel::<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());
@@ -1064,7 +1115,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 thread-scoped approval op message in the queue.
@@ -1104,7 +1155,7 @@ mod tests {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let thread_id = ThreadId::new();
let mut view = ApprovalOverlay::new(
let mut view = make_overlay(
ApprovalRequest::Exec {
thread_id,
thread_label: Some("Robie [explorer]".to_string()),
@@ -1132,7 +1183,7 @@ mod tests {
fn cross_thread_footer_hint_mentions_o_shortcut() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let view = ApprovalOverlay::new(
let view = make_overlay(
ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: Some("Robie [explorer]".to_string()),
@@ -1157,7 +1208,7 @@ mod tests {
fn exec_prefix_option_emits_execpolicy_amendment() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = ApprovalOverlay::new(
let mut view = make_overlay(
ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: None,
@@ -1209,7 +1260,7 @@ mod tests {
fn network_deny_forever_shortcut_is_not_bound() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = ApprovalOverlay::new(
let mut view = make_overlay(
ApprovalRequest::Exec {
thread_id: ThreadId::new(),
thread_label: None,
@@ -1260,12 +1311,9 @@ mod tests {
additional_permissions: None,
};
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(/*width*/ 80)));
view.render(
Rect::new(0, 0, 80, view.desired_height(/*width*/ 80)),
&mut buf,
);
let view = make_overlay(exec_request, tx, Features::with_defaults());
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80)));
view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf);
let rendered: Vec<String> = (0..buf.area.height)
.map(|row| {
@@ -1288,6 +1336,7 @@ mod tests {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
};
let keymap = crate::keymap::RuntimeKeymap::defaults();
let options = exec_options(
&[
ReviewDecision::Approved,
@@ -1302,6 +1351,7 @@ mod tests {
],
Some(&network_context),
/*additional_permissions*/ None,
&keymap.approval,
);
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
@@ -1318,6 +1368,7 @@ mod tests {
#[test]
fn generic_exec_options_can_offer_allow_for_session() {
let keymap = crate::keymap::RuntimeKeymap::defaults();
let options = exec_options(
&[
ReviewDecision::Approved,
@@ -1326,6 +1377,7 @@ mod tests {
],
/*network_approval_context*/ None,
/*additional_permissions*/ None,
&keymap.approval,
);
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
@@ -1341,6 +1393,7 @@ mod tests {
#[test]
fn additional_permissions_exec_options_hide_execpolicy_amendment() {
let keymap = crate::keymap::RuntimeKeymap::defaults();
let additional_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![absolute_path("/tmp/readme.txt")]),
@@ -1352,6 +1405,7 @@ mod tests {
&[ReviewDecision::Approved, ReviewDecision::Abort],
/*network_approval_context*/ None,
Some(&additional_permissions),
&keymap.approval,
);
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
@@ -1366,7 +1420,8 @@ mod tests {
#[test]
fn permissions_options_use_expected_labels() {
let labels: Vec<String> = permissions_options()
let keymap = crate::keymap::RuntimeKeymap::defaults();
let labels: Vec<String> = permissions_options(&keymap.approval)
.into_iter()
.map(|option| option.label)
.collect();
@@ -1414,8 +1469,7 @@ mod tests {
fn permissions_session_shortcut_submits_session_scope() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view =
ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults());
let mut view = make_overlay(make_permissions_request(), tx, Features::with_defaults());
view.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
@@ -1488,12 +1542,9 @@ mod tests {
}),
};
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
let mut buf = Buffer::empty(Rect::new(0, 0, 120, view.desired_height(/*width*/ 120)));
view.render(
Rect::new(0, 0, 120, view.desired_height(/*width*/ 120)),
&mut buf,
);
let view = make_overlay(exec_request, tx, Features::with_defaults());
let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(100)));
view.render(Rect::new(0, 0, 100, view.desired_height(100)), &mut buf);
let rendered: Vec<String> = (0..buf.area.height)
.map(|row| {
@@ -1538,7 +1589,7 @@ mod tests {
}),
};
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
let view = make_overlay(exec_request, tx, Features::with_defaults());
assert_snapshot!(
"approval_overlay_additional_permissions_prompt",
normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120))
@@ -1549,7 +1600,7 @@ mod tests {
fn permissions_prompt_snapshot() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let view = ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults());
let view = make_overlay(make_permissions_request(), tx, Features::with_defaults());
assert_snapshot!(
"approval_overlay_permissions_prompt",
normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120))
@@ -1584,7 +1635,7 @@ mod tests {
additional_permissions: None,
};
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
let view = make_overlay(exec_request, tx, Features::with_defaults());
let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)));
view.render(
Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)),
@@ -1616,6 +1667,27 @@ mod tests {
);
}
#[test]
fn ctrl_shift_a_opens_fullscreen() {
let (tx_raw, mut rx) = unbounded_channel::<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![
@@ -1647,11 +1719,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!(
+78 -5
View File
@@ -183,6 +183,13 @@ use super::slash_commands;
use super::slash_commands::BuiltinCommandFlags;
use crate::bottom_pane::paste_burst::FlushResult;
use crate::bottom_pane::prompt_args::parse_slash_name;
use crate::bottom_pane::prompt_args::prompt_argument_names;
use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders;
use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::EditorKeymap;
use crate::keymap::RuntimeKeymap;
use crate::keymap::primary_binding;
use crate::render::Insets;
use crate::render::RectExt;
use crate::render::renderable::Renderable;
@@ -386,6 +393,13 @@ pub(crate) struct ChatComposer {
// Agent label injected into the footer's contextual row when multi-agent mode is active.
active_agent_label: Option<String>,
history_search: Option<HistorySearchSession>,
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)]
@@ -533,6 +547,16 @@ impl ChatComposer {
side_conversation_context_label: None,
active_agent_label: None,
history_search: None,
submit_keys: vec![key_hint::plain(KeyCode::Enter)],
queue_keys: vec![key_hint::plain(KeyCode::Tab)],
toggle_shortcuts_keys: vec![
key_hint::plain(KeyCode::Char('?')),
key_hint::shift(KeyCode::Char('?')),
],
editor_keymap: RuntimeKeymap::defaults().editor,
footer_external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))),
footer_edit_previous_key: Some(key_hint::plain(KeyCode::Esc)),
footer_show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))),
};
// Apply configuration via the setter to keep side-effects centralized.
this.set_disable_paste_burst(disable_paste_burst);
@@ -606,6 +630,17 @@ impl ChatComposer {
self.fast_command_enabled = enabled;
}
pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.submit_keys = keymap.composer.submit.clone();
self.queue_keys = keymap.composer.queue.clone();
self.toggle_shortcuts_keys = keymap.composer.toggle_shortcuts.clone();
self.editor_keymap = keymap.editor.clone();
self.textarea.set_keymap_bindings(&self.editor_keymap);
self.footer_external_editor_key = primary_binding(&keymap.app.open_external_editor);
self.footer_edit_previous_key = primary_binding(&keymap.chat.edit_previous_message);
self.footer_show_transcript_key = primary_binding(&keymap.app.open_transcript);
}
pub fn set_collaboration_mode_indicator(
&mut self,
indicator: Option<CollaborationModeIndicator>,
@@ -1606,7 +1641,7 @@ impl ChatComposer {
if self.disable_paste_burst {
// When burst detection is disabled, treat IME/non-ASCII input as normal typing.
// In particular, do not retro-capture or buffer already-inserted prefix text.
self.textarea.input(input);
self.textarea.input_with_keymap(input, &self.editor_keymap);
let text_after = self.textarea.text();
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
@@ -1663,7 +1698,7 @@ impl ChatComposer {
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
self.textarea.input(input);
self.textarea.input_with_keymap(input, &self.editor_keymap);
let text_after = self.textarea.text();
self.pending_pastes
@@ -2846,6 +2881,15 @@ impl ChatComposer {
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
if self.queue_keys.is_pressed(key_event) && self.is_task_running {
return self.handle_submission(true);
}
if self.submit_keys.is_pressed(key_event) {
let should_queue = !self.steer_enabled;
return self.handle_submission(should_queue);
}
match key_event {
KeyEvent {
code: KeyCode::Char('d'),
@@ -3070,7 +3114,7 @@ impl ChatComposer {
return (InputResult::None, true);
}
self.textarea.input(input);
self.textarea.input_with_keymap(input, &self.editor_keymap);
self.sync_bash_mode_from_text();
if let Some(elements_before) = elements_before {
@@ -3146,13 +3190,18 @@ impl ChatComposer {
}
}
/// Handle the dedicated shortcut-overlay toggle key(s).
///
/// This only toggles when the composer is empty and no paste burst is in
/// progress, so typing/pasting `?` still inserts text instead of opening
/// help. The bound key list intentionally supports terminal-variant
/// modifier reporting (for example `?` vs `shift-?`).
fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool {
if key_event.kind != KeyEventKind::Press {
return false;
}
let toggles = matches!(key_event.code, KeyCode::Char('?'))
&& !has_ctrl_or_alt(key_event.modifiers)
let toggles = self.toggle_shortcuts_keys.is_pressed(*key_event)
&& self.is_empty()
&& !self.is_in_paste_burst();
@@ -5219,6 +5268,30 @@ mod tests {
assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft);
}
#[test]
fn shift_question_mark_toggles_shortcut_overlay_when_empty() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<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;
@@ -265,6 +267,7 @@ pub(crate) struct ListSelectionView {
/// Called when the picker is dismissed via Esc/Ctrl+C without selecting.
on_cancel: OnCancelCallback,
keymap: ListKeymap,
}
impl ListSelectionView {
@@ -275,7 +278,11 @@ impl ListSelectionView {
/// When search is enabled, rows without `search_value` will disappear as
/// soon as the query is non-empty, which can look like dropped data unless
/// callers intentionally populate that field.
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
pub fn new(
params: SelectionViewParams,
app_event_tx: AppEventSender,
keymap: ListKeymap,
) -> Self {
let mut header = params.header;
if params.title.is_some() || params.subtitle.is_some() {
let title = params.title.map(|title| Line::from(title.bold()));
@@ -330,6 +337,7 @@ impl ListSelectionView {
preserve_side_content_bg: params.preserve_side_content_bg,
on_selection_changed: params.on_selection_changed,
on_cancel: params.on_cancel,
keymap,
};
s.apply_filter();
if s.tabs_enabled() && !has_initial_selected_idx && s.state.selected_idx.is_none() {
@@ -746,23 +754,46 @@ impl ListSelectionView {
impl BottomPaneView for ListSelectionView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
let is_plain_text_char = matches!(
key_event,
KeyEvent {
code: KeyCode::Char(_),
modifiers,
..
} if !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT)
);
let allow_plain_char_navigation = !self.is_searchable || !is_plain_text_char;
let c0_ctrl_p = matches!(
key_event,
KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
}
);
let c0_ctrl_n = matches!(
key_event,
KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
}
);
match key_event {
// Some terminals (or configurations) send Control key chords as
// C0 control characters without reporting the CONTROL modifier.
// Handle fallbacks for Ctrl-P/N here so navigation works everywhere.
KeyEvent {
code: KeyCode::Up, ..
_ if c0_ctrl_p
|| (allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event)) =>
{
self.move_up()
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
_ if c0_ctrl_n
|| (allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event)) =>
{
self.move_down()
}
| KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} /* ^P */ => self.move_up(),
KeyEvent {
code: KeyCode::Left,
..
@@ -771,30 +802,6 @@ impl BottomPaneView for ListSelectionView {
code: KeyCode::Right,
..
} if self.tabs_enabled() => self.switch_tab(/*step*/ 1),
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
} if !self.is_searchable => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} /* ^N */ => self.move_down(),
KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} if !self.is_searchable => self.move_down(),
KeyEvent {
code: KeyCode::Backspace,
..
@@ -818,9 +825,7 @@ impl BottomPaneView for ListSelectionView {
} if self.is_searchable
&& self.search_query.is_empty()
&& self.selected_item_has_toggle_placeholder() => {}
KeyEvent {
code: KeyCode::Esc, ..
} => {
_ if self.keymap.cancel.is_pressed(key_event) => {
self.on_ctrl_c();
}
KeyEvent {
@@ -866,11 +871,7 @@ impl BottomPaneView for ListSelectionView {
self.accept();
}
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => self.accept(),
_ if self.keymap.accept.is_pressed(key_event) => self.accept(),
_ => {}
}
}
@@ -1255,6 +1256,10 @@ mod tests {
}
}
fn new_view(params: SelectionViewParams, tx: AppEventSender) -> ListSelectionView {
ListSelectionView::new(params, tx, crate::keymap::RuntimeKeymap::defaults().list)
}
fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
@@ -1274,7 +1279,7 @@ mod tests {
..Default::default()
},
];
ListSelectionView::new(
new_view(
SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
subtitle: subtitle.map(str::to_string),
@@ -1354,6 +1359,7 @@ mod tests {
..Default::default()
},
tx,
crate::keymap::RuntimeKeymap::defaults().list,
);
let before_scroll = render_lines_with_width(&view, width);
@@ -1469,7 +1475,7 @@ mod tests {
"Use /setup-default-sandbox".cyan(),
" to allow network access.".dim(),
]);
let view = ListSelectionView::new(
let view = new_view(
SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
footer_note: Some(footer_note),
@@ -1496,7 +1502,7 @@ mod tests {
dismiss_on_select: true,
..Default::default()
}];
let mut view = ListSelectionView::new(
let mut view = new_view(
SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
footer_hint: Some(standard_popup_hint_line()),
@@ -1855,7 +1861,7 @@ mod tests {
..Default::default()
},
];
let view = ListSelectionView::new(
let view = new_view(
SelectionViewParams {
title: Some("Approval".to_string()),
items,
@@ -1913,7 +1919,7 @@ mod tests {
..Default::default()
},
];
let view = ListSelectionView::new(
let view = new_view(
SelectionViewParams {
title: Some("Select Model and Effort".to_string()),
items,
@@ -1947,7 +1953,7 @@ mod tests {
..Default::default()
})
.collect();
let view = ListSelectionView::new(
let view = new_view(
SelectionViewParams {
title: Some("Debug".to_string()),
items,
@@ -1995,7 +2001,7 @@ mod tests {
..Default::default()
},
];
let view = ListSelectionView::new(
let view = new_view(
SelectionViewParams {
title: Some("Select Model and Effort".to_string()),
items,
@@ -2022,7 +2028,7 @@ mod tests {
..Default::default()
})
.collect();
let view = ListSelectionView::new(
let view = new_view(
SelectionViewParams {
title: Some("Debug".to_string()),
items,
@@ -2073,6 +2079,7 @@ mod tests {
..Default::default()
},
tx,
crate::keymap::RuntimeKeymap::defaults().list,
);
let before_scroll = render_lines_with_width(&view, /*width*/ 96);
@@ -2107,6 +2114,7 @@ mod tests {
..Default::default()
},
tx,
crate::keymap::RuntimeKeymap::defaults().list,
);
let before_scroll = render_lines_with_width(&view, width);
+28 -3
View File
@@ -23,6 +23,7 @@ use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals;
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::keymap::RuntimeKeymap;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
@@ -203,6 +204,7 @@ pub(crate) struct BottomPane {
pending_thread_approvals: PendingThreadApprovals,
context_window_percent: Option<i64>,
context_window_used_tokens: Option<i64>,
keymap: RuntimeKeymap,
}
pub(crate) struct BottomPaneParams {
@@ -236,6 +238,8 @@ impl BottomPane {
disable_paste_burst,
);
composer.set_frame_requester(frame_requester.clone());
let keymap = RuntimeKeymap::defaults();
composer.set_keymap_bindings(&keymap);
composer.set_skill_mentions(skills);
Self {
composer,
@@ -254,6 +258,7 @@ impl BottomPane {
animations_enabled,
context_window_percent: None,
context_window_used_tokens: None,
keymap,
}
}
@@ -301,6 +306,12 @@ impl BottomPane {
self.composer.record_pending_slash_command_history();
}
pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.keymap = keymap.clone();
self.composer.set_keymap_bindings(keymap);
self.request_redraw();
}
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
pub(crate) fn drain_pending_submission_state(&mut self) {
let _ = self.take_recent_submission_images_with_placeholders();
@@ -840,7 +851,11 @@ impl BottomPane {
/// Show a generic list selection view with the provided items.
pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) {
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
let view = list_selection_view::ListSelectionView::new(
params,
self.app_event_tx.clone(),
self.keymap.list.clone(),
);
self.push_view(Box::new(view));
}
@@ -859,7 +874,11 @@ impl BottomPane {
}
self.view_stack.pop();
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
let view = list_selection_view::ListSelectionView::new(
params,
self.app_event_tx.clone(),
self.keymap.list.clone(),
);
self.push_view(Box::new(view));
true
}
@@ -977,7 +996,13 @@ impl BottomPane {
};
// Otherwise create a new approval modal overlay.
let modal = ApprovalOverlay::new(request, self.app_event_tx.clone(), features.clone());
let modal = ApprovalOverlay::new(
request,
self.app_event_tx.clone(),
features.clone(),
self.keymap.approval.clone(),
self.keymap.list.clone(),
);
self.pause_status_timer_for_modal();
self.push_view(Box::new(modal));
}
+560 -214
View File
@@ -10,7 +10,10 @@
//! This module does not implement an Emacs-style multi-entry kill ring. It keeps only the most
//! recent killed span.
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_altgr;
use crate::keymap::EditorKeymap;
use crate::keymap::RuntimeKeymap;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement as UserTextElement;
use crossterm::event::KeyCode;
@@ -92,6 +95,10 @@ pub(crate) struct TextArea {
elements: Vec<TextElement>,
next_element_id: u64,
kill_buffer: String,
vim_enabled: bool,
vim_mode: VimMode,
vim_operator: Option<VimOperator>,
editor_keymap: EditorKeymap,
}
#[derive(Debug, Clone)]
@@ -106,6 +113,31 @@ pub(crate) struct TextAreaState {
scroll: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VimMode {
Normal,
Insert,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VimOperator {
Delete,
Yank,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VimMotion {
Left,
Right,
Up,
Down,
WordForward,
WordBackward,
WordEnd,
LineStart,
LineEnd,
}
impl TextArea {
pub fn new() -> Self {
Self {
@@ -116,9 +148,17 @@ impl TextArea {
elements: Vec::new(),
next_element_id: 1,
kill_buffer: String::new(),
vim_enabled: false,
vim_mode: VimMode::Insert,
vim_operator: None,
editor_keymap: RuntimeKeymap::defaults().editor,
}
}
pub fn set_keymap_bindings(&mut self, keymap: &EditorKeymap) {
self.editor_keymap = keymap.clone();
}
/// Replace the visible textarea text and clear any existing text elements.
///
/// This is the "fresh buffer" path for callers that want plain text with no placeholder
@@ -170,6 +210,38 @@ impl TextArea {
self.preferred_col = None;
}
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
self.vim_enabled = enabled;
self.vim_operator = None;
self.vim_mode = if enabled {
VimMode::Normal
} else {
VimMode::Insert
};
}
pub(crate) fn is_vim_enabled(&self) -> bool {
self.vim_enabled
}
pub(crate) fn allows_paste_burst(&self) -> bool {
!self.vim_enabled || self.vim_mode == VimMode::Insert
}
pub(crate) fn is_vim_insert(&self) -> bool {
self.vim_enabled && self.vim_mode == VimMode::Insert
}
pub(crate) fn vim_mode_label(&self) -> Option<&'static str> {
if !self.vim_enabled {
return None;
}
Some(match self.vim_mode {
VimMode::Normal => "Normal",
VimMode::Insert => "Insert",
})
}
pub fn text(&self) -> &str {
&self.text
}
@@ -319,243 +391,464 @@ impl TextArea {
if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return;
}
if self.vim_enabled {
self.handle_vim_input(event);
} else {
let keymap = self.editor_keymap.clone();
self.input_with_keymap(event, &keymap);
}
}
pub fn input_with_keymap(&mut self, event: KeyEvent, keymap: &EditorKeymap) {
match event {
// Some terminals (or configurations) send Control key chords as
// C0 control characters without reporting the CONTROL modifier.
// Handle common fallbacks for Ctrl-B/F/P/N here so they don't get
// inserted as literal control bytes.
KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => {
self.move_cursor_left();
}
KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => {
self.move_cursor_right();
}
KeyEvent { code: KeyCode::Char('\u{0010}'), modifiers: KeyModifiers::NONE, .. } /* ^P */ => {
self.move_cursor_up();
}
KeyEvent { code: KeyCode::Char('\u{000e}'), modifiers: KeyModifiers::NONE, .. } /* ^N */ => {
self.move_cursor_down();
}
// Keep these as compatibility fallbacks.
KeyEvent {
code: KeyCode::Char(c),
// Insert plain characters (and Shift-modified). Do NOT insert when ALT is held,
// because many terminals map Option/Meta combos to ALT+<char> (e.g. ESC f/ESC b)
// for word navigation. Those are handled explicitly below.
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
..
} => self.insert_str(&c.to_string()),
KeyEvent {
code: KeyCode::Char('j' | 'm'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Enter,
..
} => self.insert_str("\n"),
KeyEvent {
code: KeyCode::Char('h'),
modifiers,
..
} if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => {
self.delete_backward_word()
},
// Windows AltGr generates ALT|CONTROL; treat as a plain character input unless
// we match a specific Control+Alt binding above.
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if is_altgr(modifiers) => self.insert_str(&c.to_string()),
KeyEvent {
code: KeyCode::Backspace,
modifiers: KeyModifiers::ALT,
..
} => self.delete_backward_word(),
KeyEvent {
code: KeyCode::Backspace,
..
}
| KeyEvent {
code: KeyCode::Char('h'),
modifiers: KeyModifiers::CONTROL,
..
} => self.delete_backward(/*n*/ 1),
KeyEvent {
code: KeyCode::Delete,
modifiers: KeyModifiers::ALT,
..
}
| KeyEvent {
code: KeyCode::Char('d'),
modifiers: KeyModifiers::ALT,
..
} => self.delete_forward_word(),
KeyEvent {
code: KeyCode::Delete,
..
}
| KeyEvent {
code: KeyCode::Char('d'),
modifiers: KeyModifiers::CONTROL,
..
} => self.delete_forward(/*n*/ 1),
KeyEvent {
code: KeyCode::Char('w'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.delete_backward_word();
}
// Meta-b -> move to beginning of previous word
// Meta-f -> move to end of next word
// Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT).
KeyEvent {
code: KeyCode::Char('b'),
modifiers: KeyModifiers::ALT,
..
} => {
self.set_cursor(self.beginning_of_previous_word());
}
KeyEvent {
code: KeyCode::Char('f'),
modifiers: KeyModifiers::ALT,
..
} => {
self.set_cursor(self.end_of_next_word());
}
KeyEvent {
code: KeyCode::Char('u'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.kill_to_beginning_of_line();
}
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.kill_to_end_of_line();
}
KeyEvent {
code: KeyCode::Char('y'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.yank();
}
// Cursor movement
KeyEvent {
code: KeyCode::Left,
code: KeyCode::Char('\u{0002}'),
modifiers: KeyModifiers::NONE,
..
} => {
} /* ^B */ => {
self.move_cursor_left();
return;
}
KeyEvent {
code: KeyCode::Right,
code: KeyCode::Char('\u{0006}'),
modifiers: KeyModifiers::NONE,
..
} => {
} /* ^F */ => {
self.move_cursor_right();
return;
}
KeyEvent {
code: KeyCode::Char('b'),
modifiers: KeyModifiers::CONTROL,
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} => {
self.move_cursor_left();
}
KeyEvent {
code: KeyCode::Char('f'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.move_cursor_right();
}
KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
} /* ^P */ => {
self.move_cursor_up();
return;
}
KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} => {
} /* ^N */ => {
self.move_cursor_down();
}
// Some terminals send Alt+Arrow for word-wise movement:
// Option/Left -> Alt+Left (previous word start)
// Option/Right -> Alt+Right (next word end)
KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::ALT,
..
}
| KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::CONTROL,
..
} => {
self.set_cursor(self.beginning_of_previous_word());
}
KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::ALT,
..
}
| KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::CONTROL,
..
} => {
self.set_cursor(self.end_of_next_word());
}
KeyEvent {
code: KeyCode::Up, ..
} => {
self.move_cursor_up();
}
KeyEvent {
code: KeyCode::Down,
..
} => {
self.move_cursor_down();
}
KeyEvent {
code: KeyCode::Home,
..
} => {
self.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ false);
}
KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ true);
}
KeyEvent {
code: KeyCode::End, ..
} => {
self.move_cursor_to_end_of_line(/*move_down_at_eol*/ false);
}
KeyEvent {
code: KeyCode::Char('e'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.move_cursor_to_end_of_line(/*move_down_at_eol*/ true);
return;
}
_ => {}
}
if keymap.insert_newline.is_pressed(event) {
self.insert_str("\n");
return;
}
if keymap.delete_backward_word.is_pressed(event) {
self.delete_backward_word();
return;
}
// Windows AltGr generates ALT|CONTROL. Preserve typed characters for AltGr users
// unless a specific shortcut already matched above.
if let KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} = event
&& is_altgr(modifiers)
{
self.insert_str(&c.to_string());
return;
}
if keymap.delete_backward.is_pressed(event) {
self.delete_backward(1);
return;
}
if keymap.delete_forward_word.is_pressed(event) {
self.delete_forward_word();
return;
}
if keymap.delete_forward.is_pressed(event) {
self.delete_forward(1);
return;
}
if keymap.kill_line_start.is_pressed(event) {
self.kill_to_beginning_of_line();
return;
}
if keymap.kill_line_end.is_pressed(event) {
self.kill_to_end_of_line();
return;
}
if keymap.yank.is_pressed(event) {
self.yank();
return;
}
if keymap.move_word_left.is_pressed(event) {
self.set_cursor(self.beginning_of_previous_word());
return;
}
if keymap.move_word_right.is_pressed(event) {
self.set_cursor(self.end_of_next_word());
return;
}
if keymap.move_left.is_pressed(event) {
self.move_cursor_left();
return;
}
if keymap.move_right.is_pressed(event) {
self.move_cursor_right();
return;
}
if keymap.move_up.is_pressed(event) {
self.move_cursor_up();
return;
}
if keymap.move_down.is_pressed(event) {
self.move_cursor_down();
return;
}
if keymap.move_line_start.is_pressed(event) {
let move_up_at_bol = matches!(
event,
KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::CONTROL,
..
}
);
self.move_cursor_to_beginning_of_line(move_up_at_bol);
return;
}
if keymap.move_line_end.is_pressed(event) {
let move_down_at_eol = matches!(
event,
KeyEvent {
code: KeyCode::Char('e'),
modifiers: KeyModifiers::CONTROL,
..
}
);
self.move_cursor_to_end_of_line(move_down_at_eol);
return;
}
if let KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
..
} = event
{
// Insert plain characters (and Shift-modified). Do not insert when ALT is held,
// because many terminals map Option/Meta combos to ALT+<char>.
self.insert_str(&c.to_string());
return;
}
#[cfg(feature = "debug-logs")]
tracing::debug!("Unhandled key event in TextArea: {:?}", event);
}
fn handle_vim_input(&mut self, event: KeyEvent) {
match self.vim_mode {
VimMode::Insert => self.handle_vim_insert(event),
VimMode::Normal => self.handle_vim_normal(event),
}
}
fn handle_vim_insert(&mut self, event: KeyEvent) {
if matches!(event.code, KeyCode::Esc) {
self.vim_mode = VimMode::Normal;
self.vim_operator = None;
self.preferred_col = None;
return;
}
let keymap = self.editor_keymap.clone();
self.input_with_keymap(event, &keymap);
}
fn handle_vim_normal(&mut self, event: KeyEvent) {
if let Some(op) = self.vim_operator.take()
&& self.handle_vim_operator(op, event)
{
return;
}
match event {
KeyEvent {
code: KeyCode::Char('i'),
modifiers: KeyModifiers::NONE,
..
} => {
self.vim_mode = VimMode::Insert;
}
KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::NONE,
..
} => {
let next = self.next_atomic_boundary(self.cursor_pos);
self.set_cursor(next);
self.vim_mode = VimMode::Insert;
}
KeyEvent {
code: KeyCode::Char('A'),
modifiers,
..
} if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => {
self.set_cursor(self.end_of_current_line());
self.vim_mode = VimMode::Insert;
}
KeyEvent {
code: KeyCode::Char('I'),
modifiers,
..
} if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => {
self.set_cursor(self.beginning_of_current_line());
self.vim_mode = VimMode::Insert;
}
KeyEvent {
code: KeyCode::Char('o'),
modifiers: KeyModifiers::NONE,
..
} => {
let eol = self.end_of_current_line();
let insert_at = if eol < self.text.len() { eol + 1 } else { eol };
self.insert_str_at(insert_at, "\n");
self.set_cursor(insert_at + 1);
self.vim_mode = VimMode::Insert;
}
KeyEvent {
code: KeyCode::Char('O'),
modifiers,
..
} if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => {
let bol = self.beginning_of_current_line();
self.insert_str_at(bol, "\n");
self.set_cursor(bol);
self.vim_mode = VimMode::Insert;
}
KeyEvent {
code: KeyCode::Char('h'),
modifiers: KeyModifiers::NONE,
..
} => self.move_cursor_left(),
KeyEvent {
code: KeyCode::Char('l'),
modifiers: KeyModifiers::NONE,
..
} => self.move_cursor_right(),
KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} => self.move_cursor_down(),
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
} => self.move_cursor_up(),
KeyEvent {
code: KeyCode::Char('w'),
modifiers: KeyModifiers::NONE,
..
} => self.set_cursor(self.beginning_of_next_word()),
KeyEvent {
code: KeyCode::Char('b'),
modifiers: KeyModifiers::NONE,
..
} => self.set_cursor(self.beginning_of_previous_word()),
KeyEvent {
code: KeyCode::Char('e'),
modifiers: KeyModifiers::NONE,
..
} => self.set_cursor(self.end_of_next_word()),
KeyEvent {
code: KeyCode::Char('0'),
modifiers: KeyModifiers::NONE,
..
} => self.set_cursor(self.beginning_of_current_line()),
KeyEvent {
code: KeyCode::Char('$'),
modifiers,
..
} if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => {
self.set_cursor(self.end_of_current_line());
}
KeyEvent {
code: KeyCode::Char('x'),
modifiers: KeyModifiers::NONE,
..
} => {
self.delete_forward_kill(1);
}
KeyEvent {
code: KeyCode::Char('D'),
modifiers,
..
} if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => {
self.kill_to_end_of_line();
}
KeyEvent {
code: KeyCode::Char('Y'),
modifiers,
..
} if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => {
self.yank_current_line();
}
KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::NONE,
..
} => self.paste_after_cursor(),
KeyEvent {
code: KeyCode::Char('d'),
modifiers: KeyModifiers::NONE,
..
} => {
self.vim_operator = Some(VimOperator::Delete);
}
KeyEvent {
code: KeyCode::Char('y'),
modifiers: KeyModifiers::NONE,
..
} => {
self.vim_operator = Some(VimOperator::Yank);
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.vim_operator = None;
}
_ => {}
}
}
fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool {
match event {
KeyEvent {
code: KeyCode::Char('d'),
modifiers: KeyModifiers::NONE,
..
} if op == VimOperator::Delete => {
self.delete_current_line();
true
}
KeyEvent {
code: KeyCode::Char('y'),
modifiers: KeyModifiers::NONE,
..
} if op == VimOperator::Yank => {
self.yank_current_line();
true
}
_ => {
if let Some(motion) = self.vim_motion_for_event(event) {
self.apply_vim_operator(op, motion);
return true;
}
false
}
}
}
fn vim_motion_for_event(&self, event: KeyEvent) -> Option<VimMotion> {
match event {
KeyEvent {
code: KeyCode::Char('h'),
modifiers: KeyModifiers::NONE,
..
} => Some(VimMotion::Left),
KeyEvent {
code: KeyCode::Char('l'),
modifiers: KeyModifiers::NONE,
..
} => Some(VimMotion::Right),
KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} => Some(VimMotion::Down),
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
} => Some(VimMotion::Up),
KeyEvent {
code: KeyCode::Char('w'),
modifiers: KeyModifiers::NONE,
..
} => Some(VimMotion::WordForward),
KeyEvent {
code: KeyCode::Char('b'),
modifiers: KeyModifiers::NONE,
..
} => Some(VimMotion::WordBackward),
KeyEvent {
code: KeyCode::Char('e'),
modifiers: KeyModifiers::NONE,
..
} => Some(VimMotion::WordEnd),
KeyEvent {
code: KeyCode::Char('0'),
modifiers: KeyModifiers::NONE,
..
} => Some(VimMotion::LineStart),
KeyEvent {
code: KeyCode::Char('$'),
modifiers,
..
} if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => {
Some(VimMotion::LineEnd)
}
_ => None,
}
}
fn apply_vim_operator(&mut self, op: VimOperator, motion: VimMotion) {
let Some(range) = self.range_for_motion(motion) else {
return;
};
match op {
VimOperator::Delete => self.kill_range(range),
VimOperator::Yank => self.yank_range(range),
}
}
fn range_for_motion(&mut self, motion: VimMotion) -> Option<Range<usize>> {
let start = self.cursor_pos;
let target = self.target_for_motion(motion);
if start == target {
return None;
}
let (range_start, range_end) = if target < start {
(target, start)
} else {
(start, target)
};
Some(range_start..range_end)
}
fn target_for_motion(&mut self, motion: VimMotion) -> usize {
let original_cursor = self.cursor_pos;
let original_preferred = self.preferred_col;
match motion {
VimMotion::Left => self.move_cursor_left(),
VimMotion::Right => self.move_cursor_right(),
VimMotion::Up => self.move_cursor_up(),
VimMotion::Down => self.move_cursor_down(),
VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()),
VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()),
VimMotion::WordEnd => self.set_cursor(self.end_of_next_word()),
VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()),
VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()),
}
let target = self.cursor_pos;
self.cursor_pos = original_cursor;
self.preferred_col = original_preferred;
target
}
// ####### Input Functions #######
@@ -587,6 +880,20 @@ impl TextArea {
self.replace_range(self.cursor_pos..target, "");
}
pub fn delete_forward_kill(&mut self, n: usize) {
if n == 0 || self.cursor_pos >= self.text.len() {
return;
}
let mut target = self.cursor_pos;
for _ in 0..n {
target = self.next_atomic_boundary(target);
if target >= self.text.len() {
break;
}
}
self.kill_range(self.cursor_pos..target);
}
pub fn delete_backward_word(&mut self) {
let start = self.beginning_of_previous_word();
self.kill_range(start..self.cursor_pos);
@@ -668,6 +975,45 @@ impl TextArea {
self.replace_range_raw(range, "");
}
fn yank_range(&mut self, range: Range<usize>) {
let range = self.expand_range_to_element_boundaries(range);
if range.start >= range.end {
return;
}
let removed = self.text[range].to_string();
if removed.is_empty() {
return;
}
self.kill_buffer = removed;
}
fn paste_after_cursor(&mut self) {
if self.kill_buffer.is_empty() {
return;
}
let insert_at = self.next_atomic_boundary(self.cursor_pos);
self.set_cursor(insert_at);
let text = self.kill_buffer.clone();
self.insert_str(&text);
}
fn yank_current_line(&mut self) {
let range = self.current_line_range_with_newline();
self.yank_range(range);
}
fn delete_current_line(&mut self) {
let range = self.current_line_range_with_newline();
self.kill_range(range);
}
fn current_line_range_with_newline(&self) -> Range<usize> {
let bol = self.beginning_of_current_line();
let eol = self.end_of_current_line();
let end = if eol < self.text.len() { eol + 1 } else { eol };
bol..end
}
/// Move the cursor left by a single grapheme cluster.
pub fn move_cursor_left(&mut self) {
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos);
+5 -1
View File
@@ -353,7 +353,7 @@ use crate::history_cell::PlainHistoryCell;
use crate::history_cell::WebSearchCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
#[cfg(test)]
use crate::keymap::RuntimeKeymap;
use crate::markdown::append_markdown;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
@@ -5289,6 +5289,10 @@ impl ChatWidget {
last_non_retry_error: None,
};
widget.prefetch_rate_limits();
if let Ok(keymap) = RuntimeKeymap::from_config(&widget.config.tui_keymap) {
widget.bottom_pane.set_keymap_bindings(&keymap);
}
widget
.bottom_pane
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
+132 -75
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_app_server_client::AppServerRequestHandle;
@@ -37,6 +44,11 @@ use std::sync::RwLock;
use uuid::Uuid;
use crate::LoginStatus;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::OnboardingKeymap;
use crate::keymap::primary_binding;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::shimmer::shimmer_spans;
@@ -185,39 +197,42 @@ impl KeyboardHandler for AuthModeWidget {
return;
}
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.move_highlight(/*delta*/ -1);
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_highlight(/*delta*/ 1);
}
KeyCode::Char('1') => {
self.select_option_by_index(/*index*/ 0);
}
KeyCode::Char('2') => {
self.select_option_by_index(/*index*/ 1);
}
KeyCode::Char('3') => {
self.select_option_by_index(/*index*/ 2);
}
KeyCode::Enter => {
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
match sign_in_state {
SignInState::PickMode => {
self.handle_sign_in_option(self.highlighted_mode);
}
SignInState::ChatGptSuccessMessage => {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
}
_ => {}
if self.onboarding_keymap.move_up.is_pressed(key_event) {
self.move_highlight(-1);
return;
}
if self.onboarding_keymap.move_down.is_pressed(key_event) {
self.move_highlight(1);
return;
}
if self.onboarding_keymap.select_first.is_pressed(key_event) {
self.select_option_by_index(0);
return;
}
if self.onboarding_keymap.select_second.is_pressed(key_event) {
self.select_option_by_index(1);
return;
}
if self.onboarding_keymap.select_third.is_pressed(key_event) {
self.select_option_by_index(2);
return;
}
if self.onboarding_keymap.confirm.is_pressed(key_event) {
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
match sign_in_state {
SignInState::PickMode => {
self.handle_sign_in_option(self.highlighted_mode);
}
SignInState::ChatGptSuccessMessage => {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
}
_ => {}
}
KeyCode::Esc => {
tracing::info!("Esc pressed");
self.cancel_active_attempt();
}
_ => {}
return;
}
if self.onboarding_keymap.cancel.is_pressed(key_event) {
tracing::info!("Cancel onboarding auth step");
self.cancel_active_attempt();
}
}
@@ -238,6 +253,7 @@ pub(crate) struct AuthModeWidget {
pub forced_login_method: Option<ForcedLoginMethod>,
pub animations_enabled: bool,
pub animations_suppressed: Cell<bool>,
pub onboarding_keymap: OnboardingKeymap,
}
impl AuthModeWidget {
@@ -286,6 +302,28 @@ impl AuthModeWidget {
self.error.read().unwrap().clone()
}
/// Returns whether the auth flow is currently in API-key entry mode.
pub(crate) fn is_api_key_entry_active(&self) -> bool {
self.sign_in_state
.read()
.is_ok_and(|guard| matches!(&*guard, SignInState::ApiKeyEntry(_)))
}
/// Returns whether the API-key entry field currently contains any text.
pub(crate) fn api_key_entry_has_text(&self) -> bool {
self.sign_in_state.read().is_ok_and(
|guard| matches!(&*guard, SignInState::ApiKeyEntry(state) if !state.value.is_empty()),
)
}
fn confirm_binding(&self) -> KeyBinding {
primary_binding(&self.onboarding_keymap.confirm).unwrap_or(key_hint::plain(KeyCode::Enter))
}
fn cancel_binding(&self) -> KeyBinding {
primary_binding(&self.onboarding_keymap.cancel).unwrap_or(key_hint::plain(KeyCode::Esc))
}
fn is_api_login_allowed(&self) -> bool {
!matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt))
}
@@ -455,11 +493,11 @@ impl AuthModeWidget {
);
lines.push("".into());
}
lines.push(
// AE: Following styles.md, this should probably be Cyan because it's a user input tip.
// But leaving this for a future cleanup.
" Press Enter to continue".dim().into(),
);
lines.push(Line::from(vec![
" Press ".dim(),
self.confirm_binding().into(),
" to continue".dim(),
]));
if let Some(err) = self.error_message() {
lines.push("".into());
lines.push(err.red().into());
@@ -494,7 +532,9 @@ impl AuthModeWidget {
]));
lines.push("".into());
lines.push(Line::from(vec![
" On a remote or headless machine? Press Esc and choose ".into(),
" On a remote or headless machine? Press ".into(),
self.cancel_binding().into(),
" and choose ".into(),
"Sign in with Device Code".cyan(),
".".into(),
]));
@@ -504,7 +544,11 @@ impl AuthModeWidget {
None
};
lines.push(" Press Esc to cancel".dim().into());
lines.push(Line::from(vec![
" Press ".dim(),
self.cancel_binding().into(),
" to cancel".dim(),
]));
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
@@ -539,7 +583,11 @@ impl AuthModeWidget {
])
.dim(),
"".into(),
" Press Enter to continue".fg(Color::Cyan).into(),
Line::from(vec![
" Press ".fg(Color::Cyan),
self.confirm_binding().into(),
" to continue".fg(Color::Cyan),
]),
];
Paragraph::new(lines)
@@ -618,8 +666,16 @@ impl AuthModeWidget {
.render(input_area, buf);
let mut footer_lines: Vec<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_message() {
footer_lines.push("".into());
@@ -637,46 +693,46 @@ impl AuthModeWidget {
{
let mut guard = self.sign_in_state.write().unwrap();
if let SignInState::ApiKeyEntry(state) = &mut *guard {
match key_event.code {
KeyCode::Esc => {
*guard = SignInState::PickMode;
self.set_error(/*message*/ None);
if self.onboarding_keymap.cancel.is_pressed(*key_event) {
*guard = SignInState::PickMode;
self.set_error(/*message*/ None);
should_request_frame = true;
} else if self.onboarding_keymap.confirm.is_pressed(*key_event) {
let trimmed = state.value.trim().to_string();
if trimmed.is_empty() {
self.set_error(Some("API key cannot be empty".to_string()));
should_request_frame = true;
} else {
should_save = Some(trimmed);
}
KeyCode::Enter => {
let trimmed = state.value.trim().to_string();
if trimmed.is_empty() {
self.set_error(Some("API key cannot be empty".to_string()));
} else {
match key_event.code {
KeyCode::Backspace => {
if state.prepopulated_from_env {
state.value.clear();
state.prepopulated_from_env = false;
} else {
state.value.pop();
}
self.set_error(/*message*/ None);
should_request_frame = true;
} else {
should_save = Some(trimmed);
}
}
KeyCode::Backspace => {
if state.prepopulated_from_env {
state.value.clear();
state.prepopulated_from_env = false;
} else {
state.value.pop();
KeyCode::Char(c)
if key_event.kind == KeyEventKind::Press
&& !key_event.modifiers.contains(KeyModifiers::SUPER)
&& !key_event.modifiers.contains(KeyModifiers::CONTROL)
&& !key_event.modifiers.contains(KeyModifiers::ALT) =>
{
if state.prepopulated_from_env {
state.value.clear();
state.prepopulated_from_env = false;
}
state.value.push(c);
self.set_error(/*message*/ None);
should_request_frame = true;
}
self.set_error(/*message*/ None);
should_request_frame = true;
_ => {}
}
KeyCode::Char(c)
if key_event.kind == KeyEventKind::Press
&& !key_event.modifiers.contains(KeyModifiers::SUPER)
&& !key_event.modifiers.contains(KeyModifiers::CONTROL)
&& !key_event.modifiers.contains(KeyModifiers::ALT) =>
{
if state.prepopulated_from_env {
state.value.clear();
state.prepopulated_from_env = false;
}
state.value.push(c);
self.set_error(/*message*/ None);
should_request_frame = true;
}
_ => {}
}
// handled; let guard drop before potential save
} else {
@@ -1013,6 +1069,7 @@ mod tests {
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
animations_enabled: true,
animations_suppressed: std::cell::Cell::new(false),
onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding,
};
(widget, codex_home)
}
@@ -137,7 +137,11 @@ pub(super) fn render_device_code_login(
None
};
lines.push(" Press Esc to cancel".dim().into());
lines.push(Line::from(vec![
" Press ".dim(),
widget.cancel_binding().into(),
" to cancel".dim(),
]));
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
+142 -34
View File
@@ -1,6 +1,15 @@
use crate::legacy_core::config::Config;
#[cfg(target_os = "windows")]
use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt;
//! Onboarding screen orchestration and top-level keyboard routing.
//!
//! The onboarding flow is a small state machine over visible steps
//! (welcome/auth/trust). This module decides which step receives key/paste
//! events and enforces flow-level safety rules that cut across individual step
//! widgets.
//!
//! In particular, onboarding quit handling has a text-entry guard for API-key
//! input: printable quit bindings are treated as text input while the user is
//! editing a non-empty API-key field, while control/alt chords remain available
//! as explicit exit shortcuts.
use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ServerNotification;
@@ -11,6 +20,7 @@ use codex_protocol::config_types::WindowsSandboxLevel;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
@@ -22,6 +32,12 @@ use codex_protocol::config_types::ForcedLoginMethod;
use crate::LoginStatus;
use crate::app_server_session::AppServerSession;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::OnboardingKeymap;
use crate::keymap::RuntimeKeymap;
use crate::legacy_core::config::Config;
#[cfg(target_os = "windows")]
use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt;
use crate::onboarding::auth::AuthModeWidget;
use crate::onboarding::auth::SignInOption;
use crate::onboarding::auth::SignInState;
@@ -61,6 +77,7 @@ pub(crate) trait StepStateProvider {
pub(crate) struct OnboardingScreen {
request_frame: FrameRequester,
steps: Vec<Step>,
onboarding_keymap: OnboardingKeymap,
is_done: bool,
should_exit: bool,
}
@@ -78,6 +95,14 @@ pub(crate) struct OnboardingResult {
pub should_exit: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct ApiKeyEntryContext {
/// True when onboarding is currently rendering the API-key entry state.
active: bool,
/// True when the API-key input field currently contains user text.
has_text: bool,
}
impl OnboardingScreen {
pub(crate) async fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
let OnboardingScreenArgs {
@@ -90,11 +115,22 @@ impl OnboardingScreen {
let cwd = config.cwd.to_path_buf();
let codex_home = config.codex_home.to_path_buf();
let forced_login_method = config.forced_login_method;
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).unwrap_or_else(|err| {
tracing::warn!(
"Invalid `tui.keymap` configuration during onboarding: {err}. \
Falling back to default onboarding keymap values. \
Fix `~/.codex/config.toml`.\n\
Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml"
);
RuntimeKeymap::defaults()
});
let onboarding_keymap = runtime_keymap.onboarding;
let mut steps: Vec<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 {
@@ -112,6 +148,7 @@ impl OnboardingScreen {
forced_login_method,
animations_enabled: config.animations,
animations_suppressed: std::cell::Cell::new(false),
onboarding_keymap: onboarding_keymap.clone(),
}));
} else {
tracing::warn!("skipping onboarding login step without app-server request handle");
@@ -137,11 +174,13 @@ impl OnboardingScreen {
selection: None,
highlighted,
error: None,
onboarding_keymap: onboarding_keymap.clone(),
}))
}
Self {
request_frame: tui.frame_requester(),
steps,
onboarding_keymap,
is_done: false,
should_exit: false,
}
@@ -248,45 +287,39 @@ impl OnboardingScreen {
}
}
fn is_api_key_entry_active(&self) -> bool {
self.steps.iter().any(|step| {
if let Step::Auth(widget) = step {
return widget
.sign_in_state
.read()
.is_ok_and(|g| matches!(&*g, SignInState::ApiKeyEntry(_)));
}
false
})
fn api_key_entry_context(&self) -> ApiKeyEntryContext {
self.steps
.iter()
.find_map(|step| {
if let Step::Auth(widget) = step {
Some(ApiKeyEntryContext {
active: widget.is_api_key_entry_active(),
has_text: widget.api_key_entry_has_text(),
})
} else {
None
}
})
.unwrap_or_default()
}
}
impl KeyboardHandler for OnboardingScreen {
/// Route key events to onboarding steps while preserving text-entry safety.
///
/// In API-key entry mode, printable quit bindings are suppressed only after
/// the user has started typing in the API-key field. This keeps custom
/// printable quit keys usable on an empty field while protecting in-progress
/// text entry from accidental exits. Control/alt quit chords still work as
/// emergency exits.
fn handle_key_event(&mut self, key_event: KeyEvent) {
if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return;
}
let is_api_key_entry_active = self.is_api_key_entry_active();
let should_quit = match key_event {
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => true,
KeyEvent {
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
..
} => !is_api_key_entry_active,
_ => false,
};
let api_key_entry_context = self.api_key_entry_context();
let should_quit = key_event.kind == KeyEventKind::Press
&& self.onboarding_keymap.quit.is_pressed(key_event)
&& !suppress_quit_while_typing_api_key(key_event, api_key_entry_context);
if should_quit {
if self.is_auth_in_progress() {
self.cancel_auth_if_active();
@@ -332,6 +365,24 @@ impl KeyboardHandler for OnboardingScreen {
}
}
/// Returns `true` when a quit shortcut should be ignored as text input.
///
/// This only applies while API-key entry is active and the key is a printable
/// character without control/alt modifiers and there is already text in the
/// input field. Empty input intentionally does not trigger suppression so
/// printable custom quit bindings can still exit onboarding.
fn suppress_quit_while_typing_api_key(
key_event: KeyEvent,
api_key_entry_context: ApiKeyEntryContext,
) -> bool {
api_key_entry_context.active
&& api_key_entry_context.has_text
&& matches!(key_event.code, KeyCode::Char(_))
&& !key_event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
}
impl WidgetRef for &OnboardingScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let suppress_animations = self.should_suppress_animations();
@@ -544,3 +595,60 @@ pub(crate) async fn run_onboarding_app(
should_exit: onboarding_screen.should_exit(),
})
}
#[cfg(test)]
mod tests {
use super::ApiKeyEntryContext;
use super::suppress_quit_while_typing_api_key;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
#[test]
fn suppresses_printable_custom_quit_key_during_api_key_entry() {
let suppressed = suppress_quit_while_typing_api_key(
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
ApiKeyEntryContext {
active: true,
has_text: true,
},
);
assert!(suppressed);
}
#[test]
fn does_not_suppress_printable_quit_key_when_api_key_input_is_empty() {
let suppressed = suppress_quit_while_typing_api_key(
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
ApiKeyEntryContext {
active: true,
has_text: false,
},
);
assert!(!suppressed);
}
#[test]
fn does_not_suppress_control_quit_key_during_api_key_entry() {
let suppressed = suppress_quit_while_typing_api_key(
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
ApiKeyEntryContext {
active: true,
has_text: true,
},
);
assert!(!suppressed);
}
#[test]
fn does_not_suppress_when_not_in_api_key_entry() {
let suppressed = suppress_quit_while_typing_api_key(
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
ApiKeyEntryContext {
active: false,
has_text: true,
},
);
assert!(!suppressed);
}
}
+23 -13
View File
@@ -14,6 +14,9 @@ use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::OnboardingKeymap;
use crate::keymap::primary_binding;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::render::Insets;
@@ -32,6 +35,7 @@ pub(crate) struct TrustDirectoryWidget {
pub selection: Option<TrustDirectorySelection>,
pub highlighted: TrustDirectorySelection,
pub error: Option<String>,
pub onboarding_keymap: OnboardingKeymap,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -112,7 +116,9 @@ impl WidgetRef for &TrustDirectoryWidget {
column.push(
Line::from(vec![
"Press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
primary_binding(&self.onboarding_keymap.confirm)
.unwrap_or(key_hint::plain(KeyCode::Enter))
.into(),
if self.show_windows_create_sandbox_hint {
" to continue and create a sandbox...".dim()
} else {
@@ -134,20 +140,22 @@ impl KeyboardHandler for TrustDirectoryWidget {
return;
}
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.highlighted = TrustDirectorySelection::Trust;
}
KeyCode::Down | KeyCode::Char('j') => {
self.highlighted = TrustDirectorySelection::Quit;
}
KeyCode::Char('1') | KeyCode::Char('y') => self.handle_trust(),
KeyCode::Char('2') | KeyCode::Char('n') => self.handle_quit(),
KeyCode::Enter => match self.highlighted {
if self.onboarding_keymap.move_up.is_pressed(key_event) {
self.highlighted = TrustDirectorySelection::Trust;
} else if self.onboarding_keymap.move_down.is_pressed(key_event) {
self.highlighted = TrustDirectorySelection::Quit;
} else if self.onboarding_keymap.select_first.is_pressed(key_event) {
self.handle_trust();
} else if self.onboarding_keymap.select_second.is_pressed(key_event)
|| self.onboarding_keymap.quit.is_pressed(key_event)
|| self.onboarding_keymap.cancel.is_pressed(key_event)
{
self.handle_quit();
} else if self.onboarding_keymap.confirm.is_pressed(key_event) {
match self.highlighted {
TrustDirectorySelection::Trust => self.handle_trust(),
TrustDirectorySelection::Quit => self.handle_quit(),
},
_ => {}
}
}
}
}
@@ -209,6 +217,7 @@ mod tests {
selection: None,
highlighted: TrustDirectorySelection::Quit,
error: None,
onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding,
};
let release = KeyEvent {
@@ -235,6 +244,7 @@ mod tests {
selection: None,
highlighted: TrustDirectorySelection::Trust,
error: None,
onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding,
};
let mut terminal =
+45 -5
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;
@@ -28,17 +29,21 @@ pub(crate) struct WelcomeWidget {
animation: AsciiAnimation,
animations_enabled: bool,
animations_suppressed: Cell<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();
@@ -51,12 +56,14 @@ impl WelcomeWidget {
is_logged_in: bool,
request_frame: FrameRequester,
animations_enabled: bool,
onboarding_keymap: OnboardingKeymap,
) -> Self {
Self {
is_logged_in,
animation: AsciiAnimation::new(request_frame),
animations_enabled,
animations_suppressed: Cell::new(false),
toggle_animation_keys: onboarding_keymap.toggle_animation,
layout_area: Cell::new(None),
}
}
@@ -115,6 +122,8 @@ impl StepStateProvider for WelcomeWidget {
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -139,6 +148,7 @@ mod tests {
/*is_logged_in*/ false,
FrameRequester::test_dummy(),
/*animations_enabled*/ true,
crate::keymap::RuntimeKeymap::defaults().onboarding,
);
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT);
let mut buf = Buffer::empty(area);
@@ -155,6 +165,7 @@ mod tests {
/*is_logged_in*/ false,
FrameRequester::test_dummy(),
/*animations_enabled*/ true,
crate::keymap::RuntimeKeymap::defaults().onboarding,
);
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT - 1);
let mut buf = Buffer::empty(area);
@@ -175,6 +186,9 @@ mod tests {
),
animations_enabled: true,
animations_suppressed: Cell::new(false),
toggle_animation_keys: crate::keymap::RuntimeKeymap::defaults()
.onboarding
.toggle_animation,
layout_area: Cell::new(None),
};
@@ -187,4 +201,30 @@ mod tests {
"expected ctrl+. to switch welcome animation variant"
);
}
#[test]
fn ctrl_shift_dot_changes_animation_variant() {
let mut widget = WelcomeWidget {
is_logged_in: false,
animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0),
animations_enabled: true,
animations_suppressed: Cell::new(false),
toggle_animation_keys: crate::keymap::RuntimeKeymap::defaults()
.onboarding
.toggle_animation,
layout_area: Cell::new(None),
};
let before = widget.animation.current_frame();
widget.handle_key_event(KeyEvent::new(
KeyCode::Char('.'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
let after = widget.animation.current_frame();
assert_ne!(
before, after,
"expected ctrl+shift+. to switch welcome animation variant"
);
}
}
+174 -94
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;
}
_ => {
@@ -454,12 +438,13 @@ impl TranscriptOverlay {
///
/// This overlay does not own the "active cell"; callers may optionally append a live tail via
/// `sync_live_tail` during draws to reflect in-flight activity.
pub(crate) fn new(transcript_cells: Vec<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, /*highlight_cell*/ None),
"T R A N S C R I P T".to_string(),
usize::MAX,
keymap,
),
cells: transcript_cells,
highlight_cell: None,
@@ -668,15 +653,51 @@ impl TranscriptOverlay {
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(line1, buf, PAGER_KEY_HINTS);
render_key_hints(
line1,
buf,
&[
(
first_or_empty(&self.view.keymap.scroll_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.scroll_down))
.collect(),
"to scroll",
),
(
first_or_empty(&self.view.keymap.page_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.page_down))
.collect(),
"to page",
),
(
first_or_empty(&self.view.keymap.jump_top)
.into_iter()
.chain(first_or_empty(&self.view.keymap.jump_bottom))
.collect(),
"to jump",
),
],
);
let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")];
let mut pairs: Vec<(Vec<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);
}
@@ -694,7 +715,9 @@ impl TranscriptOverlay {
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match event {
TuiEvent::Key(key_event) => match key_event {
e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) || KEY_CTRL_T.is_press(e) => {
e if self.view.keymap.close.is_pressed(e)
|| self.view.keymap.close_transcript.is_pressed(e) =>
{
self.is_done = true;
Ok(())
}
@@ -725,14 +748,26 @@ pub(crate) struct StaticOverlay {
}
impl StaticOverlay {
pub(crate) fn with_title(lines: Vec<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, /*scroll_offset*/ 0),
view: PagerView::new(renderables, title, 0, keymap),
is_done: false,
}
}
@@ -740,8 +775,35 @@ impl StaticOverlay {
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(line1, buf, PAGER_KEY_HINTS);
let pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")];
render_key_hints(
line1,
buf,
&[
(
first_or_empty(&self.view.keymap.scroll_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.scroll_down))
.collect(),
"to scroll",
),
(
first_or_empty(&self.view.keymap.page_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.page_down))
.collect(),
"to page",
),
(
first_or_empty(&self.view.keymap.jump_top)
.into_iter()
.chain(first_or_empty(&self.view.keymap.jump_bottom))
.collect(),
"to jump",
),
],
);
let pairs: Vec<(Vec<KeyBinding>, &str)> =
vec![(first_or_empty(&self.view.keymap.close), "to quit")];
render_key_hints(line2, buf, &pairs);
}
@@ -758,7 +820,7 @@ impl StaticOverlay {
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match event {
TuiEvent::Key(key_event) => match key_event {
e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) => {
e if self.view.keymap.close.is_pressed(e) => {
self.is_done = true;
Ok(())
}
@@ -851,9 +913,34 @@ mod tests {
Box::new(Paragraph::new(text)) as Box<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")],
})]);
@@ -871,7 +958,7 @@ mod tests {
#[test]
fn edit_next_hint_is_visible_when_highlighted() {
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("hello")],
})]);
overlay.set_highlight_cell(Some(0));
@@ -891,7 +978,7 @@ mod tests {
#[test]
fn transcript_overlay_snapshot_basic() {
// Prepare a transcript overlay with a few lines
let mut overlay = TranscriptOverlay::new(vec![
let mut overlay = transcript_overlay(vec![
Arc::new(TestCell {
lines: vec![Line::from("alpha")],
}),
@@ -910,7 +997,7 @@ mod tests {
#[test]
fn transcript_overlay_renders_live_tail() {
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("alpha")],
})]);
overlay.sync_live_tail(
@@ -931,7 +1018,7 @@ mod tests {
#[test]
fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() {
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("alpha")],
})]);
@@ -1027,7 +1114,7 @@ mod tests {
let exec_cell: Arc<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);
@@ -1041,7 +1128,7 @@ mod tests {
#[test]
fn transcript_overlay_keeps_scroll_pinned_at_bottom() {
let mut overlay = TranscriptOverlay::new(
let mut overlay = transcript_overlay(
(0..20)
.map(|i| {
Arc::new(TestCell {
@@ -1068,7 +1155,7 @@ mod tests {
#[test]
fn transcript_overlay_preserves_manual_scroll_position() {
let mut overlay = TranscriptOverlay::new(
let mut overlay = transcript_overlay(
(0..20)
.map(|i| {
Arc::new(TestCell {
@@ -1093,9 +1180,9 @@ mod tests {
#[test]
fn static_overlay_snapshot_basic() {
// Prepare a static overlay with a few lines and a title
let mut overlay = StaticOverlay::with_title(
let mut overlay = static_overlay(
vec!["one".into(), "two".into(), "three".into()],
"S T A T I C".to_string(),
"S T A T I C",
);
let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
@@ -1131,7 +1218,7 @@ mod tests {
#[test]
fn transcript_overlay_paging_is_continuous_and_round_trips() {
let mut overlay = TranscriptOverlay::new(
let mut overlay = transcript_overlay(
(0..50)
.map(|i| {
Arc::new(TestCell {
@@ -1199,9 +1286,9 @@ mod tests {
#[test]
fn static_overlay_wraps_long_lines() {
let mut overlay = StaticOverlay::with_title(
let mut overlay = static_overlay(
vec!["a very long line that should wrap when rendered within a narrow pager overlay width".into()],
"S T A T I C".to_string(),
"S T A T I C",
);
let mut term = Terminal::new(TestBackend::new(24, 8)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
@@ -1211,13 +1298,10 @@ mod tests {
#[test]
fn pager_view_content_height_counts_renderables() {
let pv = PagerView::new(
vec![
paragraph_block("a", /*lines*/ 2),
paragraph_block("b", /*lines*/ 3),
],
"T".to_string(),
/*scroll_offset*/ 0,
let pv = pager_view(
vec![paragraph_block("a", 2), paragraph_block("b", 3)],
"T",
0,
);
assert_eq!(pv.content_height(/*width*/ 80), 5);
@@ -1225,14 +1309,14 @@ mod tests {
#[test]
fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() {
let mut pv = PagerView::new(
let mut pv = pager_view(
vec![
paragraph_block("a", /*lines*/ 1),
paragraph_block("b", /*lines*/ 3),
paragraph_block("c", /*lines*/ 3),
],
"T".to_string(),
/*scroll_offset*/ 0,
"T",
0,
);
let area = Rect::new(0, 0, 20, 8);
@@ -1260,14 +1344,14 @@ mod tests {
#[test]
fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() {
let mut pv = PagerView::new(
let mut pv = pager_view(
vec![
paragraph_block("a", /*lines*/ 2),
paragraph_block("b", /*lines*/ 3),
paragraph_block("c", /*lines*/ 3),
],
"T".to_string(),
/*scroll_offset*/ 0,
"T",
0,
);
let area = Rect::new(0, 0, 20, 3);
@@ -1279,11 +1363,7 @@ mod tests {
#[test]
fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() {
let mut pv = PagerView::new(
vec![paragraph_block("a", /*lines*/ 10)],
"T".to_string(),
/*scroll_offset*/ 0,
);
let mut pv = pager_view(vec![paragraph_block("a", 10)], "T", 0);
let area = Rect::new(0, 0, 20, 8);
let mut buf = Buffer::empty(area);