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