mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(tui): add configurable keymap support (#18593)
## Why The TUI currently handles keyboard shortcuts as hard-coded event matches spread across app, composer, pager, list, approval, and navigation code. That makes shortcuts hard to customize, makes displayed hints easy to drift from actual behavior, and makes future keymap work riskier because there is no central action inventory. This PR adds the foundation for configurable, action-based keymaps without adding the interactive remapping UI yet. Onboarding intentionally stays on fixed startup shortcuts because users cannot reasonably configure keymaps before completing onboarding. This is PR1 in the keymap stack: - PR1: #18593: configurable keymap foundation - PR2: #18594: `/keymap` picker and guided remapping UI - PR3: #18595: Vim composer mode and the remap option ## Design Notes The new model resolves named actions into concrete runtime bindings once from config, then passes those bindings to the UI surfaces that handle input or render shortcut hints. The main concepts are: - **Context**: a scope where an action is active, such as `global`, `chat`, `composer`, `editor`, `pager`, `list`, or `approval`. - **Action**: a named operation inside a context, such as `global.open_transcript`, `composer.submit`, or `pager.close`. - **Binding**: one or more single-key shortcuts assigned to an action, written as config strings such as `ctrl-t`, `alt-backspace`, or `page-down`. Multi-step sequences such as `ctrl-x ctrl-s`, `g g`, or leader-key flows are not part of this PR. - **Resolution order**: context-specific config wins first, supported global fallbacks come next, and built-in defaults fill in anything unset. - **Explicit unbinding**: an empty array removes an action binding in that scope and does not fall through to a fallback binding. - **Conflict validation**: a resolved keymap rejects duplicate active bindings inside the same scope so one keypress cannot dispatch two actions. ## What Changed - Added `TuiKeymap` config support under `[tui.keymap]`, including typed contexts/actions, key alias normalization, generated schema coverage, and user-facing config errors. - Added `RuntimeKeymap` resolution in `codex-rs/tui/src/keymap.rs`, including fallback precedence, built-in defaults, explicit unbinding, and per-context conflict validation. - Rewired existing TUI handlers to consume resolved keymap actions instead of directly matching hard-coded keys in each component. - Updated key hint rendering and footer/pager/list surfaces so displayed shortcuts follow the resolved keymap. - Kept onboarding shortcuts fixed in `codex-rs/tui/src/onboarding/keys.rs` instead of exposing them through `[tui.keymap]`. ## Validation The branch includes focused coverage for config parsing, key normalization, runtime fallback resolution, explicit unbinding, duplicate-key conflict validation, default keymap consistency, onboarding startup key behavior, and UI hint snapshots affected by resolved key bindings.
This commit is contained in:
committed by
GitHub
Unverified
parent
a61c785040
commit
5e737372ee
@@ -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,9 @@ use std::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::LoginStatus;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::onboarding::keys;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
@@ -185,39 +195,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 keys::MOVE_UP.is_pressed(key_event) {
|
||||
self.move_highlight(/*delta*/ -1);
|
||||
return;
|
||||
}
|
||||
if keys::MOVE_DOWN.is_pressed(key_event) {
|
||||
self.move_highlight(/*delta*/ 1);
|
||||
return;
|
||||
}
|
||||
if keys::SELECT_FIRST.is_pressed(key_event) {
|
||||
self.select_option_by_index(/*index*/ 0);
|
||||
return;
|
||||
}
|
||||
if keys::SELECT_SECOND.is_pressed(key_event) {
|
||||
self.select_option_by_index(/*index*/ 1);
|
||||
return;
|
||||
}
|
||||
if keys::SELECT_THIRD.is_pressed(key_event) {
|
||||
self.select_option_by_index(/*index*/ 2);
|
||||
return;
|
||||
}
|
||||
if keys::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 keys::CANCEL.is_pressed(key_event) {
|
||||
tracing::info!("Cancel onboarding auth step");
|
||||
self.cancel_active_attempt();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +299,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 {
|
||||
keys::CONFIRM[0]
|
||||
}
|
||||
|
||||
fn cancel_binding(&self) -> KeyBinding {
|
||||
keys::CANCEL[0]
|
||||
}
|
||||
|
||||
fn is_api_login_allowed(&self) -> bool {
|
||||
!matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt))
|
||||
}
|
||||
@@ -455,11 +490,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 +529,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 +541,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 +580,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 +663,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 +690,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 keys::CANCEL.is_pressed(*key_event) {
|
||||
*guard = SignInState::PickMode;
|
||||
self.set_error(/*message*/ None);
|
||||
should_request_frame = true;
|
||||
} else if keys::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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Fixed shortcuts used before users have had a chance to configure Codex.
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
|
||||
pub(crate) const MOVE_UP: [KeyBinding; 2] = [
|
||||
key_hint::plain(KeyCode::Up),
|
||||
key_hint::plain(KeyCode::Char('k')),
|
||||
];
|
||||
pub(crate) const MOVE_DOWN: [KeyBinding; 2] = [
|
||||
key_hint::plain(KeyCode::Down),
|
||||
key_hint::plain(KeyCode::Char('j')),
|
||||
];
|
||||
pub(crate) const SELECT_FIRST: [KeyBinding; 2] = [
|
||||
key_hint::plain(KeyCode::Char('1')),
|
||||
key_hint::plain(KeyCode::Char('y')),
|
||||
];
|
||||
pub(crate) const SELECT_SECOND: [KeyBinding; 2] = [
|
||||
key_hint::plain(KeyCode::Char('2')),
|
||||
key_hint::plain(KeyCode::Char('n')),
|
||||
];
|
||||
pub(crate) const SELECT_THIRD: [KeyBinding; 1] = [key_hint::plain(KeyCode::Char('3'))];
|
||||
pub(crate) const CONFIRM: [KeyBinding; 1] = [key_hint::plain(KeyCode::Enter)];
|
||||
pub(crate) const CANCEL: [KeyBinding; 1] = [key_hint::plain(KeyCode::Esc)];
|
||||
pub(crate) const QUIT: [KeyBinding; 3] = [
|
||||
key_hint::plain(KeyCode::Char('q')),
|
||||
key_hint::ctrl(KeyCode::Char('c')),
|
||||
key_hint::ctrl(KeyCode::Char('d')),
|
||||
];
|
||||
pub(crate) const TOGGLE_ANIMATION: [KeyBinding; 2] = [
|
||||
key_hint::ctrl(KeyCode::Char('.')),
|
||||
KeyBinding::new(
|
||||
KeyCode::Char('.'),
|
||||
KeyModifiers::CONTROL.union(KeyModifiers::SHIFT),
|
||||
),
|
||||
];
|
||||
@@ -1,4 +1,5 @@
|
||||
mod auth;
|
||||
mod keys;
|
||||
pub(crate) mod onboarding_screen;
|
||||
mod trust_directory;
|
||||
pub(crate) use auth::mark_url_hyperlink;
|
||||
|
||||
@@ -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: the printable `q` quit key is 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,9 +32,14 @@ use codex_protocol::config_types::ForcedLoginMethod;
|
||||
|
||||
use crate::LoginStatus;
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
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;
|
||||
use crate::onboarding::keys;
|
||||
use crate::onboarding::trust_directory::TrustDirectorySelection;
|
||||
use crate::onboarding::trust_directory::TrustDirectoryWidget;
|
||||
use crate::onboarding::welcome::WelcomeWidget;
|
||||
@@ -78,6 +93,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 {
|
||||
@@ -248,45 +271,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 the
|
||||
/// printable `q` quit key 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
|
||||
&& keys::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 +349,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
|
||||
/// the printable `q` quit key 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 +579,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_quit_key_during_api_key_entry() {
|
||||
let suppressed = suppress_quit_while_typing_api_key(
|
||||
KeyEvent::new(KeyCode::Char('q'), 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('q'), 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::legacy_core::config::set_project_trust_level;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -13,7 +12,8 @@ use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::onboarding::keys;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::render::Insets;
|
||||
@@ -112,7 +112,7 @@ impl WidgetRef for &TrustDirectoryWidget {
|
||||
column.push(
|
||||
Line::from(vec![
|
||||
"Press ".dim(),
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
keys::CONFIRM[0].into(),
|
||||
if self.show_windows_create_sandbox_hint {
|
||||
" to continue and create a sandbox...".dim()
|
||||
} else {
|
||||
@@ -134,20 +134,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 keys::MOVE_UP.is_pressed(key_event) {
|
||||
self.highlighted = TrustDirectorySelection::Trust;
|
||||
} else if keys::MOVE_DOWN.is_pressed(key_event) {
|
||||
self.highlighted = TrustDirectorySelection::Quit;
|
||||
} else if keys::SELECT_FIRST.is_pressed(key_event) {
|
||||
self.handle_trust();
|
||||
} else if keys::SELECT_SECOND.is_pressed(key_event)
|
||||
|| keys::QUIT.is_pressed(key_event)
|
||||
|| keys::CANCEL.is_pressed(key_event)
|
||||
{
|
||||
self.handle_quit();
|
||||
} else if keys::CONFIRM.is_pressed(key_event) {
|
||||
match self.highlighted {
|
||||
TrustDirectorySelection::Trust => self.handle_trust(),
|
||||
TrustDirectorySelection::Quit => self.handle_quit(),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 @@ use ratatui::widgets::Wrap;
|
||||
use std::cell::Cell;
|
||||
|
||||
use crate::ascii_animation::AsciiAnimation;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::onboarding::keys;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::tui::FrameRequester;
|
||||
@@ -32,14 +32,15 @@ pub(crate) struct WelcomeWidget {
|
||||
}
|
||||
|
||||
impl KeyboardHandler for WelcomeWidget {
|
||||
/// Rotate the welcome animation when the fixed toggle shortcut fires.
|
||||
///
|
||||
/// The key list includes 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 && keys::TOGGLE_ANIMATION.is_pressed(key_event) {
|
||||
tracing::warn!("Welcome background to press '.'");
|
||||
let _ = self.animation.pick_random_variant();
|
||||
}
|
||||
@@ -115,6 +116,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;
|
||||
@@ -187,4 +190,31 @@ 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,
|
||||
/*variant_idx*/ 0,
|
||||
),
|
||||
animations_enabled: true,
|
||||
animations_suppressed: Cell::new(false),
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user