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
@@ -41,6 +41,8 @@ use crate::history_cell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
#[cfg(not(debug_assertions))]
|
||||
use crate::history_cell::UpdateAvailableHistoryCell;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::legacy_core::append_message_history_entry;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::legacy_core::config::ConfigBuilder;
|
||||
@@ -520,6 +522,7 @@ pub(crate) struct App {
|
||||
initial_history_replay_buffer: Option<InitialHistoryReplayBuffer>,
|
||||
|
||||
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>,
|
||||
@@ -883,6 +886,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\
|
||||
See the Codex keymap documentation for supported actions and examples."
|
||||
)
|
||||
})?;
|
||||
#[cfg(not(debug_assertions))]
|
||||
let upgrade_version = crate::updates::get_upgrade_version(&config);
|
||||
|
||||
@@ -899,6 +909,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(),
|
||||
|
||||
@@ -340,6 +340,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();
|
||||
}
|
||||
@@ -1590,6 +1591,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, .. } => {
|
||||
@@ -1599,6 +1601,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 {
|
||||
@@ -1623,6 +1626,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 {
|
||||
@@ -1640,6 +1644,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(),
|
||||
));
|
||||
}
|
||||
},
|
||||
@@ -1736,10 +1741,154 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::OpenKeymapActionMenu { context, action } => {
|
||||
self.chat_widget
|
||||
.open_keymap_action_menu(context, action, &self.keymap);
|
||||
}
|
||||
AppEvent::OpenKeymapReplaceBindingMenu { context, action } => {
|
||||
self.chat_widget
|
||||
.open_keymap_replace_binding_menu(context, action, &self.keymap);
|
||||
}
|
||||
AppEvent::OpenKeymapCapture {
|
||||
context,
|
||||
action,
|
||||
intent,
|
||||
} => {
|
||||
self.chat_widget
|
||||
.open_keymap_capture(context, action, intent, &self.keymap);
|
||||
}
|
||||
AppEvent::KeymapCaptured {
|
||||
context,
|
||||
action,
|
||||
key,
|
||||
intent,
|
||||
} => {
|
||||
self.apply_keymap_capture(context, action, key, intent)
|
||||
.await;
|
||||
}
|
||||
AppEvent::KeymapCleared { context, action } => {
|
||||
self.apply_keymap_clear(context, action).await;
|
||||
}
|
||||
}
|
||||
Ok(AppRunControl::Continue)
|
||||
}
|
||||
|
||||
async fn apply_keymap_capture(
|
||||
&mut self,
|
||||
context: String,
|
||||
action: String,
|
||||
key: String,
|
||||
intent: crate::app_event::KeymapEditIntent,
|
||||
) {
|
||||
let outcome = match crate::keymap_setup::keymap_with_edit(
|
||||
&self.config.tui_keymap,
|
||||
&self.keymap,
|
||||
&context,
|
||||
&action,
|
||||
&key,
|
||||
&intent,
|
||||
) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (keymap_config, bindings, message) = match outcome {
|
||||
crate::keymap_setup::KeymapEditOutcome::Updated {
|
||||
keymap_config,
|
||||
bindings,
|
||||
message,
|
||||
} => (*keymap_config, bindings, message),
|
||||
crate::keymap_setup::KeymapEditOutcome::Unchanged { message } => {
|
||||
self.chat_widget.add_info_message(message, /*hint*/ None);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let runtime_keymap = match RuntimeKeymap::from_config(&keymap_config) {
|
||||
Ok(runtime_keymap) => runtime_keymap,
|
||||
Err(err) => {
|
||||
let params = crate::keymap_setup::build_keymap_conflict_params(
|
||||
context, action, key, intent, err,
|
||||
);
|
||||
self.chat_widget.show_selection_view(params);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let edit =
|
||||
crate::legacy_core::config::edit::keymap_bindings_edit(&context, &action, &bindings);
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_edits([edit])
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.config.tui_keymap = keymap_config.clone();
|
||||
self.keymap = runtime_keymap.clone();
|
||||
self.chat_widget
|
||||
.apply_keymap_update(keymap_config, &runtime_keymap);
|
||||
self.chat_widget
|
||||
.return_to_keymap_picker(&context, &action, &runtime_keymap);
|
||||
self.chat_widget.add_info_message(message, /*hint*/ None);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(error = %err, "failed to persist keymap binding");
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to save shortcut: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_keymap_clear(&mut self, context: String, action: String) {
|
||||
let keymap_config = match crate::keymap_setup::keymap_without_custom_binding(
|
||||
&self.config.tui_keymap,
|
||||
&context,
|
||||
&action,
|
||||
) {
|
||||
Ok(keymap_config) => keymap_config,
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let runtime_keymap = match RuntimeKeymap::from_config(&keymap_config) {
|
||||
Ok(runtime_keymap) => runtime_keymap,
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to refresh shortcuts: {err}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let edit = crate::legacy_core::config::edit::keymap_binding_clear_edit(&context, &action);
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_edits([edit])
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.config.tui_keymap = keymap_config.clone();
|
||||
self.keymap = runtime_keymap.clone();
|
||||
self.chat_widget
|
||||
.apply_keymap_update(keymap_config, &runtime_keymap);
|
||||
self.chat_widget
|
||||
.return_to_keymap_picker(&context, &action, &runtime_keymap);
|
||||
self.chat_widget.add_info_message(
|
||||
format!("Removed custom shortcut for `{context}.{action}`."),
|
||||
/*hint*/ None,
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(error = %err, "failed to clear keymap binding");
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to remove shortcut: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_exit_mode(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
|
||||
@@ -122,24 +122,46 @@ 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);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('l'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(key_event.code, KeyCode::Esc)
|
||||
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
match key_event {
|
||||
_ if self.keymap.app.clear_terminal.is_pressed(key_event) => {
|
||||
if !self.chat_widget.can_run_ctrl_l_clear_now() {
|
||||
return;
|
||||
}
|
||||
@@ -153,38 +175,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,
|
||||
|
||||
@@ -33,6 +33,7 @@ pub(super) async fn make_test_app() -> App {
|
||||
transcript_reflow: TranscriptReflowState::default(),
|
||||
initial_history_replay_buffer: None,
|
||||
enhanced_keys_supported: false,
|
||||
keymap: crate::keymap::RuntimeKeymap::defaults(),
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
|
||||
@@ -3696,6 +3696,7 @@ async fn make_test_app() -> App {
|
||||
transcript_reflow: TranscriptReflowState::default(),
|
||||
initial_history_replay_buffer: None,
|
||||
enhanced_keys_supported: false,
|
||||
keymap: crate::keymap::RuntimeKeymap::defaults(),
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
@@ -3755,6 +3756,7 @@ async fn make_test_app_with_channels() -> (
|
||||
transcript_reflow: TranscriptReflowState::default(),
|
||||
initial_history_replay_buffer: None,
|
||||
enhanced_keys_supported: false,
|
||||
keymap: crate::keymap::RuntimeKeymap::defaults(),
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
@@ -4754,7 +4756,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;
|
||||
@@ -4978,7 +4983,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(),
|
||||
crate::keymap::RuntimeKeymap::defaults().pager,
|
||||
));
|
||||
app.deferred_history_lines = vec![Line::from("stale buffered line")];
|
||||
app.has_emitted_history_lines = true;
|
||||
app.backtrack.primed = true;
|
||||
|
||||
@@ -146,7 +146,6 @@ impl App {
|
||||
self.overlay_confirm_backtrack(tui);
|
||||
Ok(true)
|
||||
}
|
||||
// Catchall: forward any other events to the overlay widget.
|
||||
_ => {
|
||||
self.overlay_forward_event(tui, event)?;
|
||||
Ok(true)
|
||||
@@ -233,7 +232,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,13 @@ pub(crate) enum RateLimitRefreshOrigin {
|
||||
StatusCommand { request_id: u64 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum KeymapEditIntent {
|
||||
ReplaceAll,
|
||||
AddAlternate,
|
||||
ReplaceOne { old_key: String },
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum AppEvent {
|
||||
@@ -740,6 +747,39 @@ pub(crate) enum AppEvent {
|
||||
SyntaxThemeSelected {
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Open set/remove actions for the selected keymap action.
|
||||
OpenKeymapActionMenu {
|
||||
context: String,
|
||||
action: String,
|
||||
},
|
||||
|
||||
/// Open binding selection before replacing one binding for an action.
|
||||
OpenKeymapReplaceBindingMenu {
|
||||
context: String,
|
||||
action: String,
|
||||
},
|
||||
|
||||
/// Open key capture for the selected keymap action.
|
||||
OpenKeymapCapture {
|
||||
context: String,
|
||||
action: String,
|
||||
intent: KeymapEditIntent,
|
||||
},
|
||||
|
||||
/// Apply a captured key to the selected keymap action.
|
||||
KeymapCaptured {
|
||||
context: String,
|
||||
action: String,
|
||||
key: String,
|
||||
intent: KeymapEditIntent,
|
||||
},
|
||||
|
||||
/// Remove the custom root binding for the selected keymap action.
|
||||
KeymapCleared {
|
||||
context: String,
|
||||
action: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -155,6 +155,7 @@ use super::command_popup::CommandPopup;
|
||||
use super::command_popup::CommandPopupFlags;
|
||||
use super::file_search_popup::FileSearchPopup;
|
||||
use super::footer::CollaborationModeIndicator;
|
||||
use super::footer::FooterKeyHints;
|
||||
use super::footer::FooterMode;
|
||||
use super::footer::FooterProps;
|
||||
use super::footer::GoalStatusIndicator;
|
||||
@@ -185,6 +186,10 @@ 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::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;
|
||||
@@ -390,6 +395,20 @@ 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>,
|
||||
history_search_previous_keys: Vec<KeyBinding>,
|
||||
history_search_next_keys: Vec<KeyBinding>,
|
||||
editor_keymap: EditorKeymap,
|
||||
footer_external_editor_key: Option<KeyBinding>,
|
||||
footer_show_transcript_key: Option<KeyBinding>,
|
||||
footer_insert_newline_key: Option<KeyBinding>,
|
||||
footer_queue_key: Option<KeyBinding>,
|
||||
footer_toggle_shortcuts_key: Option<KeyBinding>,
|
||||
footer_history_search_key: Option<KeyBinding>,
|
||||
footer_reasoning_down_key: Option<KeyBinding>,
|
||||
footer_reasoning_up_key: Option<KeyBinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -486,6 +505,8 @@ impl ChatComposer {
|
||||
config: ChatComposerConfig,
|
||||
) -> Self {
|
||||
let use_shift_enter_hint = enhanced_keys_supported;
|
||||
let default_keymap = RuntimeKeymap::defaults();
|
||||
let default_editor_keymap = default_keymap.editor.clone();
|
||||
|
||||
let mut this = Self {
|
||||
textarea: TextArea::new(),
|
||||
@@ -549,6 +570,32 @@ 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('?')),
|
||||
],
|
||||
history_search_previous_keys: default_keymap.composer.history_search_previous.clone(),
|
||||
history_search_next_keys: default_keymap.composer.history_search_next.clone(),
|
||||
editor_keymap: default_editor_keymap,
|
||||
footer_external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))),
|
||||
footer_show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))),
|
||||
footer_insert_newline_key: footer_insert_newline_key(
|
||||
&default_keymap.editor.insert_newline,
|
||||
use_shift_enter_hint,
|
||||
),
|
||||
footer_queue_key: Some(key_hint::plain(KeyCode::Tab)),
|
||||
footer_toggle_shortcuts_key: Some(key_hint::plain(KeyCode::Char('?'))),
|
||||
footer_history_search_key: primary_binding(
|
||||
&default_keymap.composer.history_search_previous,
|
||||
),
|
||||
footer_reasoning_down_key: primary_binding(
|
||||
&default_keymap.chat.decrease_reasoning_effort,
|
||||
),
|
||||
footer_reasoning_up_key: primary_binding(
|
||||
&default_keymap.chat.increase_reasoning_effort,
|
||||
),
|
||||
};
|
||||
// Apply configuration via the setter to keep side-effects centralized.
|
||||
this.set_disable_paste_burst(disable_paste_burst);
|
||||
@@ -626,6 +673,31 @@ impl ChatComposer {
|
||||
self.goal_command_enabled = enabled;
|
||||
}
|
||||
|
||||
/// Replace composer, editor, and footer-hint key bindings from one runtime snapshot.
|
||||
///
|
||||
/// Submit and queue bindings are cached here because composer dispatch must
|
||||
/// check them before generic textarea editing. The embedded textarea receives
|
||||
/// the same snapshot's editor bindings so a live remap cannot leave submit
|
||||
/// keys updated while cursor/editing keys still use old defaults.
|
||||
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.history_search_previous_keys = keymap.composer.history_search_previous.clone();
|
||||
self.history_search_next_keys = keymap.composer.history_search_next.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_show_transcript_key = primary_binding(&keymap.app.open_transcript);
|
||||
self.footer_insert_newline_key =
|
||||
footer_insert_newline_key(&keymap.editor.insert_newline, self.use_shift_enter_hint);
|
||||
self.footer_queue_key = primary_binding(&keymap.composer.queue);
|
||||
self.footer_toggle_shortcuts_key = primary_binding(&keymap.composer.toggle_shortcuts);
|
||||
self.footer_history_search_key = primary_binding(&keymap.composer.history_search_previous);
|
||||
self.footer_reasoning_down_key = primary_binding(&keymap.chat.decrease_reasoning_effort);
|
||||
self.footer_reasoning_up_key = primary_binding(&keymap.chat.increase_reasoning_effort);
|
||||
}
|
||||
|
||||
pub fn set_collaboration_mode_indicator(
|
||||
&mut self,
|
||||
indicator: Option<CollaborationModeIndicator>,
|
||||
@@ -1445,7 +1517,7 @@ impl ChatComposer {
|
||||
return self.handle_history_search_key(key_event);
|
||||
}
|
||||
|
||||
if Self::is_history_search_key(&key_event) {
|
||||
if Self::is_history_search_key(&key_event, &self.history_search_previous_keys) {
|
||||
return self.begin_history_search();
|
||||
}
|
||||
|
||||
@@ -1630,7 +1702,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));
|
||||
@@ -1687,7 +1759,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
|
||||
@@ -2870,6 +2942,16 @@ 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 || !self.is_bang_shell_command())
|
||||
{
|
||||
return self.handle_submission(self.is_task_running);
|
||||
}
|
||||
|
||||
if self.submit_keys.is_pressed(key_event) {
|
||||
return self.handle_submission(/*should_queue*/ false);
|
||||
}
|
||||
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
@@ -2910,19 +2992,6 @@ impl ChatComposer {
|
||||
}
|
||||
self.handle_input_basic(key_event)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Tab,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if self.is_task_running || !self.is_bang_shell_command() => {
|
||||
self.handle_submission(self.is_task_running)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.handle_submission(/*should_queue*/ false),
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
}
|
||||
@@ -3094,7 +3163,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 {
|
||||
@@ -3170,13 +3239,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();
|
||||
|
||||
@@ -3219,6 +3293,17 @@ impl ChatComposer {
|
||||
context_window_used_tokens: self.context_window_used_tokens,
|
||||
status_line_value: self.status_line_value.clone(),
|
||||
status_line_enabled: self.status_line_enabled,
|
||||
key_hints: FooterKeyHints {
|
||||
toggle_shortcuts: self.footer_toggle_shortcuts_key,
|
||||
queue: self.footer_queue_key,
|
||||
insert_newline: self.footer_insert_newline_key,
|
||||
external_editor: self.footer_external_editor_key,
|
||||
edit_previous: Some(key_hint::plain(KeyCode::Esc)),
|
||||
show_transcript: self.footer_show_transcript_key,
|
||||
history_search: self.footer_history_search_key,
|
||||
reasoning_down: self.footer_reasoning_down_key,
|
||||
reasoning_up: self.footer_reasoning_up_key,
|
||||
},
|
||||
active_agent_label: self.active_agent_label.clone(),
|
||||
}
|
||||
}
|
||||
@@ -3780,6 +3865,23 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_insert_newline_key(
|
||||
bindings: &[KeyBinding],
|
||||
enhanced_keys_supported: bool,
|
||||
) -> Option<KeyBinding> {
|
||||
let shift_enter = key_hint::shift(KeyCode::Enter);
|
||||
if enhanced_keys_supported && bindings.contains(&shift_enter) {
|
||||
return Some(shift_enter);
|
||||
}
|
||||
|
||||
let plain_enter = key_hint::plain(KeyCode::Enter);
|
||||
bindings
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|binding| *binding != plain_enter)
|
||||
.or_else(|| bindings.first().copied())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl ChatComposer {
|
||||
pub fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
|
||||
@@ -4048,6 +4150,7 @@ impl ChatComposer {
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
footer_props.key_hints,
|
||||
))
|
||||
}
|
||||
FooterMode::EscHint
|
||||
@@ -5248,6 +5351,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(
|
||||
/*has_input_focus*/ true,
|
||||
sender,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
/*disable_paste_burst*/ 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]
|
||||
@@ -6929,6 +7056,96 @@ mod tests {
|
||||
assert_queued_slash("/does-not-exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remapped_submit_does_not_fall_back_to_enter() {
|
||||
use crate::key_hint;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
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(
|
||||
/*has_input_focus*/ true,
|
||||
sender,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
composer
|
||||
.textarea
|
||||
.set_text_clearing_elements("explain the change");
|
||||
composer.textarea.set_cursor(composer.textarea.text().len());
|
||||
let mut keymap = RuntimeKeymap::defaults();
|
||||
keymap.composer.submit = vec![key_hint::ctrl(KeyCode::Char('j'))];
|
||||
composer.set_keymap_bindings(&keymap);
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::None, result);
|
||||
assert_eq!("explain the change\n", composer.textarea.text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remapped_queue_does_not_fall_back_to_tab() {
|
||||
use crate::key_hint;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
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(
|
||||
/*has_input_focus*/ true,
|
||||
sender,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
composer.set_task_running(/*running*/ true);
|
||||
composer.textarea.set_text_clearing_elements("queue me");
|
||||
let mut keymap = RuntimeKeymap::defaults();
|
||||
keymap.composer.queue = vec![key_hint::ctrl(KeyCode::Char('q'))];
|
||||
composer.set_keymap_bindings(&keymap);
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::None, result);
|
||||
assert_eq!("queue me", composer.textarea.text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remapped_history_search_does_not_fall_back_to_ctrl_r() {
|
||||
use crate::key_hint;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
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(
|
||||
/*has_input_focus*/ true,
|
||||
sender,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
let mut keymap = RuntimeKeymap::defaults();
|
||||
keymap.composer.history_search_previous = vec![key_hint::plain(KeyCode::F(2))];
|
||||
composer.set_keymap_bindings(&keymap);
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
assert!(!composer.history_search_active());
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
|
||||
assert!(composer.history_search_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_queues_leading_space_slash_as_plain_text_while_task_running() {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -41,6 +41,8 @@ use super::ComposerDraft;
|
||||
use super::InputResult;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::key_hint::has_ctrl_or_alt;
|
||||
use crate::ui_consts::FOOTER_INDENT_COLS;
|
||||
|
||||
@@ -84,44 +86,12 @@ impl ChatComposer {
|
||||
/// some terminals emit. Callers should only use this before generic text handling; treating the
|
||||
/// raw control character as ordinary input would insert an invisible byte into the search query
|
||||
/// or composer draft.
|
||||
pub(super) fn is_history_search_key(key_event: &KeyEvent) -> bool {
|
||||
matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'r')
|
||||
) || matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('\u{0012}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}
|
||||
)
|
||||
pub(super) fn is_history_search_key(key_event: &KeyEvent, bindings: &[KeyBinding]) -> bool {
|
||||
bindings.is_pressed(*key_event)
|
||||
}
|
||||
|
||||
fn is_history_search_forward_key(key_event: &KeyEvent) -> bool {
|
||||
matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'s')
|
||||
) || matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('\u{0013}'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}
|
||||
)
|
||||
fn is_history_search_forward_key(key_event: &KeyEvent, bindings: &[KeyBinding]) -> bool {
|
||||
bindings.is_pressed(*key_event)
|
||||
}
|
||||
|
||||
/// Opens footer-owned reverse history search without previewing history yet.
|
||||
@@ -166,12 +136,14 @@ impl ChatComposer {
|
||||
return (InputResult::None, false);
|
||||
}
|
||||
|
||||
if Self::is_history_search_key(&key_event) || matches!(key_event.code, KeyCode::Up) {
|
||||
if Self::is_history_search_key(&key_event, &self.history_search_previous_keys)
|
||||
|| matches!(key_event.code, KeyCode::Up)
|
||||
{
|
||||
let result = self.history_search_in_direction(HistorySearchDirection::Older);
|
||||
return (result, true);
|
||||
}
|
||||
|
||||
if Self::is_history_search_forward_key(&key_event)
|
||||
if Self::is_history_search_forward_key(&key_event, &self.history_search_next_keys)
|
||||
|| matches!(key_event.code, KeyCode::Down)
|
||||
{
|
||||
let result = self.history_search_in_direction(HistorySearchDirection::Newer);
|
||||
|
||||
@@ -78,6 +78,7 @@ pub(crate) struct FooterProps {
|
||||
pub(crate) context_window_used_tokens: Option<i64>,
|
||||
pub(crate) status_line_value: Option<Line<'static>>,
|
||||
pub(crate) status_line_enabled: bool,
|
||||
pub(crate) key_hints: FooterKeyHints,
|
||||
/// Active thread label shown when the footer is rendering contextual information instead of an
|
||||
/// instructional hint.
|
||||
///
|
||||
@@ -106,6 +107,36 @@ pub(crate) enum GoalStatusIndicator {
|
||||
const MODE_CYCLE_HINT: &str = "shift+tab to cycle";
|
||||
const FOOTER_CONTEXT_GAP_COLS: u16 = 1;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct FooterKeyHints {
|
||||
pub(crate) toggle_shortcuts: Option<KeyBinding>,
|
||||
pub(crate) queue: Option<KeyBinding>,
|
||||
pub(crate) insert_newline: Option<KeyBinding>,
|
||||
pub(crate) external_editor: Option<KeyBinding>,
|
||||
pub(crate) edit_previous: Option<KeyBinding>,
|
||||
pub(crate) show_transcript: Option<KeyBinding>,
|
||||
pub(crate) history_search: Option<KeyBinding>,
|
||||
pub(crate) reasoning_down: Option<KeyBinding>,
|
||||
pub(crate) reasoning_up: Option<KeyBinding>,
|
||||
}
|
||||
|
||||
impl FooterKeyHints {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn default_bindings() -> Self {
|
||||
Self {
|
||||
toggle_shortcuts: Some(key_hint::plain(KeyCode::Char('?'))),
|
||||
queue: Some(key_hint::plain(KeyCode::Tab)),
|
||||
insert_newline: Some(key_hint::ctrl(KeyCode::Char('j'))),
|
||||
external_editor: Some(key_hint::ctrl(KeyCode::Char('g'))),
|
||||
edit_previous: Some(key_hint::plain(KeyCode::Esc)),
|
||||
show_transcript: Some(key_hint::ctrl(KeyCode::Char('t'))),
|
||||
history_search: Some(key_hint::ctrl(KeyCode::Char('r'))),
|
||||
reasoning_down: Some(key_hint::alt(KeyCode::Char(','))),
|
||||
reasoning_up: Some(key_hint::alt(KeyCode::Char('.'))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CollaborationModeIndicator {
|
||||
fn label(self, show_cycle_hint: bool) -> String {
|
||||
let suffix = if show_cycle_hint {
|
||||
@@ -284,21 +315,28 @@ struct LeftSideState {
|
||||
fn left_side_line(
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
state: LeftSideState,
|
||||
key_hints: FooterKeyHints,
|
||||
) -> Line<'static> {
|
||||
let mut line = Line::from("");
|
||||
match state.hint {
|
||||
SummaryHintKind::None => {}
|
||||
SummaryHintKind::Shortcuts => {
|
||||
line.push_span(key_hint::plain(KeyCode::Char('?')));
|
||||
line.push_span(" for shortcuts".dim());
|
||||
if let Some(key) = key_hints.toggle_shortcuts {
|
||||
line.push_span(key);
|
||||
line.push_span(" for shortcuts".dim());
|
||||
}
|
||||
}
|
||||
SummaryHintKind::QueueMessage => {
|
||||
line.push_span(key_hint::plain(KeyCode::Tab));
|
||||
line.push_span(" to queue message".dim());
|
||||
if let Some(key) = key_hints.queue {
|
||||
line.push_span(key);
|
||||
line.push_span(" to queue message".dim());
|
||||
}
|
||||
}
|
||||
SummaryHintKind::QueueShort => {
|
||||
line.push_span(key_hint::plain(KeyCode::Tab));
|
||||
line.push_span(" to queue".dim());
|
||||
if let Some(key) = key_hints.queue {
|
||||
line.push_span(key);
|
||||
line.push_span(" to queue".dim());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -327,6 +365,7 @@ pub(crate) fn single_line_footer_layout(
|
||||
show_cycle_hint: bool,
|
||||
show_shortcuts_hint: bool,
|
||||
show_queue_hint: bool,
|
||||
key_hints: FooterKeyHints,
|
||||
) -> (SummaryLeft, bool) {
|
||||
let hint_kind = if show_queue_hint {
|
||||
SummaryHintKind::QueueMessage
|
||||
@@ -339,7 +378,7 @@ pub(crate) fn single_line_footer_layout(
|
||||
hint: hint_kind,
|
||||
show_cycle_hint,
|
||||
};
|
||||
let default_line = left_side_line(collaboration_mode_indicator, default_state);
|
||||
let default_line = left_side_line(collaboration_mode_indicator, default_state, key_hints);
|
||||
let default_width = default_line.width() as u16;
|
||||
if default_width > 0 && can_show_left_with_context(area, default_width, context_width) {
|
||||
return (SummaryLeft::Default, true);
|
||||
@@ -349,7 +388,7 @@ pub(crate) fn single_line_footer_layout(
|
||||
if state == default_state {
|
||||
default_line.clone()
|
||||
} else {
|
||||
left_side_line(collaboration_mode_indicator, state)
|
||||
left_side_line(collaboration_mode_indicator, state, key_hints)
|
||||
}
|
||||
};
|
||||
let state_width = |state: LeftSideState| -> u16 { state_line(state).width() as u16 };
|
||||
@@ -457,8 +496,12 @@ pub(crate) fn single_line_footer_layout(
|
||||
};
|
||||
// Compute the width without going through `state_line` so we do not
|
||||
// depend on `default_state` (which may still be a queue variant).
|
||||
let mode_only_width =
|
||||
left_side_line(Some(collaboration_mode_indicator), mode_only_state).width() as u16;
|
||||
let mode_only_width = left_side_line(
|
||||
Some(collaboration_mode_indicator),
|
||||
mode_only_state,
|
||||
key_hints,
|
||||
)
|
||||
.width() as u16;
|
||||
if !context_requires_cycle_hint
|
||||
&& can_show_left_with_context(area, mode_only_width, context_width)
|
||||
{
|
||||
@@ -466,6 +509,7 @@ pub(crate) fn single_line_footer_layout(
|
||||
SummaryLeft::Custom(left_side_line(
|
||||
Some(collaboration_mode_indicator),
|
||||
mode_only_state,
|
||||
key_hints,
|
||||
)),
|
||||
true, // show_context
|
||||
);
|
||||
@@ -475,6 +519,7 @@ pub(crate) fn single_line_footer_layout(
|
||||
SummaryLeft::Custom(left_side_line(
|
||||
Some(collaboration_mode_indicator),
|
||||
mode_only_state,
|
||||
key_hints,
|
||||
)),
|
||||
false, // show_context
|
||||
);
|
||||
@@ -637,6 +682,7 @@ fn footer_from_props_lines(
|
||||
show_shortcuts_hint: bool,
|
||||
show_queue_hint: bool,
|
||||
) -> Vec<Line<'static>> {
|
||||
let key_hints = props.key_hints;
|
||||
// Passive footer context can come from the configurable status line, the
|
||||
// active agent label, or both combined.
|
||||
if let Some(status_line) = passive_footer_status_line(props) {
|
||||
@@ -656,7 +702,11 @@ fn footer_from_props_lines(
|
||||
},
|
||||
show_cycle_hint,
|
||||
};
|
||||
vec![left_side_line(collaboration_mode_indicator, state)]
|
||||
vec![left_side_line(
|
||||
collaboration_mode_indicator,
|
||||
state,
|
||||
key_hints,
|
||||
)]
|
||||
}
|
||||
FooterMode::ShortcutOverlay => {
|
||||
let state = ShortcutsState {
|
||||
@@ -664,6 +714,7 @@ fn footer_from_props_lines(
|
||||
esc_backtrack_hint: props.esc_backtrack_hint,
|
||||
is_wsl: props.is_wsl,
|
||||
collaboration_modes_enabled: props.collaboration_modes_enabled,
|
||||
key_hints,
|
||||
};
|
||||
shortcut_overlay_lines(state)
|
||||
}
|
||||
@@ -679,7 +730,11 @@ fn footer_from_props_lines(
|
||||
},
|
||||
show_cycle_hint,
|
||||
};
|
||||
vec![left_side_line(collaboration_mode_indicator, state)]
|
||||
vec![left_side_line(
|
||||
collaboration_mode_indicator,
|
||||
state,
|
||||
key_hints,
|
||||
)]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -781,6 +836,7 @@ struct ShortcutsState {
|
||||
esc_backtrack_hint: bool,
|
||||
is_wsl: bool,
|
||||
collaboration_modes_enabled: bool,
|
||||
key_hints: FooterKeyHints,
|
||||
}
|
||||
|
||||
fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> {
|
||||
@@ -856,10 +912,15 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
|
||||
if change_mode.width() > 0 {
|
||||
ordered.push(change_mode);
|
||||
}
|
||||
ordered.push(Line::from(""));
|
||||
ordered.push(show_transcript);
|
||||
|
||||
build_columns(ordered)
|
||||
let mut lines = build_columns(ordered);
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
"customize shortcuts with ".into(),
|
||||
"/keymap".cyan(),
|
||||
]));
|
||||
lines
|
||||
}
|
||||
|
||||
fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
||||
@@ -987,8 +1048,23 @@ impl ShortcutDescriptor {
|
||||
}
|
||||
|
||||
fn overlay_entry(&self, state: ShortcutsState) -> Option<Line<'static>> {
|
||||
let binding = self.binding_for(state)?;
|
||||
let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]);
|
||||
let key = match self.id {
|
||||
ShortcutId::InsertNewline => state.key_hints.insert_newline,
|
||||
ShortcutId::QueueMessageTab => state.key_hints.queue,
|
||||
ShortcutId::ExternalEditor => state.key_hints.external_editor,
|
||||
ShortcutId::EditPrevious => state.key_hints.edit_previous,
|
||||
ShortcutId::ShowTranscript => state.key_hints.show_transcript,
|
||||
ShortcutId::HistorySearch => state.key_hints.history_search,
|
||||
ShortcutId::ReasoningDown => state.key_hints.reasoning_down,
|
||||
ShortcutId::ReasoningUp => state.key_hints.reasoning_up,
|
||||
ShortcutId::Commands
|
||||
| ShortcutId::ShellCommands
|
||||
| ShortcutId::FilePaths
|
||||
| ShortcutId::PasteImage
|
||||
| ShortcutId::Quit
|
||||
| ShortcutId::ChangeMode => self.binding_for(state).map(|binding| binding.key),
|
||||
}?;
|
||||
let mut line = Line::from(vec![self.prefix.into(), key.into()]);
|
||||
match self.id {
|
||||
ShortcutId::EditPrevious => {
|
||||
if state.esc_backtrack_hint {
|
||||
@@ -996,7 +1072,7 @@ impl ShortcutDescriptor {
|
||||
} else {
|
||||
line.extend(vec![
|
||||
" ".into(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
key.into(),
|
||||
" to edit previous message".into(),
|
||||
]);
|
||||
}
|
||||
@@ -1287,6 +1363,7 @@ mod tests {
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
props.key_hints,
|
||||
);
|
||||
match summary_left {
|
||||
SummaryLeft::Default => {
|
||||
@@ -1374,6 +1451,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1392,6 +1470,10 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints {
|
||||
insert_newline: Some(key_hint::shift(KeyCode::Enter)),
|
||||
..FooterKeyHints::default_bindings()
|
||||
},
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1410,6 +1492,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1428,6 +1511,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1446,6 +1530,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1464,6 +1549,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1482,6 +1568,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1500,6 +1587,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1518,6 +1606,7 @@ mod tests {
|
||||
context_window_used_tokens: Some(123_456),
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1536,6 +1625,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
);
|
||||
@@ -1552,6 +1642,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1581,6 +1672,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1603,6 +1695,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1620,6 +1713,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1637,6 +1731,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1654,6 +1749,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None, // command timed out / empty
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1676,6 +1772,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1698,6 +1795,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1723,6 +1821,7 @@ mod tests {
|
||||
"Status line content that should truncate before the mode indicator".to_string(),
|
||||
)),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1745,6 +1844,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: Some("Robie [explorer]".to_string()),
|
||||
};
|
||||
|
||||
@@ -1762,6 +1862,7 @@ mod tests {
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: Some("Robie [explorer]".to_string()),
|
||||
};
|
||||
|
||||
@@ -1785,6 +1886,7 @@ mod tests {
|
||||
.to_string(),
|
||||
)),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -1838,6 +1940,7 @@ mod tests {
|
||||
esc_backtrack_hint: false,
|
||||
is_wsl,
|
||||
collaboration_modes_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
})
|
||||
.expect("shortcut binding")
|
||||
.key;
|
||||
|
||||
@@ -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() {
|
||||
@@ -800,23 +808,25 @@ impl ListSelectionView {
|
||||
|
||||
impl BottomPaneView for ListSelectionView {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
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.
|
||||
let is_plain_text_char = matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('p'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
code: KeyCode::Char(ch),
|
||||
modifiers,
|
||||
..
|
||||
} if !ch.is_ascii_control()
|
||||
&& !modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !modifiers.contains(KeyModifiers::ALT)
|
||||
);
|
||||
let allow_plain_char_navigation = !self.is_searchable || !is_plain_text_char;
|
||||
|
||||
match key_event {
|
||||
_ if allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event) => {
|
||||
self.move_up()
|
||||
}
|
||||
_ if 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,
|
||||
..
|
||||
@@ -825,30 +835,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,
|
||||
..
|
||||
@@ -872,11 +858,14 @@ 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();
|
||||
}
|
||||
_ if self.keymap.accept.is_pressed(key_event) => self.accept(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
..
|
||||
} if c.is_ascii_control() => {}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
@@ -914,11 +903,6 @@ impl BottomPaneView for ListSelectionView {
|
||||
self.accept();
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.accept(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -951,6 +935,10 @@ impl BottomPaneView for ListSelectionView {
|
||||
ListSelectionView::active_tab_id(self)
|
||||
}
|
||||
|
||||
fn prefer_esc_to_handle_key_event(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if let Some(cb) = &self.on_cancel {
|
||||
cb(&self.app_event_tx);
|
||||
@@ -1252,6 +1240,8 @@ mod tests {
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -1303,6 +1293,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);
|
||||
@@ -1322,7 +1316,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
ListSelectionView::new(
|
||||
new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
subtitle: subtitle.map(str::to_string),
|
||||
@@ -1402,6 +1396,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, width);
|
||||
@@ -1439,7 +1434,7 @@ mod tests {
|
||||
Some(&codex_home),
|
||||
Some(94),
|
||||
);
|
||||
let view = ListSelectionView::new(params, tx);
|
||||
let view = new_view(params, tx);
|
||||
|
||||
let rendered = render_lines_in_area(&view, /*width*/ 94, /*height*/ 35);
|
||||
assert!(rendered.contains("Move up/down to live preview themes"));
|
||||
@@ -1481,6 +1476,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
let area = Rect::new(0, 0, 120, 35);
|
||||
let mut buf = Buffer::empty(area);
|
||||
@@ -1517,7 +1513,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),
|
||||
@@ -1544,7 +1540,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()),
|
||||
@@ -1597,6 +1593,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
view.set_search_query("beta".to_string());
|
||||
|
||||
@@ -1659,6 +1656,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
assert_eq!(view.active_tab_id(), Some("beta"));
|
||||
@@ -1690,6 +1688,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
view.set_search_query("plugin".to_string());
|
||||
|
||||
@@ -1729,6 +1728,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
let (wrapped_tx_raw, _wrapped_rx) = unbounded_channel::<AppEvent>();
|
||||
let wrapped_tx = AppEventSender::new(wrapped_tx_raw);
|
||||
@@ -1747,6 +1747,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
wrapped_tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let rendered = render_lines_with_width(&single_line_view, /*width*/ 36);
|
||||
@@ -1802,6 +1803,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
auto_tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
let (widened_tx_raw, _widened_rx) = unbounded_channel::<AppEvent>();
|
||||
let widened_tx = AppEventSender::new(widened_tx_raw);
|
||||
@@ -1814,6 +1816,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
widened_tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let auto_rendered = render_lines_with_width(&auto_view, /*width*/ 48);
|
||||
@@ -1845,6 +1848,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
view.set_search_query("no-matches".to_string());
|
||||
|
||||
@@ -1875,6 +1879,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
@@ -1921,6 +1926,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
assert_eq!(view.selected_actual_idx(), Some(1));
|
||||
@@ -1944,6 +1950,100 @@ mod tests {
|
||||
assert_eq!(view.take_last_selected_index(), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c0_ctrl_p_respects_unbound_list_move_up() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut keymap = crate::keymap::RuntimeKeymap::defaults().list;
|
||||
keymap.move_up.clear();
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "First".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Second".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
initial_selected_idx: Some(1),
|
||||
is_searchable: true,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
keymap,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(view.selected_actual_idx(), Some(1));
|
||||
assert_eq!(view.search_query, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c0_ctrl_n_respects_unbound_list_move_down() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut keymap = crate::keymap::RuntimeKeymap::defaults().list;
|
||||
keymap.move_down.clear();
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "First".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Second".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
is_searchable: true,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
keymap,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('\u{000e}'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(view.selected_actual_idx(), Some(0));
|
||||
assert_eq!(view.search_query, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c0_ctrl_p_respects_remapped_list_move_down() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut keymap = crate::keymap::RuntimeKeymap::defaults().list;
|
||||
keymap.move_up.clear();
|
||||
keymap.move_down = vec![crate::key_hint::ctrl(KeyCode::Char('p'))];
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "First".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Second".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
is_searchable: true,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
keymap,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(view.selected_actual_idx(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_long_option_without_overflowing_columns() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -1960,7 +2060,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Approval".to_string()),
|
||||
items,
|
||||
@@ -2018,7 +2118,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Model and Effort".to_string()),
|
||||
items,
|
||||
@@ -2052,7 +2152,7 @@ mod tests {
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items,
|
||||
@@ -2100,7 +2200,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Select Model and Effort".to_string()),
|
||||
items,
|
||||
@@ -2127,7 +2227,7 @@ mod tests {
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items,
|
||||
@@ -2178,6 +2278,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, /*width*/ 96);
|
||||
@@ -2212,6 +2313,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let before_scroll = render_lines_with_width(&view, width);
|
||||
@@ -2253,6 +2355,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let content_width: u16 = 120;
|
||||
@@ -2280,6 +2383,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
assert_eq!(view.side_layout_width(/*content_width*/ 80), None);
|
||||
@@ -2310,6 +2414,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let rendered = render_lines_with_width(&view, /*width*/ 70);
|
||||
@@ -2344,6 +2449,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let width = 120;
|
||||
@@ -2403,6 +2509,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let width = 120;
|
||||
|
||||
@@ -24,11 +24,12 @@ 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;
|
||||
use crate::tui::FrameRequester;
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
pub(crate) use bottom_pane_view::BottomPaneView;
|
||||
use bottom_pane_view::ViewCompletion;
|
||||
use codex_core_skills::model::SkillMetadata;
|
||||
use codex_features::Features;
|
||||
@@ -99,6 +100,8 @@ pub(crate) use footer::GoalStatusIndicator;
|
||||
#[cfg(test)]
|
||||
pub(crate) use footer::goal_status_indicator_line;
|
||||
pub(crate) use list_selection_view::ColumnWidthMode;
|
||||
#[cfg(test)]
|
||||
pub(crate) use list_selection_view::ListSelectionView;
|
||||
pub(crate) use list_selection_view::SelectionRowDisplay;
|
||||
pub(crate) use list_selection_view::SelectionToggle;
|
||||
pub(crate) use list_selection_view::SelectionViewParams;
|
||||
@@ -220,6 +223,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 {
|
||||
@@ -253,6 +257,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,
|
||||
@@ -273,6 +279,7 @@ impl BottomPane {
|
||||
animations_enabled,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
keymap,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +327,19 @@ impl BottomPane {
|
||||
self.composer.record_pending_slash_command_history();
|
||||
}
|
||||
|
||||
/// Replace all bottom-pane keymap caches from one resolved runtime keymap.
|
||||
///
|
||||
/// The bottom pane owns several input surfaces: composer, overlays, and
|
||||
/// selection views. Applying one snapshot through this method keeps those
|
||||
/// surfaces synchronized after config reloads or interactive remaps. Callers
|
||||
/// should not update the composer directly unless they deliberately want
|
||||
/// overlays and selection views to continue using the previous bindings.
|
||||
pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
|
||||
self.keymap = keymap.clone();
|
||||
self.composer.set_keymap_bindings(keymap);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
|
||||
pub(crate) fn drain_pending_submission_state(&mut self) {
|
||||
let _ = self.take_recent_submission_images_with_placeholders();
|
||||
@@ -393,7 +413,7 @@ impl BottomPane {
|
||||
|
||||
/// Update the key hint shown next to queued messages so it matches the
|
||||
/// binding that `ChatWidget` actually listens for.
|
||||
pub(crate) fn set_queued_message_edit_binding(&mut self, binding: KeyBinding) {
|
||||
pub(crate) fn set_queued_message_edit_binding(&mut self, binding: Option<KeyBinding>) {
|
||||
self.pending_input_preview.set_edit_binding(binding);
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -491,8 +511,13 @@ impl BottomPane {
|
||||
let Some(first) = self.delayed_approval_requests.pop_front() else {
|
||||
return;
|
||||
};
|
||||
let mut modal =
|
||||
ApprovalOverlay::new(first.request, self.app_event_tx.clone(), first.features);
|
||||
let mut modal = ApprovalOverlay::new(
|
||||
first.request,
|
||||
self.app_event_tx.clone(),
|
||||
first.features,
|
||||
self.keymap.approval.clone(),
|
||||
self.keymap.list.clone(),
|
||||
);
|
||||
while let Some(delayed) = self.delayed_approval_requests.pop_back() {
|
||||
modal.enqueue_request(delayed.request);
|
||||
}
|
||||
@@ -933,16 +958,32 @@ 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());
|
||||
pub(crate) fn show_selection_view(
|
||||
&mut self,
|
||||
mut params: list_selection_view::SelectionViewParams,
|
||||
) {
|
||||
self.apply_standard_popup_hint(&mut params);
|
||||
let view = list_selection_view::ListSelectionView::new(
|
||||
params,
|
||||
self.app_event_tx.clone(),
|
||||
self.keymap.list.clone(),
|
||||
);
|
||||
self.push_view(Box::new(view));
|
||||
}
|
||||
|
||||
fn apply_standard_popup_hint(&self, params: &mut list_selection_view::SelectionViewParams) {
|
||||
if params.footer_hint.is_none()
|
||||
|| params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line())
|
||||
{
|
||||
params.footer_hint = Some(self.standard_popup_hint_line());
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the active selection view when it matches `view_id`.
|
||||
pub(crate) fn replace_selection_view_if_active(
|
||||
&mut self,
|
||||
view_id: &'static str,
|
||||
params: list_selection_view::SelectionViewParams,
|
||||
mut params: list_selection_view::SelectionViewParams,
|
||||
) -> bool {
|
||||
let is_match = self
|
||||
.view_stack
|
||||
@@ -953,7 +994,50 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
self.view_stack.pop();
|
||||
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
|
||||
self.apply_standard_popup_hint(&mut params);
|
||||
let view = list_selection_view::ListSelectionView::new(
|
||||
params,
|
||||
self.app_event_tx.clone(),
|
||||
self.keymap.list.clone(),
|
||||
);
|
||||
self.push_view(Box::new(view));
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn standard_popup_hint_line(&self) -> Line<'static> {
|
||||
popup_consts::standard_popup_hint_line_for_keymap(&self.keymap.list)
|
||||
}
|
||||
|
||||
/// Replace one or more active views whose IDs are in `view_ids` with a
|
||||
/// generic list selection view.
|
||||
pub(crate) fn replace_active_views_with_selection_view(
|
||||
&mut self,
|
||||
view_ids: &[&'static str],
|
||||
mut params: list_selection_view::SelectionViewParams,
|
||||
) -> bool {
|
||||
let is_match = self
|
||||
.view_stack
|
||||
.last()
|
||||
.and_then(|view| view.view_id())
|
||||
.is_some_and(|view_id| view_ids.contains(&view_id));
|
||||
if !is_match {
|
||||
return false;
|
||||
}
|
||||
|
||||
while self
|
||||
.view_stack
|
||||
.last()
|
||||
.and_then(|view| view.view_id())
|
||||
.is_some_and(|view_id| view_ids.contains(&view_id))
|
||||
{
|
||||
self.view_stack.pop();
|
||||
}
|
||||
self.apply_standard_popup_hint(&mut params);
|
||||
let view = list_selection_view::ListSelectionView::new(
|
||||
params,
|
||||
self.app_event_tx.clone(),
|
||||
self.keymap.list.clone(),
|
||||
);
|
||||
self.push_view(Box::new(view));
|
||||
true
|
||||
}
|
||||
@@ -1031,11 +1115,15 @@ impl BottomPane {
|
||||
.is_some_and(bottom_pane_view::BottomPaneView::terminal_title_requires_action)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn has_active_view(&self) -> bool {
|
||||
!self.view_stack.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn active_view_id(&self) -> Option<&'static str> {
|
||||
self.view_stack.last().and_then(|view| view.view_id())
|
||||
}
|
||||
|
||||
/// Return true when the pane is in the regular composer state without any
|
||||
/// overlays or popups and not running a task. This is the safe context to
|
||||
/// use Esc-Esc for backtracking from the main view.
|
||||
@@ -1087,7 +1175,13 @@ impl BottomPane {
|
||||
self.maybe_show_delayed_approval_requests_at(now);
|
||||
} else {
|
||||
// No recent composer activity, so show the approval modal immediately.
|
||||
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));
|
||||
}
|
||||
@@ -2414,6 +2508,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_view_esc_respects_remapped_list_cancel() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = test_pane(tx);
|
||||
let mut keymap = RuntimeKeymap::defaults();
|
||||
keymap.list.cancel = vec![crate::key_hint::plain(KeyCode::Char('q'))];
|
||||
pane.set_keymap_bindings(&keymap);
|
||||
pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Agents".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Main".to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
on_cancel: Some(Box::new(|tx: &_| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert!(pane.active_view().is_some());
|
||||
assert!(rx.try_recv().is_err());
|
||||
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
|
||||
|
||||
assert!(pane.no_modal_or_popup_active());
|
||||
assert!(matches!(rx.try_recv(), Ok(AppEvent::OpenApprovalsPopup)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_routes_to_handle_key_event_when_requested() {
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -26,7 +26,7 @@ pub(crate) struct PendingInputPreview {
|
||||
pub queued_messages: Vec<String>,
|
||||
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
|
||||
/// be overridden for terminals where that chord is unavailable.
|
||||
edit_binding: key_hint::KeyBinding,
|
||||
edit_binding: Option<key_hint::KeyBinding>,
|
||||
}
|
||||
|
||||
const PREVIEW_LINE_LIMIT: usize = 3;
|
||||
@@ -37,14 +37,14 @@ impl PendingInputPreview {
|
||||
pending_steers: Vec::new(),
|
||||
rejected_steers: Vec::new(),
|
||||
queued_messages: Vec::new(),
|
||||
edit_binding: key_hint::alt(KeyCode::Up),
|
||||
edit_binding: Some(key_hint::alt(KeyCode::Up)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the keybinding shown in the hint line at the bottom of the
|
||||
/// queued-messages list. The caller is responsible for also wiring the
|
||||
/// corresponding key event handler.
|
||||
pub(crate) fn set_edit_binding(&mut self, binding: key_hint::KeyBinding) {
|
||||
pub(crate) fn set_edit_binding(&mut self, binding: Option<key_hint::KeyBinding>) {
|
||||
self.edit_binding = binding;
|
||||
}
|
||||
|
||||
@@ -145,11 +145,13 @@ impl PendingInputPreview {
|
||||
}
|
||||
}
|
||||
|
||||
if !self.queued_messages.is_empty() {
|
||||
if !self.queued_messages.is_empty()
|
||||
&& let Some(edit_binding) = self.edit_binding
|
||||
{
|
||||
lines.push(
|
||||
Line::from(vec![
|
||||
" ".into(),
|
||||
self.edit_binding.into(),
|
||||
edit_binding.into(),
|
||||
" edit last queued message".into(),
|
||||
])
|
||||
.dim(),
|
||||
@@ -208,7 +210,7 @@ mod tests {
|
||||
fn render_one_message_with_shift_left_binding() {
|
||||
let mut queue = PendingInputPreview::new();
|
||||
queue.queued_messages.push("Hello, world!".to_string());
|
||||
queue.set_edit_binding(key_hint::shift(KeyCode::Left));
|
||||
queue.set_edit_binding(Some(key_hint::shift(KeyCode::Left)));
|
||||
let width = 40;
|
||||
let height = queue.desired_height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Shared popup-related constants for bottom pane widgets.
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use ratatui::text::Line;
|
||||
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::keymap::ListKeymap;
|
||||
use crate::keymap::primary_binding;
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
/// Maximum number of rows any popup should attempt to display.
|
||||
/// Keep this consistent across all popups for a uniform feel.
|
||||
@@ -19,3 +22,40 @@ pub(crate) fn standard_popup_hint_line() -> Line<'static> {
|
||||
" to go back".into(),
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn standard_popup_hint_line_for_keymap(list_keymap: &ListKeymap) -> Line<'static> {
|
||||
accept_cancel_hint_line(
|
||||
primary_binding(&list_keymap.accept),
|
||||
"to confirm",
|
||||
primary_binding(&list_keymap.cancel),
|
||||
"to go back",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn accept_cancel_hint_line(
|
||||
accept: Option<KeyBinding>,
|
||||
accept_label: &'static str,
|
||||
cancel: Option<KeyBinding>,
|
||||
cancel_label: &'static str,
|
||||
) -> Line<'static> {
|
||||
match (accept, cancel) {
|
||||
(Some(accept), Some(cancel)) => Line::from(vec![
|
||||
"Press ".into(),
|
||||
accept.into(),
|
||||
format!(" {accept_label} or ").into(),
|
||||
cancel.into(),
|
||||
format!(" {cancel_label}").into(),
|
||||
]),
|
||||
(Some(accept), None) => Line::from(vec![
|
||||
"Press ".into(),
|
||||
accept.into(),
|
||||
format!(" {accept_label}").into(),
|
||||
]),
|
||||
(None, Some(cancel)) => Line::from(vec![
|
||||
"Press ".into(),
|
||||
cancel.into(),
|
||||
format!(" {cancel_label}").into(),
|
||||
]),
|
||||
(None, None) => Line::from(""),
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -12,6 +12,6 @@ expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))"
|
||||
› 1. Yes, grant these permissions for this turn (y)
|
||||
2. Yes, grant for this turn with strict auto review (r)
|
||||
3. Yes, grant these permissions for this session (a)
|
||||
4. No, continue without permissions (n)
|
||||
4. No, continue without permissions (d)
|
||||
|
||||
Press enter to confirm or esc to cancel
|
||||
|
||||
+3
-1
@@ -16,4 +16,6 @@ expression: terminal.backend()
|
||||
" ctrl + g to edit in external editor esc again to edit previous message "
|
||||
" ctrl + r search history ctrl + c to exit "
|
||||
" ⌥ + , reasoning down ⌥ + . reasoning up "
|
||||
" ctrl + t to view transcript "
|
||||
" ctrl + t to view transcript "
|
||||
" "
|
||||
" customize shortcuts with /keymap "
|
||||
|
||||
+3
-2
@@ -8,5 +8,6 @@ expression: terminal.backend()
|
||||
" ctrl + g to edit in external editor esc esc to edit previous message "
|
||||
" ctrl + r search history ctrl + c to exit "
|
||||
" ⌥ + , reasoning down ⌥ + . reasoning up "
|
||||
" shift + tab to change mode "
|
||||
" ctrl + t to view transcript "
|
||||
" shift + tab to change mode ctrl + t to view transcript "
|
||||
" "
|
||||
" customize shortcuts with /keymap "
|
||||
|
||||
+3
-1
@@ -8,4 +8,6 @@ expression: terminal.backend()
|
||||
" ctrl + g to edit in external editor esc again to edit previous message "
|
||||
" ctrl + r search history ctrl + c to exit "
|
||||
" ⌥ + , reasoning down ⌥ + . reasoning up "
|
||||
" ctrl + t to view transcript "
|
||||
" ctrl + t to view transcript "
|
||||
" "
|
||||
" customize shortcuts with /keymap "
|
||||
|
||||
@@ -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,7 @@ pub(crate) struct TextArea {
|
||||
elements: Vec<TextElement>,
|
||||
next_element_id: u64,
|
||||
kill_buffer: String,
|
||||
editor_keymap: EditorKeymap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -116,9 +120,20 @@ impl TextArea {
|
||||
elements: Vec::new(),
|
||||
next_element_id: 1,
|
||||
kill_buffer: String::new(),
|
||||
editor_keymap: RuntimeKeymap::defaults().editor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the editor keymap used by subsequent text-editing input.
|
||||
///
|
||||
/// This method intentionally swaps only the keymap cache. It does not
|
||||
/// reinterpret pending input, move the cursor, or mutate the kill buffer, so
|
||||
/// callers can safely apply a live config update while preserving the
|
||||
/// current draft exactly as typed.
|
||||
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
|
||||
@@ -319,243 +334,123 @@ impl TextArea {
|
||||
if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
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),
|
||||
let keymap = self.editor_keymap.clone();
|
||||
self.input_with_keymap(event, &keymap);
|
||||
}
|
||||
|
||||
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,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_left();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_right();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('b'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_left();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('f'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_right();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('p'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_up();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('n'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
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);
|
||||
}
|
||||
_ => {}
|
||||
pub fn input_with_keymap(&mut self, event: KeyEvent, keymap: &EditorKeymap) {
|
||||
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(/*n*/ 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(/*n*/ 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>.
|
||||
if c.is_ascii_control() {
|
||||
return;
|
||||
}
|
||||
self.insert_str(&c.to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = event;
|
||||
}
|
||||
|
||||
// ####### Input Functions #######
|
||||
@@ -1941,6 +1836,37 @@ mod tests {
|
||||
assert_eq!(t.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c0_control_chars_respect_unbound_editor_movement() {
|
||||
let mut t = ta_with("a\nb");
|
||||
t.set_cursor(/*pos*/ 2);
|
||||
let mut keymap = RuntimeKeymap::defaults().editor;
|
||||
keymap.move_up.clear();
|
||||
|
||||
t.input_with_keymap(
|
||||
KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE),
|
||||
&keymap,
|
||||
);
|
||||
|
||||
assert_eq!(t.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c0_control_chars_respect_remapped_editor_movement() {
|
||||
let mut t = ta_with("a\nb");
|
||||
t.set_cursor(/*pos*/ 0);
|
||||
let mut keymap = RuntimeKeymap::defaults().editor;
|
||||
keymap.move_up.clear();
|
||||
keymap.move_down = vec![crate::key_hint::ctrl(KeyCode::Char('p'))];
|
||||
|
||||
t.input_with_keymap(
|
||||
KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE),
|
||||
&keymap,
|
||||
);
|
||||
|
||||
assert_eq!(t.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_backward_word_alt_keys() {
|
||||
// Test the custom Alt+Ctrl+h binding
|
||||
|
||||
@@ -311,6 +311,17 @@ fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyB
|
||||
}
|
||||
}
|
||||
|
||||
fn queued_message_edit_hint_binding(
|
||||
bindings: &[KeyBinding],
|
||||
terminal_info: TerminalInfo,
|
||||
) -> Option<KeyBinding> {
|
||||
let terminal_binding = queued_message_edit_binding_for_terminal(terminal_info);
|
||||
bindings
|
||||
.contains(&terminal_binding)
|
||||
.then_some(terminal_binding)
|
||||
.or_else(|| bindings.first().copied())
|
||||
}
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use crate::app_event::ExitMode;
|
||||
@@ -361,8 +372,9 @@ use crate::history_cell::PlainHistoryCell;
|
||||
use crate::history_cell::WebSearchCell;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
#[cfg(test)]
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::ChatKeymap;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::FlexRenderable;
|
||||
@@ -382,6 +394,7 @@ use self::goal_status::goal_status_indicator_from_app_goal;
|
||||
mod goal_menu;
|
||||
mod interrupts;
|
||||
use self::interrupts::InterruptManager;
|
||||
mod keymap_picker;
|
||||
mod session_header;
|
||||
use self::session_header::SessionHeader;
|
||||
mod skills;
|
||||
@@ -832,6 +845,7 @@ pub(crate) struct ChatWidget {
|
||||
plan_stream_controller: Option<PlanStreamController>,
|
||||
/// Holds the platform clipboard lease so copied text remains available while supported.
|
||||
clipboard_lease: Option<crate::clipboard_copy::ClipboardLease>,
|
||||
copy_last_response_binding: Vec<KeyBinding>,
|
||||
/// Raw markdown of the most recently completed agent response that
|
||||
/// survived any local thread rollback.
|
||||
last_agent_markdown: Option<String>,
|
||||
@@ -965,11 +979,12 @@ pub(crate) struct ChatWidget {
|
||||
// When set, the next interrupt should resubmit all pending steers as one
|
||||
// fresh user turn instead of restoring them into the composer.
|
||||
submit_pending_steers_after_interrupt: bool,
|
||||
/// Terminal-appropriate keybinding for popping the most-recently queued
|
||||
/// message back into the composer. Determined once at construction time via
|
||||
/// [`queued_message_edit_binding_for_terminal`] and propagated to
|
||||
/// `BottomPane` so the hint text matches the actual shortcut.
|
||||
queued_message_edit_binding: KeyBinding,
|
||||
/// Main chat-surface bindings resolved from `tui.keymap.chat`.
|
||||
chat_keymap: ChatKeymap,
|
||||
/// Keybinding to show for popping the most-recently queued message back
|
||||
/// into the composer. This may differ from the first configured binding
|
||||
/// when the default set includes a terminal-specific fallback.
|
||||
queued_message_edit_hint_binding: Option<KeyBinding>,
|
||||
// Pending notification to show when unfocused on next Draw
|
||||
pending_notification: Option<Notification>,
|
||||
/// When `Some`, the user has pressed a quit shortcut and the second press
|
||||
@@ -5482,7 +5497,21 @@ impl ChatWidget {
|
||||
|
||||
let current_cwd = Some(config.cwd.to_path_buf());
|
||||
let effective_service_tier = config.service_tier;
|
||||
let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info());
|
||||
let current_terminal_info = terminal_info();
|
||||
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok();
|
||||
let default_keymap = RuntimeKeymap::defaults();
|
||||
let copy_last_response_binding = runtime_keymap
|
||||
.as_ref()
|
||||
.map(|keymap| keymap.app.copy.clone())
|
||||
.unwrap_or_else(|| default_keymap.app.copy.clone());
|
||||
let chat_keymap = runtime_keymap
|
||||
.as_ref()
|
||||
.map(|keymap| keymap.chat.clone())
|
||||
.unwrap_or_else(|| default_keymap.chat.clone());
|
||||
let queued_message_edit_hint_binding = queued_message_edit_hint_binding(
|
||||
&chat_keymap.edit_queued_message,
|
||||
current_terminal_info,
|
||||
);
|
||||
let mut widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
@@ -5524,6 +5553,7 @@ impl ChatWidget {
|
||||
stream_controller: None,
|
||||
plan_stream_controller: None,
|
||||
clipboard_lease: None,
|
||||
copy_last_response_binding,
|
||||
running_commands: HashMap::new(),
|
||||
collab_agent_metadata: HashMap::new(),
|
||||
pending_collab_spawn_requests: HashMap::new(),
|
||||
@@ -5583,7 +5613,8 @@ impl ChatWidget {
|
||||
rejected_steer_history_records: VecDeque::new(),
|
||||
pending_steers: VecDeque::new(),
|
||||
submit_pending_steers_after_interrupt: false,
|
||||
queued_message_edit_binding,
|
||||
chat_keymap,
|
||||
queued_message_edit_hint_binding,
|
||||
show_welcome_banner: is_first_run,
|
||||
startup_tooltip_override,
|
||||
suppress_session_configured_redraw: false,
|
||||
@@ -5628,6 +5659,10 @@ impl ChatWidget {
|
||||
last_non_retry_error: None,
|
||||
};
|
||||
|
||||
widget.prefetch_rate_limits();
|
||||
if let Some(keymap) = runtime_keymap {
|
||||
widget.bottom_pane.set_keymap_bindings(&keymap);
|
||||
}
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
|
||||
@@ -5646,7 +5681,7 @@ impl ChatWidget {
|
||||
widget.sync_goal_command_enabled();
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_queued_message_edit_binding(widget.queued_message_edit_binding);
|
||||
.set_queued_message_edit_binding(widget.queued_message_edit_hint_binding);
|
||||
#[cfg(target_os = "windows")]
|
||||
widget.bottom_pane.set_windows_degraded_sandbox_active(
|
||||
crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
@@ -5666,6 +5701,24 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if self.bottom_pane.has_active_view()
|
||||
&& !matches!(
|
||||
key_event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c')
|
||||
)
|
||||
{
|
||||
self.bottom_pane.handle_key_event(key_event);
|
||||
if self.bottom_pane.no_modal_or_popup_active() {
|
||||
self.maybe_send_next_queued_input();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.handle_reasoning_shortcut(key_event) {
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
@@ -5673,20 +5726,17 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& self.copy_last_response_binding.is_pressed(key_event)
|
||||
{
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.copy_last_agent_markdown();
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event {
|
||||
// Ctrl+O - copy last agent response from the main view.
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('o'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.copy_last_agent_markdown();
|
||||
return;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
@@ -5745,8 +5795,9 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& self.queued_message_edit_binding.is_press(key_event)
|
||||
&& self.chat_keymap.edit_queued_message.is_pressed(key_event)
|
||||
&& self.has_queued_follow_up_messages()
|
||||
&& self.bottom_pane.no_modal_or_popup_active()
|
||||
{
|
||||
if let Some(user_message) = self.pop_latest_queued_user_message() {
|
||||
self.restore_user_message_to_composer(user_message);
|
||||
@@ -7873,7 +7924,7 @@ impl ChatWidget {
|
||||
} else {
|
||||
// Show explanation when there are no structured findings.
|
||||
let mut rendered: Vec<ratatui::text::Line<'static>> = vec!["".into()];
|
||||
append_markdown(
|
||||
crate::markdown::append_markdown(
|
||||
&explanation,
|
||||
/*width*/ None,
|
||||
Some(self.config.cwd.as_path()),
|
||||
@@ -9075,7 +9126,7 @@ impl ChatWidget {
|
||||
"Access legacy models by running codex -m <model_name> or in your config.toml",
|
||||
);
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
footer_hint: Some("Press enter to select reasoning effort, or esc to dismiss.".into()),
|
||||
footer_hint: Some(self.bottom_pane.standard_popup_hint_line()),
|
||||
items,
|
||||
header,
|
||||
..Default::default()
|
||||
@@ -11279,7 +11330,7 @@ impl ChatWidget {
|
||||
SelectionViewParams {
|
||||
view_id: Some(CONNECTORS_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(Self::connectors_popup_hint_line()),
|
||||
footer_hint: Some(self.bottom_pane.standard_popup_hint_line()),
|
||||
items,
|
||||
is_searchable: true,
|
||||
search_placeholder: Some("Type to search apps".to_string()),
|
||||
@@ -11309,14 +11360,6 @@ impl ChatWidget {
|
||||
);
|
||||
}
|
||||
|
||||
fn connectors_popup_hint_line() -> Line<'static> {
|
||||
Line::from(vec![
|
||||
"Press ".into(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" to close.".into(),
|
||||
])
|
||||
}
|
||||
|
||||
fn connector_brief_description(connector: &AppInfo) -> String {
|
||||
let status_label = Self::connector_status_label(connector);
|
||||
match Self::connector_description(connector) {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! `ChatWidget` integration points for the `/keymap` picker flow.
|
||||
//!
|
||||
//! The picker model, capture view, and edit semantics live in [`crate::keymap_setup`]. This module
|
||||
//! keeps only the `ChatWidget`-owned responsibilities: opening those views in the bottom pane,
|
||||
//! routing users back to the right picker row after an edit, and synchronizing the committed
|
||||
//! keymap config back into the live widget state. Keeping these methods outside `chatwidget.rs`
|
||||
//! keeps the main transcript/event surface from also owning the `/keymap` navigation details.
|
||||
//!
|
||||
//! The important invariant is that any accepted keymap edit must update three places together:
|
||||
//! the stored `Config.tui_keymap`, the cached copy-response binding used by app-level shortcuts,
|
||||
//! and the bottom pane's runtime keymap bindings. Updating only one of those would make the UI
|
||||
//! appear to accept a remap while some handlers still respond to the old keys.
|
||||
|
||||
use codex_config::types::TuiKeymap;
|
||||
use codex_terminal_detection::terminal_info;
|
||||
|
||||
use super::ChatWidget;
|
||||
use super::queued_message_edit_hint_binding;
|
||||
use crate::app_event::KeymapEditIntent;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::keymap_setup;
|
||||
|
||||
impl ChatWidget {
|
||||
/// Opens the root `/keymap` picker using the current `tui.keymap` configuration.
|
||||
///
|
||||
/// This validates the persisted keymap before building picker rows because every subsequent
|
||||
/// picker screen needs the effective runtime bindings, including preset defaults and user
|
||||
/// overrides. If the config is invalid, the user sees the parse error instead of a partial
|
||||
/// picker that could commit edits against stale runtime state.
|
||||
pub(crate) fn open_keymap_picker(&mut self) {
|
||||
match RuntimeKeymap::from_config(&self.config.tui_keymap) {
|
||||
Ok(runtime_keymap) => {
|
||||
let params = keymap_setup::build_keymap_picker_params(
|
||||
&runtime_keymap,
|
||||
&self.config.tui_keymap,
|
||||
);
|
||||
self.bottom_pane.show_selection_view(params);
|
||||
}
|
||||
Err(err) => {
|
||||
self.add_error_message(format!("Invalid `tui.keymap` configuration: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the per-action menu for one keymap action.
|
||||
///
|
||||
/// Callers pass the already-resolved runtime keymap from the app event that selected the
|
||||
/// action. Recomputing it here would risk showing a menu for a different config if another
|
||||
/// keymap edit was applied between the picker event and this handler.
|
||||
pub(crate) fn open_keymap_action_menu(
|
||||
&mut self,
|
||||
context: String,
|
||||
action: String,
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
) {
|
||||
let params = keymap_setup::build_keymap_action_menu_params(
|
||||
context,
|
||||
action,
|
||||
runtime_keymap,
|
||||
&self.config.tui_keymap,
|
||||
);
|
||||
self.bottom_pane.show_selection_view(params);
|
||||
}
|
||||
|
||||
/// Opens the key-capture view for a set, replace, or alternate-binding edit.
|
||||
///
|
||||
/// The capture view owns raw key interpretation, but `ChatWidget` supplies the event sender so
|
||||
/// the captured key can come back through the same app-event path as menu selections. Bypassing
|
||||
/// that path would skip config persistence and leave the runtime keymap cache unchanged.
|
||||
pub(crate) fn open_keymap_capture(
|
||||
&mut self,
|
||||
context: String,
|
||||
action: String,
|
||||
intent: KeymapEditIntent,
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
) {
|
||||
let view = keymap_setup::build_keymap_capture_view(
|
||||
context,
|
||||
action,
|
||||
intent,
|
||||
runtime_keymap,
|
||||
self.app_event_tx.clone(),
|
||||
);
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Opens the menu that lets the user choose which existing binding to replace.
|
||||
///
|
||||
/// This is only used for actions with multiple effective bindings. The chosen binding is
|
||||
/// carried through the subsequent capture intent so replacement edits do not accidentally
|
||||
/// collapse alternate bindings that should remain available.
|
||||
pub(crate) fn open_keymap_replace_binding_menu(
|
||||
&mut self,
|
||||
context: String,
|
||||
action: String,
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
) {
|
||||
let params =
|
||||
keymap_setup::build_keymap_replace_binding_menu_params(context, action, runtime_keymap);
|
||||
self.bottom_pane.show_selection_view(params);
|
||||
}
|
||||
|
||||
/// Returns to the root picker with the edited action selected.
|
||||
///
|
||||
/// The preferred path replaces any active keymap picker submenu in place so the bottom-pane
|
||||
/// back stack does not accumulate obsolete menus after each edit. If the expected view stack is
|
||||
/// no longer active, this falls back to showing a fresh picker rather than dropping the user on
|
||||
/// a stale screen.
|
||||
pub(crate) fn return_to_keymap_picker(
|
||||
&mut self,
|
||||
context: &str,
|
||||
action: &str,
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
) {
|
||||
let params = keymap_setup::build_keymap_picker_params_for_selected_action(
|
||||
runtime_keymap,
|
||||
&self.config.tui_keymap,
|
||||
context,
|
||||
action,
|
||||
);
|
||||
let replaced = self.bottom_pane.replace_active_views_with_selection_view(
|
||||
&[
|
||||
keymap_setup::KEYMAP_PICKER_VIEW_ID,
|
||||
keymap_setup::KEYMAP_ACTION_MENU_VIEW_ID,
|
||||
keymap_setup::KEYMAP_REPLACE_BINDING_MENU_VIEW_ID,
|
||||
],
|
||||
params,
|
||||
);
|
||||
if !replaced {
|
||||
let params = keymap_setup::build_keymap_picker_params_for_selected_action(
|
||||
runtime_keymap,
|
||||
&self.config.tui_keymap,
|
||||
context,
|
||||
action,
|
||||
);
|
||||
self.bottom_pane.show_selection_view(params);
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Applies a committed keymap edit to the live chat widget.
|
||||
///
|
||||
/// The caller is responsible for persisting the config file before invoking this method. This
|
||||
/// method updates the in-memory config, app-level copy binding cache, and bottom-pane keymap
|
||||
/// bindings as one unit; callers that update only `self.config.tui_keymap` would leave visible
|
||||
/// picker state and active key handlers disagreeing until the next restart.
|
||||
pub(crate) fn apply_keymap_update(
|
||||
&mut self,
|
||||
keymap_config: TuiKeymap,
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
) {
|
||||
self.config.tui_keymap = keymap_config;
|
||||
self.copy_last_response_binding = runtime_keymap.app.copy.clone();
|
||||
self.chat_keymap = runtime_keymap.chat.clone();
|
||||
self.queued_message_edit_hint_binding = queued_message_edit_hint_binding(
|
||||
&self.chat_keymap.edit_queued_message,
|
||||
terminal_info(),
|
||||
);
|
||||
self.bottom_pane
|
||||
.set_queued_message_edit_binding(self.queued_message_edit_hint_binding);
|
||||
self.bottom_pane.set_keymap_bindings(runtime_keymap);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,12 @@
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
use super::ChatWidget;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
|
||||
/// Direction requested by a reasoning-level shortcut.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -32,18 +30,6 @@ pub(super) enum ReasoningShortcutDirection {
|
||||
}
|
||||
|
||||
impl ReasoningShortcutDirection {
|
||||
fn from_key_event(key_event: KeyEvent) -> Option<Self> {
|
||||
if key_event.kind != KeyEventKind::Press || key_event.modifiers != KeyModifiers::ALT {
|
||||
return None;
|
||||
}
|
||||
|
||||
match key_event.code {
|
||||
KeyCode::Char(',') => Some(Self::Lower),
|
||||
KeyCode::Char('.') => Some(Self::Raise),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bound_message(self, effort: ReasoningEffortConfig) -> String {
|
||||
let label = ChatWidget::reasoning_effort_label(effort).to_lowercase();
|
||||
match self {
|
||||
@@ -66,7 +52,19 @@ impl ChatWidget {
|
||||
/// persisting them. In Plan mode, shortcuts apply only to the active
|
||||
/// Plan-mode override and skip the global-vs-Plan scope prompt.
|
||||
pub(super) fn handle_reasoning_shortcut(&mut self, key_event: KeyEvent) -> bool {
|
||||
let Some(direction) = ReasoningShortcutDirection::from_key_event(key_event) else {
|
||||
let direction = if self
|
||||
.chat_keymap
|
||||
.decrease_reasoning_effort
|
||||
.is_pressed(key_event)
|
||||
{
|
||||
ReasoningShortcutDirection::Lower
|
||||
} else if self
|
||||
.chat_keymap
|
||||
.increase_reasoning_effort
|
||||
.is_pressed(key_event)
|
||||
{
|
||||
ReasoningShortcutDirection::Raise
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
@@ -243,6 +243,9 @@ impl ChatWidget {
|
||||
SlashCommand::Permissions => {
|
||||
self.open_permissions_popup();
|
||||
}
|
||||
SlashCommand::Keymap => {
|
||||
self.open_keymap_picker();
|
||||
}
|
||||
SlashCommand::ElevateSandbox => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -860,6 +863,7 @@ impl ChatWidget {
|
||||
| SlashCommand::Goal
|
||||
| SlashCommand::Collab
|
||||
| SlashCommand::Side
|
||||
| SlashCommand::Keymap
|
||||
| SlashCommand::Agent
|
||||
| SlashCommand::MultiAgents
|
||||
| SlashCommand::Approvals
|
||||
|
||||
+3
-1
@@ -1,8 +1,10 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: before
|
||||
---
|
||||
Apps
|
||||
Loading installed and available apps...
|
||||
|
||||
› Loading apps... This updates when the full list is ready.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
|
||||
+3
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
How was this?
|
||||
@@ -11,3 +11,5 @@ expression: popup
|
||||
4. safety check Benign usage blocked due to safety checks or refusals.
|
||||
5. other Slowness, feature suggestion, UX feedback, or anything
|
||||
else.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Select Model and Effort
|
||||
@@ -7,4 +7,4 @@ expression: popup
|
||||
|
||||
› 1. test-visible-model (current) test-visible-model description
|
||||
|
||||
Press enter to select reasoning effort, or esc to dismiss.
|
||||
Press enter to confirm or esc to go back
|
||||
|
||||
+1
-1
@@ -14,4 +14,4 @@ expression: popup
|
||||
› 5. gpt-5.2 (current) Optimized for professional work and long-running
|
||||
agents.
|
||||
|
||||
Press enter to select reasoning effort, or esc to dismiss.
|
||||
Press enter to confirm or esc to go back
|
||||
|
||||
+3
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
@@ -7,3 +7,5 @@ expression: popup
|
||||
This updates when the marketplace list is ready.
|
||||
|
||||
› Loading plugins... This updates when the marketplace list is ready.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
|
||||
@@ -937,9 +937,10 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
|
||||
#[tokio::test]
|
||||
async fn alt_up_edits_most_recent_queued_message() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.queued_message_edit_binding = crate::key_hint::alt(KeyCode::Up);
|
||||
chat.chat_keymap.edit_queued_message = vec![crate::key_hint::alt(KeyCode::Up)];
|
||||
chat.queued_message_edit_hint_binding = Some(crate::key_hint::alt(KeyCode::Up));
|
||||
chat.bottom_pane
|
||||
.set_queued_message_edit_binding(crate::key_hint::alt(KeyCode::Up));
|
||||
.set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
|
||||
|
||||
// Simulate a running task so messages would normally be queued.
|
||||
chat.bottom_pane.set_task_running(/*running*/ true);
|
||||
@@ -967,6 +968,24 @@ async fn alt_up_edits_most_recent_queued_message() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unbound_queued_message_edit_does_not_fall_back_to_alt_up() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.chat_keymap.edit_queued_message = Vec::new();
|
||||
chat.queued_message_edit_hint_binding = None;
|
||||
chat.bottom_pane
|
||||
.set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
|
||||
chat.bottom_pane.set_task_running(/*running*/ true);
|
||||
chat.queued_user_messages
|
||||
.push_back(UserMessage::from("queued".to_string()).into());
|
||||
chat.refresh_pending_input_preview();
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
|
||||
|
||||
assert!(chat.bottom_pane.composer_text().is_empty());
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() {
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo {
|
||||
|
||||
@@ -211,6 +211,7 @@ pub(super) async fn make_chatwidget_manual(
|
||||
stream_controller: None,
|
||||
plan_stream_controller: None,
|
||||
clipboard_lease: None,
|
||||
copy_last_response_binding: crate::keymap::RuntimeKeymap::defaults().app.copy,
|
||||
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
|
||||
recent_auto_review_denials: RecentAutoReviewDenials::default(),
|
||||
terminal_title_status_kind: TerminalTitleStatusKind::Working,
|
||||
@@ -275,7 +276,8 @@ pub(super) async fn make_chatwidget_manual(
|
||||
rejected_steer_history_records: VecDeque::new(),
|
||||
pending_steers: VecDeque::new(),
|
||||
submit_pending_steers_after_interrupt: false,
|
||||
queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up),
|
||||
chat_keymap: crate::keymap::RuntimeKeymap::defaults().chat,
|
||||
queued_message_edit_hint_binding: Some(crate::key_hint::alt(KeyCode::Up)),
|
||||
suppress_session_configured_redraw: false,
|
||||
suppress_initial_user_message_submit: false,
|
||||
pending_notification: None,
|
||||
@@ -739,9 +741,10 @@ pub(super) async fn assert_shift_left_edits_most_recent_queued_message_for_termi
|
||||
terminal_info: TerminalInfo,
|
||||
) {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info);
|
||||
chat.queued_message_edit_hint_binding =
|
||||
Some(queued_message_edit_binding_for_terminal(terminal_info));
|
||||
chat.bottom_pane
|
||||
.set_queued_message_edit_binding(chat.queued_message_edit_binding);
|
||||
.set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
|
||||
|
||||
// Simulate a running task so messages would normally be queued.
|
||||
chat.bottom_pane.set_task_running(/*running*/ true);
|
||||
|
||||
@@ -1341,6 +1341,65 @@ async fn ctrl_o_copy_reports_when_no_agent_response_exists() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keymap_capture_can_capture_current_copy_shortcut() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let runtime_keymap = crate::keymap::RuntimeKeymap::defaults();
|
||||
chat.open_keymap_capture(
|
||||
"composer".to_string(),
|
||||
"submit".to_string(),
|
||||
crate::app_event::KeymapEditIntent::ReplaceAll,
|
||||
&runtime_keymap,
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
|
||||
|
||||
let AppEvent::KeymapCaptured {
|
||||
context,
|
||||
action,
|
||||
key,
|
||||
intent,
|
||||
} = rx.try_recv().expect("captured key event")
|
||||
else {
|
||||
panic!("expected keymap capture event");
|
||||
};
|
||||
assert_eq!(context, "composer");
|
||||
assert_eq!(action, "submit");
|
||||
assert_eq!(key, "ctrl-o");
|
||||
assert_eq!(intent, crate::app_event::KeymapEditIntent::ReplaceAll);
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"copy shortcut should not run while key capture is active"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_shortcut_can_be_remapped() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let mut keymap_config = chat.config_ref().tui_keymap.clone();
|
||||
keymap_config.global.copy = Some(codex_config::types::KeybindingsSpec::One(
|
||||
codex_config::types::KeybindingSpec("ctrl-x".to_string()),
|
||||
));
|
||||
let runtime_keymap =
|
||||
crate::keymap::RuntimeKeymap::from_config(&keymap_config).expect("valid copy remap");
|
||||
chat.apply_keymap_update(keymap_config, &runtime_keymap);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"old copy shortcut should no longer copy"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL));
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected one info message");
|
||||
let rendered = lines_to_single_string(&cells[0]);
|
||||
assert!(
|
||||
rendered.contains("No agent response to copy"),
|
||||
"expected remapped copy shortcut to run, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -15,7 +15,13 @@ const ALT_PREFIX: &str = "alt + ";
|
||||
const CTRL_PREFIX: &str = "ctrl + ";
|
||||
const SHIFT_PREFIX: &str = "shift + ";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
/// One concrete key event that can trigger a TUI action.
|
||||
///
|
||||
/// The binding stores the terminal key code plus the exact modifier set that
|
||||
/// must be present on an incoming press or repeat event. It does not model
|
||||
/// multi-key chords or partial matches; callers that need sequences must keep
|
||||
/// that state outside this type.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub(crate) struct KeyBinding {
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
@@ -27,10 +33,61 @@ impl KeyBinding {
|
||||
}
|
||||
|
||||
pub fn is_press(&self, event: KeyEvent) -> bool {
|
||||
self.key == event.code
|
||||
&& self.modifiers == event.modifiers
|
||||
normalize_shifted_ascii_char(self.key, self.modifiers)
|
||||
== normalize_shifted_ascii_char(event.code, event.modifiers)
|
||||
&& (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat)
|
||||
}
|
||||
|
||||
pub(crate) const fn parts(&self) -> (KeyCode, KeyModifiers) {
|
||||
(self.key, self.modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_shifted_ascii_char(
|
||||
key: KeyCode,
|
||||
mut modifiers: KeyModifiers,
|
||||
) -> (KeyCode, KeyModifiers) {
|
||||
let KeyCode::Char(ch) = key else {
|
||||
return (key, modifiers);
|
||||
};
|
||||
if modifiers.is_empty()
|
||||
&& let Some(ctrl_char) = c0_control_char_to_ctrl_char(ch)
|
||||
{
|
||||
return (KeyCode::Char(ctrl_char), KeyModifiers::CONTROL | modifiers);
|
||||
}
|
||||
if ch.is_ascii_uppercase() {
|
||||
modifiers.insert(KeyModifiers::SHIFT);
|
||||
return (KeyCode::Char(ch.to_ascii_lowercase()), modifiers);
|
||||
}
|
||||
(key, modifiers)
|
||||
}
|
||||
|
||||
fn c0_control_char_to_ctrl_char(ch: char) -> Option<char> {
|
||||
match ch {
|
||||
'\u{0002}' => Some('b'),
|
||||
'\u{0006}' => Some('f'),
|
||||
'\u{000e}' => Some('n'),
|
||||
'\u{0010}' => Some('p'),
|
||||
'\u{0012}' => Some('r'),
|
||||
'\u{0013}' => Some('s'),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Matching helpers for one action's keybinding set.
|
||||
///
|
||||
/// Implementations are expected to treat the slice as alternatives for one
|
||||
/// action. They should not interpret order as priority for dispatch; order is
|
||||
/// reserved for UI hint selection via `primary_binding`.
|
||||
pub(crate) trait KeyBindingListExt {
|
||||
/// True when any binding in this set matches `event`.
|
||||
fn is_pressed(&self, event: KeyEvent) -> bool;
|
||||
}
|
||||
|
||||
impl KeyBindingListExt for [KeyBinding] {
|
||||
fn is_pressed(&self, event: KeyEvent) -> bool {
|
||||
self.iter().any(|binding| binding.is_press(event))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn plain(key: KeyCode) -> KeyBinding {
|
||||
@@ -110,3 +167,88 @@ pub(crate) fn is_altgr(mods: KeyModifiers) -> bool {
|
||||
pub(crate) fn is_altgr(_mods: KeyModifiers) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_press_accepts_press_and_repeat_but_rejects_release() {
|
||||
let binding = ctrl(KeyCode::Char('k'));
|
||||
let press = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL);
|
||||
let repeat = KeyEvent {
|
||||
kind: KeyEventKind::Repeat,
|
||||
..press
|
||||
};
|
||||
let release = KeyEvent {
|
||||
kind: KeyEventKind::Release,
|
||||
..press
|
||||
};
|
||||
let wrong_modifiers = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
|
||||
|
||||
assert!(binding.is_press(press));
|
||||
assert!(binding.is_press(repeat));
|
||||
assert!(!binding.is_press(release));
|
||||
assert!(!binding.is_press(wrong_modifiers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keybinding_list_ext_matches_any_binding() {
|
||||
let bindings = [plain(KeyCode::Char('a')), ctrl(KeyCode::Char('b'))];
|
||||
|
||||
assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)));
|
||||
assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)));
|
||||
assert!(!bindings.is_pressed(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifted_letter_binding_matches_uppercase_char_events() {
|
||||
let binding = shift(KeyCode::Char('a'));
|
||||
|
||||
assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE)));
|
||||
assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_letter_binding_matches_c0_control_char_events() {
|
||||
let binding = ctrl(KeyCode::Char('p'));
|
||||
|
||||
assert!(binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE)));
|
||||
assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::ALT)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_search_ctrl_bindings_match_c0_control_char_events() {
|
||||
assert!(
|
||||
ctrl(KeyCode::Char('r'))
|
||||
.is_press(KeyEvent::new(KeyCode::Char('\u{0012}'), KeyModifiers::NONE))
|
||||
);
|
||||
assert!(
|
||||
ctrl(KeyCode::Char('s'))
|
||||
.is_press(KeyEvent::new(KeyCode::Char('\u{0013}'), KeyModifiers::NONE))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_alt_sets_both_modifiers() {
|
||||
assert_eq!(
|
||||
ctrl_alt(KeyCode::Char('v')).parts(),
|
||||
(
|
||||
KeyCode::Char('v'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_ctrl_or_alt_checks_supported_modifier_combinations() {
|
||||
assert!(!has_ctrl_or_alt(KeyModifiers::NONE));
|
||||
assert!(has_ctrl_or_alt(KeyModifiers::CONTROL));
|
||||
assert!(has_ctrl_or_alt(KeyModifiers::ALT));
|
||||
|
||||
#[cfg(windows)]
|
||||
assert!(!has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT));
|
||||
#[cfg(not(windows))]
|
||||
assert!(has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
||||
//! Catalog and accessors for keymap actions shown by `/keymap`.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use codex_config::types::KeybindingsSpec;
|
||||
use codex_config::types::TuiKeymap;
|
||||
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct KeymapActionDescriptor {
|
||||
pub(super) context: &'static str,
|
||||
pub(super) context_label: &'static str,
|
||||
pub(super) action: &'static str,
|
||||
pub(super) description: &'static str,
|
||||
}
|
||||
|
||||
const fn action(
|
||||
context: &'static str,
|
||||
context_label: &'static str,
|
||||
action: &'static str,
|
||||
description: &'static str,
|
||||
) -> KeymapActionDescriptor {
|
||||
KeymapActionDescriptor {
|
||||
context,
|
||||
context_label,
|
||||
action,
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("global", "Global", "open_transcript", "Open the transcript overlay."),
|
||||
action("global", "Global", "open_external_editor", "Open the current draft in an external editor."),
|
||||
action("global", "Global", "copy", "Copy the last agent response to the clipboard."),
|
||||
action("global", "Global", "clear_terminal", "Clear the terminal UI."),
|
||||
action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."),
|
||||
action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."),
|
||||
action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."),
|
||||
action("composer", "Composer", "submit", "Submit the current composer draft."),
|
||||
action("composer", "Composer", "queue", "Queue the draft while a task is running."),
|
||||
action("composer", "Composer", "toggle_shortcuts", "Show or hide the composer shortcut overlay."),
|
||||
action("composer", "Composer", "history_search_previous", "Open history search or move to the previous match."),
|
||||
action("composer", "Composer", "history_search_next", "Move to the next history search match."),
|
||||
action("editor", "Editor", "insert_newline", "Insert a newline in the editor."),
|
||||
action("editor", "Editor", "move_left", "Move the cursor left."),
|
||||
action("editor", "Editor", "move_right", "Move the cursor right."),
|
||||
action("editor", "Editor", "move_up", "Move the cursor up."),
|
||||
action("editor", "Editor", "move_down", "Move the cursor down."),
|
||||
action("editor", "Editor", "move_word_left", "Move to the beginning of the previous word."),
|
||||
action("editor", "Editor", "move_word_right", "Move to the end of the next word."),
|
||||
action("editor", "Editor", "move_line_start", "Move to the beginning of the line."),
|
||||
action("editor", "Editor", "move_line_end", "Move to the end of the line."),
|
||||
action("editor", "Editor", "delete_backward", "Delete one grapheme to the left."),
|
||||
action("editor", "Editor", "delete_forward", "Delete one grapheme to the right."),
|
||||
action("editor", "Editor", "delete_backward_word", "Delete the previous word."),
|
||||
action("editor", "Editor", "delete_forward_word", "Delete the next word."),
|
||||
action("editor", "Editor", "kill_line_start", "Delete from cursor to line start."),
|
||||
action("editor", "Editor", "kill_line_end", "Delete from cursor to line end."),
|
||||
action("editor", "Editor", "yank", "Paste the kill buffer."),
|
||||
action("pager", "Pager", "scroll_up", "Scroll up by one row."),
|
||||
action("pager", "Pager", "scroll_down", "Scroll down by one row."),
|
||||
action("pager", "Pager", "page_up", "Scroll up by one page."),
|
||||
action("pager", "Pager", "page_down", "Scroll down by one page."),
|
||||
action("pager", "Pager", "half_page_up", "Scroll up by half a page."),
|
||||
action("pager", "Pager", "half_page_down", "Scroll down by half a page."),
|
||||
action("pager", "Pager", "jump_top", "Jump to the beginning."),
|
||||
action("pager", "Pager", "jump_bottom", "Jump to the end."),
|
||||
action("pager", "Pager", "close", "Close the pager overlay."),
|
||||
action("pager", "Pager", "close_transcript", "Close the transcript overlay."),
|
||||
action("list", "List", "move_up", "Move list selection up."),
|
||||
action("list", "List", "move_down", "Move list selection down."),
|
||||
action("list", "List", "accept", "Accept the current list selection."),
|
||||
action("list", "List", "cancel", "Cancel and close selection views."),
|
||||
action("approval", "Approval", "open_fullscreen", "Open approval details fullscreen."),
|
||||
action("approval", "Approval", "open_thread", "Open the approval source thread when available."),
|
||||
action("approval", "Approval", "approve", "Approve the primary option."),
|
||||
action("approval", "Approval", "approve_for_session", "Approve for the session when available."),
|
||||
action("approval", "Approval", "approve_for_prefix", "Approve with an exec-policy prefix when available."),
|
||||
action("approval", "Approval", "deny", "Choose the explicit deny option when available."),
|
||||
action("approval", "Approval", "decline", "Decline and provide corrective guidance."),
|
||||
action("approval", "Approval", "cancel", "Cancel an elicitation request."),
|
||||
];
|
||||
|
||||
pub(super) fn action_label(action: &str) -> String {
|
||||
action
|
||||
.split('_')
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return String::new();
|
||||
};
|
||||
format!("{}{}", first.to_ascii_uppercase(), chars.as_str())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
pub(super) fn binding_slot<'a>(
|
||||
keymap: &'a mut TuiKeymap,
|
||||
context: &str,
|
||||
action: &str,
|
||||
) -> Option<&'a mut Option<KeybindingsSpec>> {
|
||||
match (context, action) {
|
||||
("global", "open_transcript") => Some(&mut keymap.global.open_transcript),
|
||||
("global", "open_external_editor") => Some(&mut keymap.global.open_external_editor),
|
||||
("global", "copy") => Some(&mut keymap.global.copy),
|
||||
("global", "clear_terminal") => Some(&mut keymap.global.clear_terminal),
|
||||
("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort),
|
||||
("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort),
|
||||
("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message),
|
||||
("composer", "submit") => Some(&mut keymap.composer.submit),
|
||||
("composer", "queue") => Some(&mut keymap.composer.queue),
|
||||
("composer", "toggle_shortcuts") => Some(&mut keymap.composer.toggle_shortcuts),
|
||||
("composer", "history_search_previous") => Some(&mut keymap.composer.history_search_previous),
|
||||
("composer", "history_search_next") => Some(&mut keymap.composer.history_search_next),
|
||||
("editor", "insert_newline") => Some(&mut keymap.editor.insert_newline),
|
||||
("editor", "move_left") => Some(&mut keymap.editor.move_left),
|
||||
("editor", "move_right") => Some(&mut keymap.editor.move_right),
|
||||
("editor", "move_up") => Some(&mut keymap.editor.move_up),
|
||||
("editor", "move_down") => Some(&mut keymap.editor.move_down),
|
||||
("editor", "move_word_left") => Some(&mut keymap.editor.move_word_left),
|
||||
("editor", "move_word_right") => Some(&mut keymap.editor.move_word_right),
|
||||
("editor", "move_line_start") => Some(&mut keymap.editor.move_line_start),
|
||||
("editor", "move_line_end") => Some(&mut keymap.editor.move_line_end),
|
||||
("editor", "delete_backward") => Some(&mut keymap.editor.delete_backward),
|
||||
("editor", "delete_forward") => Some(&mut keymap.editor.delete_forward),
|
||||
("editor", "delete_backward_word") => Some(&mut keymap.editor.delete_backward_word),
|
||||
("editor", "delete_forward_word") => Some(&mut keymap.editor.delete_forward_word),
|
||||
("editor", "kill_line_start") => Some(&mut keymap.editor.kill_line_start),
|
||||
("editor", "kill_line_end") => Some(&mut keymap.editor.kill_line_end),
|
||||
("editor", "yank") => Some(&mut keymap.editor.yank),
|
||||
("pager", "scroll_up") => Some(&mut keymap.pager.scroll_up),
|
||||
("pager", "scroll_down") => Some(&mut keymap.pager.scroll_down),
|
||||
("pager", "page_up") => Some(&mut keymap.pager.page_up),
|
||||
("pager", "page_down") => Some(&mut keymap.pager.page_down),
|
||||
("pager", "half_page_up") => Some(&mut keymap.pager.half_page_up),
|
||||
("pager", "half_page_down") => Some(&mut keymap.pager.half_page_down),
|
||||
("pager", "jump_top") => Some(&mut keymap.pager.jump_top),
|
||||
("pager", "jump_bottom") => Some(&mut keymap.pager.jump_bottom),
|
||||
("pager", "close") => Some(&mut keymap.pager.close),
|
||||
("pager", "close_transcript") => Some(&mut keymap.pager.close_transcript),
|
||||
("list", "move_up") => Some(&mut keymap.list.move_up),
|
||||
("list", "move_down") => Some(&mut keymap.list.move_down),
|
||||
("list", "accept") => Some(&mut keymap.list.accept),
|
||||
("list", "cancel") => Some(&mut keymap.list.cancel),
|
||||
("approval", "open_fullscreen") => Some(&mut keymap.approval.open_fullscreen),
|
||||
("approval", "open_thread") => Some(&mut keymap.approval.open_thread),
|
||||
("approval", "approve") => Some(&mut keymap.approval.approve),
|
||||
("approval", "approve_for_session") => Some(&mut keymap.approval.approve_for_session),
|
||||
("approval", "approve_for_prefix") => Some(&mut keymap.approval.approve_for_prefix),
|
||||
("approval", "deny") => Some(&mut keymap.approval.deny),
|
||||
("approval", "decline") => Some(&mut keymap.approval.decline),
|
||||
("approval", "cancel") => Some(&mut keymap.approval.cancel),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
pub(super) fn bindings_for_action<'a>(
|
||||
runtime_keymap: &'a RuntimeKeymap,
|
||||
context: &str,
|
||||
action: &str,
|
||||
) -> Option<&'a [KeyBinding]> {
|
||||
match (context, action) {
|
||||
("global", "open_transcript") => Some(runtime_keymap.app.open_transcript.as_slice()),
|
||||
("global", "open_external_editor") => Some(runtime_keymap.app.open_external_editor.as_slice()),
|
||||
("global", "copy") => Some(runtime_keymap.app.copy.as_slice()),
|
||||
("global", "clear_terminal") => Some(runtime_keymap.app.clear_terminal.as_slice()),
|
||||
("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()),
|
||||
("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()),
|
||||
("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()),
|
||||
("composer", "submit") => Some(runtime_keymap.composer.submit.as_slice()),
|
||||
("composer", "queue") => Some(runtime_keymap.composer.queue.as_slice()),
|
||||
("composer", "toggle_shortcuts") => Some(runtime_keymap.composer.toggle_shortcuts.as_slice()),
|
||||
("composer", "history_search_previous") => Some(runtime_keymap.composer.history_search_previous.as_slice()),
|
||||
("composer", "history_search_next") => Some(runtime_keymap.composer.history_search_next.as_slice()),
|
||||
("editor", "insert_newline") => Some(runtime_keymap.editor.insert_newline.as_slice()),
|
||||
("editor", "move_left") => Some(runtime_keymap.editor.move_left.as_slice()),
|
||||
("editor", "move_right") => Some(runtime_keymap.editor.move_right.as_slice()),
|
||||
("editor", "move_up") => Some(runtime_keymap.editor.move_up.as_slice()),
|
||||
("editor", "move_down") => Some(runtime_keymap.editor.move_down.as_slice()),
|
||||
("editor", "move_word_left") => Some(runtime_keymap.editor.move_word_left.as_slice()),
|
||||
("editor", "move_word_right") => Some(runtime_keymap.editor.move_word_right.as_slice()),
|
||||
("editor", "move_line_start") => Some(runtime_keymap.editor.move_line_start.as_slice()),
|
||||
("editor", "move_line_end") => Some(runtime_keymap.editor.move_line_end.as_slice()),
|
||||
("editor", "delete_backward") => Some(runtime_keymap.editor.delete_backward.as_slice()),
|
||||
("editor", "delete_forward") => Some(runtime_keymap.editor.delete_forward.as_slice()),
|
||||
("editor", "delete_backward_word") => Some(runtime_keymap.editor.delete_backward_word.as_slice()),
|
||||
("editor", "delete_forward_word") => Some(runtime_keymap.editor.delete_forward_word.as_slice()),
|
||||
("editor", "kill_line_start") => Some(runtime_keymap.editor.kill_line_start.as_slice()),
|
||||
("editor", "kill_line_end") => Some(runtime_keymap.editor.kill_line_end.as_slice()),
|
||||
("editor", "yank") => Some(runtime_keymap.editor.yank.as_slice()),
|
||||
("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()),
|
||||
("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()),
|
||||
("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()),
|
||||
("pager", "page_down") => Some(runtime_keymap.pager.page_down.as_slice()),
|
||||
("pager", "half_page_up") => Some(runtime_keymap.pager.half_page_up.as_slice()),
|
||||
("pager", "half_page_down") => Some(runtime_keymap.pager.half_page_down.as_slice()),
|
||||
("pager", "jump_top") => Some(runtime_keymap.pager.jump_top.as_slice()),
|
||||
("pager", "jump_bottom") => Some(runtime_keymap.pager.jump_bottom.as_slice()),
|
||||
("pager", "close") => Some(runtime_keymap.pager.close.as_slice()),
|
||||
("pager", "close_transcript") => Some(runtime_keymap.pager.close_transcript.as_slice()),
|
||||
("list", "move_up") => Some(runtime_keymap.list.move_up.as_slice()),
|
||||
("list", "move_down") => Some(runtime_keymap.list.move_down.as_slice()),
|
||||
("list", "accept") => Some(runtime_keymap.list.accept.as_slice()),
|
||||
("list", "cancel") => Some(runtime_keymap.list.cancel.as_slice()),
|
||||
("approval", "open_fullscreen") => Some(runtime_keymap.approval.open_fullscreen.as_slice()),
|
||||
("approval", "open_thread") => Some(runtime_keymap.approval.open_thread.as_slice()),
|
||||
("approval", "approve") => Some(runtime_keymap.approval.approve.as_slice()),
|
||||
("approval", "approve_for_session") => Some(runtime_keymap.approval.approve_for_session.as_slice()),
|
||||
("approval", "approve_for_prefix") => Some(runtime_keymap.approval.approve_for_prefix.as_slice()),
|
||||
("approval", "deny") => Some(runtime_keymap.approval.deny.as_slice()),
|
||||
("approval", "decline") => Some(runtime_keymap.approval.decline.as_slice()),
|
||||
("approval", "cancel") => Some(runtime_keymap.approval.cancel.as_slice()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn format_binding_summary(bindings: &[KeyBinding]) -> String {
|
||||
let mut seen = BTreeSet::new();
|
||||
let specs = bindings
|
||||
.iter()
|
||||
.filter_map(|binding| super::binding_to_config_key_spec(*binding).ok())
|
||||
.filter(|spec| seen.insert(spec.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
if specs.is_empty() {
|
||||
"unbound".to_string()
|
||||
} else {
|
||||
specs.join(", ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Shortcut picker construction for `/keymap`.
|
||||
|
||||
use codex_config::types::TuiKeymap;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::ColumnWidthMode;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionRowDisplay;
|
||||
use crate::bottom_pane::SelectionTab;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
|
||||
use super::actions::KEYMAP_ACTIONS;
|
||||
use super::actions::action_label;
|
||||
use super::actions::bindings_for_action;
|
||||
use super::actions::format_binding_summary;
|
||||
use super::has_custom_binding;
|
||||
|
||||
pub(crate) const KEYMAP_PICKER_VIEW_ID: &str = "keymap-picker";
|
||||
pub(super) const KEYMAP_ALL_TAB_ID: &str = "all-shortcuts";
|
||||
pub(super) const KEYMAP_COMMON_TAB_ID: &str = "common-shortcuts";
|
||||
pub(super) const KEYMAP_CUSTOM_TAB_ID: &str = "custom-shortcuts";
|
||||
pub(super) const KEYMAP_UNBOUND_TAB_ID: &str = "unbound-shortcuts";
|
||||
const KEYMAP_CONTEXT_LABEL_WIDTH: usize = 12;
|
||||
const KEYMAP_ROW_PREFIX_WIDTH: usize = KEYMAP_CONTEXT_LABEL_WIDTH + 3;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct KeymapActionRow {
|
||||
context: &'static str,
|
||||
context_label: &'static str,
|
||||
action: &'static str,
|
||||
label: String,
|
||||
description: &'static str,
|
||||
binding_summary: String,
|
||||
custom_binding: bool,
|
||||
}
|
||||
|
||||
impl KeymapActionRow {
|
||||
fn is_unbound(&self) -> bool {
|
||||
self.binding_summary == "unbound"
|
||||
}
|
||||
}
|
||||
|
||||
struct KeymapContextTab {
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
description: &'static str,
|
||||
contexts: &'static [&'static str],
|
||||
}
|
||||
|
||||
const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[
|
||||
("composer", "submit"),
|
||||
("editor", "insert_newline"),
|
||||
("composer", "queue"),
|
||||
("global", "open_external_editor"),
|
||||
("global", "copy"),
|
||||
("editor", "delete_backward_word"),
|
||||
("editor", "delete_forward_word"),
|
||||
("editor", "move_word_left"),
|
||||
("editor", "move_word_right"),
|
||||
("global", "open_transcript"),
|
||||
("pager", "close"),
|
||||
("pager", "page_up"),
|
||||
("pager", "page_down"),
|
||||
("approval", "open_fullscreen"),
|
||||
("approval", "approve"),
|
||||
("approval", "approve_for_session"),
|
||||
("approval", "decline"),
|
||||
("approval", "cancel"),
|
||||
];
|
||||
|
||||
const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[
|
||||
KeymapContextTab {
|
||||
id: "app-shortcuts",
|
||||
label: "App",
|
||||
description: "Global and chat-level shortcuts.",
|
||||
contexts: &["global", "chat"],
|
||||
},
|
||||
KeymapContextTab {
|
||||
id: "composer-shortcuts",
|
||||
label: "Composer",
|
||||
description: "Composer submission and queue shortcuts.",
|
||||
contexts: &["composer"],
|
||||
},
|
||||
KeymapContextTab {
|
||||
id: "editor-shortcuts",
|
||||
label: "Editor",
|
||||
description: "Inline editor movement and editing shortcuts.",
|
||||
contexts: &["editor"],
|
||||
},
|
||||
KeymapContextTab {
|
||||
id: "navigation-shortcuts",
|
||||
label: "Navigation",
|
||||
description: "Pager and selection-list navigation shortcuts.",
|
||||
contexts: &["pager", "list"],
|
||||
},
|
||||
KeymapContextTab {
|
||||
id: "approval-shortcuts",
|
||||
label: "Approval",
|
||||
description: "Approval prompt shortcuts.",
|
||||
contexts: &["approval"],
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) fn build_keymap_picker_params(
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
keymap_config: &TuiKeymap,
|
||||
) -> SelectionViewParams {
|
||||
build_keymap_picker_params_for_action(
|
||||
runtime_keymap,
|
||||
keymap_config,
|
||||
/*selected_action*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_keymap_picker_params_for_selected_action(
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
keymap_config: &TuiKeymap,
|
||||
context: &str,
|
||||
action: &str,
|
||||
) -> SelectionViewParams {
|
||||
build_keymap_picker_params_for_action(runtime_keymap, keymap_config, Some((context, action)))
|
||||
}
|
||||
|
||||
fn build_keymap_picker_params_for_action(
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
keymap_config: &TuiKeymap,
|
||||
selected_action: Option<(&str, &str)>,
|
||||
) -> SelectionViewParams {
|
||||
let rows = build_keymap_rows(runtime_keymap, keymap_config);
|
||||
let total = rows.len();
|
||||
let custom_count = rows.iter().filter(|row| row.custom_binding).count();
|
||||
let unbound_count = rows.iter().filter(|row| row.is_unbound()).count();
|
||||
let initial_selected_idx = selected_action.and_then(|(context, action)| {
|
||||
rows.iter()
|
||||
.position(|row| row.context == context && row.action == action)
|
||||
});
|
||||
let name_column_width = rows
|
||||
.iter()
|
||||
.map(|row| KEYMAP_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(row.label.as_str()))
|
||||
.max();
|
||||
|
||||
let mut tabs = Vec::new();
|
||||
tabs.push(SelectionTab {
|
||||
id: KEYMAP_ALL_TAB_ID.to_string(),
|
||||
label: "All".to_string(),
|
||||
header: keymap_header(
|
||||
"All configurable shortcuts.".to_string(),
|
||||
format!("{total} actions, {custom_count} customized, {unbound_count} unbound."),
|
||||
),
|
||||
items: keymap_selection_items(
|
||||
rows.iter(),
|
||||
"No shortcuts available",
|
||||
"No configurable shortcuts are available.",
|
||||
),
|
||||
});
|
||||
|
||||
let common_rows = keymap_common_rows(&rows);
|
||||
let common_count = common_rows.len();
|
||||
tabs.push(SelectionTab {
|
||||
id: KEYMAP_COMMON_TAB_ID.to_string(),
|
||||
label: "Common".to_string(),
|
||||
header: keymap_header(
|
||||
"Frequently customized shortcuts.".to_string(),
|
||||
action_count_line(common_count),
|
||||
),
|
||||
items: keymap_selection_items(
|
||||
common_rows,
|
||||
"No common shortcuts",
|
||||
"No common shortcut actions are available.",
|
||||
),
|
||||
});
|
||||
|
||||
let custom_rows = rows
|
||||
.iter()
|
||||
.filter(|row| row.custom_binding)
|
||||
.collect::<Vec<_>>();
|
||||
tabs.push(SelectionTab {
|
||||
id: KEYMAP_CUSTOM_TAB_ID.to_string(),
|
||||
label: format!("Customized ({custom_count})"),
|
||||
header: keymap_header(
|
||||
"Root-level shortcut overrides.".to_string(),
|
||||
action_count_line(custom_count),
|
||||
),
|
||||
items: keymap_selection_items(
|
||||
custom_rows,
|
||||
"No customized shortcuts",
|
||||
"No root-level keymap overrides have been configured.",
|
||||
),
|
||||
});
|
||||
|
||||
let unbound_rows = rows
|
||||
.iter()
|
||||
.filter(|row| row.is_unbound())
|
||||
.collect::<Vec<_>>();
|
||||
tabs.push(SelectionTab {
|
||||
id: KEYMAP_UNBOUND_TAB_ID.to_string(),
|
||||
label: format!("Unbound ({unbound_count})"),
|
||||
header: keymap_header(
|
||||
"Actions without an active shortcut.".to_string(),
|
||||
action_count_line(unbound_count),
|
||||
),
|
||||
items: keymap_selection_items(
|
||||
unbound_rows,
|
||||
"No unbound shortcuts",
|
||||
"Every configurable action currently has a shortcut.",
|
||||
),
|
||||
});
|
||||
|
||||
for tab in KEYMAP_CONTEXT_TABS {
|
||||
let tab_rows = rows
|
||||
.iter()
|
||||
.filter(|row| tab.contexts.contains(&row.context))
|
||||
.collect::<Vec<_>>();
|
||||
let count = tab_rows.len();
|
||||
tabs.push(SelectionTab {
|
||||
id: tab.id.to_string(),
|
||||
label: tab.label.to_string(),
|
||||
header: keymap_header(tab.description.to_string(), action_count_line(count)),
|
||||
items: keymap_selection_items(
|
||||
tab_rows,
|
||||
"No shortcuts in this group",
|
||||
"No configurable actions are available in this group.",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(KEYMAP_PICKER_VIEW_ID),
|
||||
header: Box::new(()),
|
||||
footer_hint: Some(keymap_picker_hint_line()),
|
||||
tabs,
|
||||
initial_tab_id: Some(KEYMAP_ALL_TAB_ID.to_string()),
|
||||
is_searchable: true,
|
||||
search_placeholder: Some("Type to search shortcuts".to_string()),
|
||||
col_width_mode: ColumnWidthMode::AutoAllRows,
|
||||
row_display: SelectionRowDisplay::SingleLine,
|
||||
name_column_width,
|
||||
initial_selected_idx,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_keymap_rows(
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
keymap_config: &TuiKeymap,
|
||||
) -> Vec<KeymapActionRow> {
|
||||
KEYMAP_ACTIONS
|
||||
.iter()
|
||||
.map(|descriptor| {
|
||||
let bindings =
|
||||
bindings_for_action(runtime_keymap, descriptor.context, descriptor.action)
|
||||
.unwrap_or(&[]);
|
||||
KeymapActionRow {
|
||||
context: descriptor.context,
|
||||
context_label: descriptor.context_label,
|
||||
action: descriptor.action,
|
||||
label: action_label(descriptor.action),
|
||||
description: descriptor.description,
|
||||
binding_summary: format_binding_summary(bindings),
|
||||
custom_binding: has_custom_binding(
|
||||
keymap_config,
|
||||
descriptor.context,
|
||||
descriptor.action,
|
||||
)
|
||||
.unwrap_or(false),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn keymap_common_rows(rows: &[KeymapActionRow]) -> Vec<&KeymapActionRow> {
|
||||
KEYMAP_COMMON_ACTIONS
|
||||
.iter()
|
||||
.filter_map(|(context, action)| {
|
||||
rows.iter()
|
||||
.find(|row| row.context == *context && row.action == *action)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn keymap_selection_items<'a>(
|
||||
rows: impl IntoIterator<Item = &'a KeymapActionRow>,
|
||||
empty_name: &str,
|
||||
empty_description: &str,
|
||||
) -> Vec<SelectionItem> {
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(keymap_selection_item)
|
||||
.collect::<Vec<_>>();
|
||||
if items.is_empty() {
|
||||
return vec![SelectionItem {
|
||||
name: empty_name.to_string(),
|
||||
description: Some(empty_description.to_string()),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}];
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn keymap_selection_item(row: &KeymapActionRow) -> SelectionItem {
|
||||
let context = row.context.to_string();
|
||||
let action = row.action.to_string();
|
||||
let source = if row.custom_binding {
|
||||
"Custom"
|
||||
} else {
|
||||
"Default"
|
||||
};
|
||||
let search_value = format!(
|
||||
"{} {} {} {} {} {}",
|
||||
row.context_label, row.action, row.label, row.description, row.binding_summary, source
|
||||
);
|
||||
|
||||
SelectionItem {
|
||||
name: row.label.clone(),
|
||||
name_prefix_spans: keymap_row_prefix(row),
|
||||
description: Some(row.binding_summary.clone()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenKeymapActionMenu {
|
||||
context: context.clone(),
|
||||
action: action.clone(),
|
||||
});
|
||||
})],
|
||||
search_value: Some(search_value),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn keymap_row_prefix(row: &KeymapActionRow) -> Vec<Span<'static>> {
|
||||
let indicator = if row.custom_binding {
|
||||
"*".cyan()
|
||||
} else if row.is_unbound() {
|
||||
"-".dim()
|
||||
} else {
|
||||
" ".into()
|
||||
};
|
||||
|
||||
vec![
|
||||
format!(
|
||||
"{:<width$} ",
|
||||
row.context_label,
|
||||
width = KEYMAP_CONTEXT_LABEL_WIDTH
|
||||
)
|
||||
.dim(),
|
||||
indicator,
|
||||
" ".dim(),
|
||||
]
|
||||
}
|
||||
|
||||
fn keymap_header(description: String, summary: String) -> Box<dyn Renderable> {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Keymap".bold()));
|
||||
header.push(Line::from(description.dim()));
|
||||
header.push(Line::from(summary.dim()));
|
||||
Box::new(header)
|
||||
}
|
||||
|
||||
fn action_count_line(count: usize) -> String {
|
||||
match count {
|
||||
1 => "1 action.".to_string(),
|
||||
_ => format!("{count} actions."),
|
||||
}
|
||||
}
|
||||
|
||||
fn keymap_picker_hint_line() -> Line<'static> {
|
||||
Line::from(vec![
|
||||
"left/right".cyan(),
|
||||
" group · ".dim(),
|
||||
"enter".cyan(),
|
||||
" edit shortcut · ".dim(),
|
||||
"*".cyan(),
|
||||
" custom · ".dim(),
|
||||
"-".cyan(),
|
||||
" unbound · ".dim(),
|
||||
"esc".cyan(),
|
||||
" close".dim(),
|
||||
])
|
||||
}
|
||||
@@ -131,6 +131,8 @@ mod history_cell;
|
||||
pub(crate) mod insert_history;
|
||||
pub use insert_history::insert_history_lines;
|
||||
mod key_hint;
|
||||
mod keymap;
|
||||
mod keymap_setup;
|
||||
mod line_truncation;
|
||||
pub(crate) mod live_wrap;
|
||||
pub use live_wrap::RowBuilder;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ 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;
|
||||
@@ -51,19 +53,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 +88,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 +118,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 +126,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 +249,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 +440,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,
|
||||
@@ -711,15 +698,48 @@ 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((
|
||||
vec![
|
||||
key_hint::plain(KeyCode::Esc),
|
||||
key_hint::plain(KeyCode::Left),
|
||||
],
|
||||
"to edit prev",
|
||||
));
|
||||
pairs.push((vec![key_hint::plain(KeyCode::Right)], "to edit next"));
|
||||
pairs.push((vec![key_hint::plain(KeyCode::Enter)], "to edit message"));
|
||||
} else {
|
||||
pairs.push((&[KEY_ESC], "to edit prev"));
|
||||
pairs.push((vec![key_hint::plain(KeyCode::Esc)], "to edit prev"));
|
||||
}
|
||||
render_key_hints(line2, buf, &pairs);
|
||||
}
|
||||
@@ -737,7 +757,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(())
|
||||
}
|
||||
@@ -768,14 +790,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, /*scroll_offset*/ 0, keymap),
|
||||
is_done: false,
|
||||
}
|
||||
}
|
||||
@@ -783,8 +817,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);
|
||||
}
|
||||
|
||||
@@ -801,7 +862,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(())
|
||||
}
|
||||
@@ -894,9 +955,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")],
|
||||
})]);
|
||||
|
||||
@@ -914,7 +1000,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));
|
||||
@@ -934,7 +1020,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")],
|
||||
}),
|
||||
@@ -953,7 +1039,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(
|
||||
@@ -974,7 +1060,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")],
|
||||
})]);
|
||||
|
||||
@@ -1070,7 +1156,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);
|
||||
|
||||
@@ -1084,7 +1170,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 {
|
||||
@@ -1111,7 +1197,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 {
|
||||
@@ -1135,7 +1221,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_consolidation_remaps_highlight_inside_range() {
|
||||
let mut overlay = TranscriptOverlay::new(
|
||||
let mut overlay = transcript_overlay(
|
||||
(0..6)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
@@ -1162,7 +1248,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_consolidation_remaps_highlight_after_range() {
|
||||
let mut overlay = TranscriptOverlay::new(
|
||||
let mut overlay = transcript_overlay(
|
||||
(0..7)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
@@ -1190,9 +1276,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()))
|
||||
@@ -1228,7 +1314,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 {
|
||||
@@ -1296,9 +1382,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()))
|
||||
@@ -1308,12 +1394,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pager_view_content_height_counts_renderables() {
|
||||
let pv = PagerView::new(
|
||||
let pv = pager_view(
|
||||
vec![
|
||||
paragraph_block("a", /*lines*/ 2),
|
||||
paragraph_block("b", /*lines*/ 3),
|
||||
],
|
||||
"T".to_string(),
|
||||
"T",
|
||||
/*scroll_offset*/ 0,
|
||||
);
|
||||
|
||||
@@ -1322,13 +1408,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() {
|
||||
let mut pv = PagerView::new(
|
||||
let mut pv = pager_view(
|
||||
vec![
|
||||
paragraph_block("a", /*lines*/ 1),
|
||||
paragraph_block("b", /*lines*/ 3),
|
||||
paragraph_block("c", /*lines*/ 3),
|
||||
],
|
||||
"T".to_string(),
|
||||
"T",
|
||||
/*scroll_offset*/ 0,
|
||||
);
|
||||
let area = Rect::new(0, 0, 20, 8);
|
||||
@@ -1357,13 +1443,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() {
|
||||
let mut pv = PagerView::new(
|
||||
let mut pv = pager_view(
|
||||
vec![
|
||||
paragraph_block("a", /*lines*/ 2),
|
||||
paragraph_block("b", /*lines*/ 3),
|
||||
paragraph_block("c", /*lines*/ 3),
|
||||
],
|
||||
"T".to_string(),
|
||||
"T",
|
||||
/*scroll_offset*/ 0,
|
||||
);
|
||||
let area = Rect::new(0, 0, 20, 3);
|
||||
@@ -1376,9 +1462,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() {
|
||||
let mut pv = PagerView::new(
|
||||
let mut pv = pager_view(
|
||||
vec![paragraph_block("a", /*lines*/ 10)],
|
||||
"T".to_string(),
|
||||
"T",
|
||||
/*scroll_offset*/ 0,
|
||||
);
|
||||
let area = Rect::new(0, 0, 20, 8);
|
||||
|
||||
@@ -16,6 +16,7 @@ pub enum SlashCommand {
|
||||
Fast,
|
||||
Approvals,
|
||||
Permissions,
|
||||
Keymap,
|
||||
#[strum(serialize = "setup-default-sandbox")]
|
||||
ElevateSandbox,
|
||||
#[strum(serialize = "sandbox-add-read-dir")]
|
||||
@@ -113,6 +114,7 @@ impl SlashCommand {
|
||||
SlashCommand::Side => "start a side conversation in an ephemeral fork",
|
||||
SlashCommand::Approvals => "choose what Codex is allowed to do",
|
||||
SlashCommand::Permissions => "choose what Codex is allowed to do",
|
||||
SlashCommand::Keymap => "remap TUI shortcuts",
|
||||
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
|
||||
SlashCommand::SandboxReadRoot => {
|
||||
"let sandbox read a directory: /sandbox-add-read-dir <absolute_path>"
|
||||
@@ -173,6 +175,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Personality
|
||||
| SlashCommand::Approvals
|
||||
| SlashCommand::Permissions
|
||||
| SlashCommand::Keymap
|
||||
| SlashCommand::ElevateSandbox
|
||||
| SlashCommand::SandboxReadRoot
|
||||
| SlashCommand::Experimental
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
unbound:
|
||||
Set key | Capture a key for this unbound action. | enabled
|
||||
Remove custom binding | Restore the default keymap binding. | enabled
|
||||
Back to shortcuts | Return to the shortcut list. | enabled
|
||||
|
||||
single:
|
||||
Replace binding | Capture a replacement key. | enabled
|
||||
Add alternate binding | Keep the current binding and add another key. | enabled
|
||||
Remove custom binding | Restore the default keymap binding. | enabled
|
||||
Back to shortcuts | Return to the shortcut list. | enabled
|
||||
|
||||
multi:
|
||||
Replace one binding... | Choose which existing binding to replace. | enabled
|
||||
Replace all bindings | Replace every current binding with one key. | enabled
|
||||
Add alternate binding | Keep current bindings and add another key. | enabled
|
||||
Remove custom binding | Restore the default keymap binding. | enabled
|
||||
Back to shortcuts | Return to the shortcut list. | enabled
|
||||
|
||||
replace picker:
|
||||
ctrl-enter | Replace this binding. | enabled
|
||||
alt-enter | Replace this binding. | enabled
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: "format!(\"{:?}\", render_capture(&view, 80, 8))"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 80, height: 8 },
|
||||
content: [
|
||||
"Remap Shortcut ",
|
||||
"Action: Submit composer.submit ",
|
||||
"Current: enter ",
|
||||
"Press the new key now. Esc cancels. ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 14, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 8, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 16, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 31, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 9, y: 2, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 35, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
---
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default
|
||||
Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default
|
||||
Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default
|
||||
Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default
|
||||
Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default
|
||||
Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default
|
||||
Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default
|
||||
Submit | enter | Composer submit Submit Submit the current composer draft. enter Default
|
||||
Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default
|
||||
Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default
|
||||
History Search Previous | ctrl-r | Composer history_search_previous History Search Previous Open history search or move to the previous match. ctrl-r Default
|
||||
History Search Next | ctrl-s | Composer history_search_next History Search Next Move to the next history search match. ctrl-s Default
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: "render_picker(params, 120)"
|
||||
---
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
50 actions, 1 customized, 0 unbound.
|
||||
|
||||
[All] Common Customized (1) Unbound (0) App Composer Editor Navigation Approval
|
||||
|
||||
Type to search shortcuts
|
||||
› Global Open Transcript ctrl-t
|
||||
Global Open External Editor ctrl-g
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
Chat Decrease Reasoning Effort alt-,
|
||||
Chat Increase Reasoning Effort alt-.
|
||||
Chat Edit Queued Message alt-up, shift-left
|
||||
Composer * Submit ctrl-enter
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
tab: All (50 selectable)
|
||||
tab: Common (18 selectable)
|
||||
tab: Customized (0) (0 selectable)
|
||||
tab: Unbound (0) (0 selectable)
|
||||
tab: App (7 selectable)
|
||||
tab: Composer (5 selectable)
|
||||
tab: Editor (16 selectable)
|
||||
tab: Navigation (14 selectable)
|
||||
tab: Approval (8 selectable)
|
||||
Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default
|
||||
Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default
|
||||
Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default
|
||||
Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default
|
||||
Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default
|
||||
Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default
|
||||
Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default
|
||||
Submit | enter | Composer submit Submit Submit the current composer draft. enter Default
|
||||
Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default
|
||||
Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default
|
||||
History Search Previous | ctrl-r | Composer history_search_previous History Search Previous Open history search or move to the previous match. ctrl-r Default
|
||||
History Search Next | ctrl-s | Composer history_search_next History Search Next Move to the next history search match. ctrl-s Default
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: "render_picker(params, 78)"
|
||||
---
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
50 actions, 0 customized, 0 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (0) App Composer Editor
|
||||
Navigation Approval
|
||||
|
||||
Type to search shortcuts
|
||||
› Global Open Transcript ctrl-t
|
||||
Global Open External Editor ctrl-g
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
Chat Decrease Reasoning Effort alt-,
|
||||
Chat Increase Reasoning Effort alt-.
|
||||
Chat Edit Queued Message alt-up, shift-left
|
||||
Composer Submit enter
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: "render_picker(params, 120)"
|
||||
---
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
50 actions, 0 customized, 0 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (0) App Composer Editor Navigation Approval
|
||||
|
||||
Type to search shortcuts
|
||||
› Global Open Transcript ctrl-t
|
||||
Global Open External Editor ctrl-g
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
Chat Decrease Reasoning Effort alt-,
|
||||
Chat Increase Reasoning Effort alt-.
|
||||
Chat Edit Queued Message alt-up, shift-left
|
||||
Composer Submit enter
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
@@ -21,6 +21,8 @@ You can run any shell command from Codex using `!` (e.g. `!ls`)
|
||||
Type / to open the command popup; Tab autocompletes slash commands.
|
||||
When the composer is empty, press Esc to step back and edit your last message; Enter confirms.
|
||||
Press Tab to queue a message when a task is running; otherwise it sends immediately (except `!`).
|
||||
[tui.keymap] in ~/.codex/config.toml lets you rebind supported shortcuts.
|
||||
See the Codex keymap documentation for supported actions and examples.
|
||||
Paste an image with Ctrl+V to attach it to your next message.
|
||||
You can resume a previous conversation by running `codex resume`
|
||||
Use /copy or press Ctrl+O to copy the latest agent response as Markdown.
|
||||
|
||||
Reference in New Issue
Block a user