mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
## What changed - Accept `super` modifiers and normalize `cmd` and `command` aliases to `super`. Support capturing, displaying, and using these bindings in shortcuts, chords, and leaders. - Add `super-left`, `super-right`, and `super-backspace` editor defaults for moving to the start or end of a line and deleting to its start. - Honor safe Super-modified editor shortcuts during startup without treating them as text or inserting a pending paste newline. - Save keymap edits through `config/value/write` when connected to a local daemon so it can validate bindings before they are saved or applied. Preserve the configuration and active keymap if the daemon rejects the edit. ## Testing Add coverage for modifier aliases, shortcut capture and labels, chords and leaders, line editing, startup input, and daemon acceptance or rejection of keymap edits. Extend the startup PTY test with Command editing sequences. GitOrigin-RevId: 8ed74f8253c89d423d5dfe87aa092844ce834454
264 lines
11 KiB
Rust
264 lines
11 KiB
Rust
//! Edit and confirm the provisional startup draft without executing actions before initialization.
|
|
//!
|
|
//! Terminal input stays with the original pump through startup. Paste-newline ambiguity is
|
|
//! preserved, protected screens do not consume draft input, and confirmation never removes text.
|
|
|
|
use std::io;
|
|
use tokio::time::Instant;
|
|
|
|
use crossterm::event::KeyCode;
|
|
use crossterm::event::KeyEvent;
|
|
use crossterm::event::KeyEventKind;
|
|
use crossterm::event::KeyModifiers;
|
|
|
|
use super::STARTUP_PASTE_NEWLINE_TIMEOUT;
|
|
use super::StartupCancelled;
|
|
use super::StartupDraftInitialScreen;
|
|
use super::StartupDraftPump;
|
|
use crate::bottom_pane::BottomPane;
|
|
use crate::key_hint;
|
|
use crate::tui::Tui;
|
|
use crate::tui::TuiEvent;
|
|
|
|
impl StartupDraftPump {
|
|
pub(super) fn handle_event(&mut self, tui: &mut Tui, event: TuiEvent) -> io::Result<()> {
|
|
let screen_size = tui.screen_size_for_event(&event)?;
|
|
if matches!(&event, TuiEvent::Paste(_) | TuiEvent::FocusLost)
|
|
|| matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved)
|
|
{
|
|
self.key_chord_matcher.cancel();
|
|
}
|
|
if let Some((started_at, mut newlines)) = self.pending_paste_newline.take() {
|
|
let continues_paste = match &event {
|
|
TuiEvent::Key(KeyEvent {
|
|
code: KeyCode::Char(_),
|
|
modifiers,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
}) => {
|
|
!key_hint::has_ctrl_or_alt(*modifiers)
|
|
&& !modifiers.intersects(
|
|
KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META,
|
|
)
|
|
}
|
|
TuiEvent::Key(KeyEvent {
|
|
code: KeyCode::Enter,
|
|
modifiers,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
}) if modifiers.is_empty() => {
|
|
if started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT {
|
|
newlines.push('\n');
|
|
self.pending_paste_newline = Some((Instant::now(), newlines));
|
|
return Ok(());
|
|
}
|
|
false
|
|
}
|
|
TuiEvent::Paste(text) => !text.is_empty(),
|
|
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {
|
|
self.pending_paste_newline = Some((started_at, newlines));
|
|
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
|
self.draw(tui, screen_size)?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
TuiEvent::FocusLost | TuiEvent::Mouse(_) => {
|
|
self.pending_paste_newline = Some((started_at, newlines));
|
|
return Ok(());
|
|
}
|
|
TuiEvent::Key(_) => false,
|
|
};
|
|
if continues_paste && started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT {
|
|
self.bottom_pane.handle_paste(newlines);
|
|
}
|
|
}
|
|
match event {
|
|
TuiEvent::Key(key) => {
|
|
if self.initial_screen != StartupDraftInitialScreen::Composer
|
|
&& !key_hint::ctrl(KeyCode::Char('c')).is_press(key)
|
|
&& !key_hint::ctrl(KeyCode::Char('d')).is_press(key)
|
|
{
|
|
return Ok(());
|
|
}
|
|
let key = match self.key_chord_matcher.advance(
|
|
key,
|
|
&self.key_chords,
|
|
self.bottom_pane.keymap_contexts(),
|
|
) {
|
|
crate::keymap::KeyChordMatch::Completed(key) => key,
|
|
crate::keymap::KeyChordMatch::PassThrough => key,
|
|
crate::keymap::KeyChordMatch::Pending(_)
|
|
| crate::keymap::KeyChordMatch::Cancelled
|
|
| crate::keymap::KeyChordMatch::Ignored => return Ok(()),
|
|
};
|
|
if key.code == KeyCode::Enter
|
|
&& key.modifiers.is_empty()
|
|
&& self.bottom_pane.is_in_paste_burst()
|
|
{
|
|
let text_len = self.bottom_pane.composer_text().len();
|
|
self.bottom_pane.flush_composer_paste_burst();
|
|
if self
|
|
.bottom_pane
|
|
.composer_text()
|
|
.len()
|
|
.saturating_sub(text_len)
|
|
> 1
|
|
&& (self.bottom_pane.is_startup_composer_action(key)
|
|
|| !self.bottom_pane.is_safe_startup_editor_key(key))
|
|
{
|
|
self.pending_paste_newline = Some((Instant::now(), "\n".to_string()));
|
|
}
|
|
}
|
|
if self.initial_screen == StartupDraftInitialScreen::Composer
|
|
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
|
&& self.session_action != super::StartupDraftSessionAction::NewFromCommandCenter
|
|
&& self.bottom_pane.is_startup_submit_key(key)
|
|
{
|
|
self.bottom_pane.flush_composer_paste_burst();
|
|
if self.pending_paste_newline.is_none()
|
|
&& !self.bottom_pane.composer_text().trim().is_empty()
|
|
{
|
|
self.submission_pending = true;
|
|
self.bottom_pane.set_footer_hint_override(Some(vec![
|
|
("Waiting for startup".into(), String::new()),
|
|
("esc".into(), "cancel".into()),
|
|
]));
|
|
}
|
|
crate::startup_recovery::remember(
|
|
self.bottom_pane.composer_recovery_snapshot(),
|
|
);
|
|
self.draw(tui, screen_size)?;
|
|
return Ok(());
|
|
}
|
|
if self.submission_pending
|
|
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
|
&& key.code == KeyCode::Esc
|
|
{
|
|
self.cancel_submission();
|
|
self.draw(tui, screen_size)?;
|
|
return Ok(());
|
|
}
|
|
let previous_text = self.bottom_pane.composer_text();
|
|
if let Err(error) = handle_startup_draft_key(&mut self.bottom_pane, key)
|
|
&& !(self.session_action
|
|
== super::StartupDraftSessionAction::NewFromCommandCenter
|
|
&& StartupCancelled::matches(&error))
|
|
{
|
|
if StartupCancelled::matches(&error)
|
|
&& let Err(clear_error) = tui.terminal.clear()
|
|
{
|
|
tracing::warn!(
|
|
error = %clear_error,
|
|
"failed to clear the cancelled startup composer"
|
|
);
|
|
}
|
|
return Err(error);
|
|
}
|
|
if self.bottom_pane.composer_text() != previous_text
|
|
|| self.bottom_pane.is_in_paste_burst()
|
|
{
|
|
self.cancel_submission();
|
|
}
|
|
}
|
|
TuiEvent::Paste(text) => {
|
|
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
|
self.cancel_submission();
|
|
self.bottom_pane.flush_composer_paste_burst();
|
|
self.bottom_pane.handle_paste(text);
|
|
}
|
|
}
|
|
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {}
|
|
TuiEvent::FocusLost => {
|
|
self.blossom.borrow_mut().cancel_replay();
|
|
return Ok(());
|
|
}
|
|
TuiEvent::Mouse(mouse) => {
|
|
if !tui.is_owned_screen()
|
|
|| self.initial_screen != StartupDraftInitialScreen::Composer
|
|
|| !matches!(
|
|
self.session_action,
|
|
super::StartupDraftSessionAction::New
|
|
| super::StartupDraftSessionAction::NewFromCommandCenter
|
|
)
|
|
{
|
|
return Ok(());
|
|
}
|
|
// Size, composer, and modal ownership must be current before hit testing.
|
|
self.draw(tui, screen_size)?;
|
|
if !self.blossom.borrow_mut().handle_mouse(mouse) {
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
crate::startup_recovery::remember(self.bottom_pane.composer_recovery_snapshot());
|
|
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
|
self.draw(tui, screen_size)?;
|
|
}
|
|
while self.app_event_rx.try_recv().is_ok() {}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub(super) fn handle_startup_draft_key(
|
|
bottom_pane: &mut BottomPane,
|
|
key: KeyEvent,
|
|
) -> io::Result<()> {
|
|
let _ = bottom_pane.flush_paste_burst_if_due();
|
|
if key.kind == KeyEventKind::Release
|
|
|| key.code == KeyCode::Enter
|
|
&& key.modifiers.is_empty()
|
|
&& !bottom_pane.is_safe_startup_editor_key(key)
|
|
|| matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
|
|
|| bottom_pane.is_startup_composer_action(key)
|
|
{
|
|
return Ok(());
|
|
}
|
|
|
|
if let KeyCode::Char(_) = key.code {
|
|
let (code, modifiers) = key_hint::normalize_key_parts(key.code, key.modifiers);
|
|
if key_hint::has_ctrl_or_alt(modifiers) && matches!(code, KeyCode::Char('r' | 'v')) {
|
|
return Ok(());
|
|
}
|
|
let is_ctrl_c = key_hint::ctrl(KeyCode::Char('c')).is_press(key);
|
|
let is_ctrl_d = key_hint::ctrl(KeyCode::Char('d')).is_press(key);
|
|
if key.kind == KeyEventKind::Press && (is_ctrl_c || is_ctrl_d) {
|
|
bottom_pane.flush_composer_paste_burst();
|
|
if bottom_pane.composer_is_empty() {
|
|
return Err(io::Error::new(io::ErrorKind::Interrupted, StartupCancelled));
|
|
}
|
|
if is_ctrl_c {
|
|
bottom_pane.on_ctrl_c();
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
let is_safe_startup_editor_key = bottom_pane.is_safe_startup_editor_key(key);
|
|
if !is_safe_startup_editor_key
|
|
&& (key_hint::has_ctrl_or_alt(key.modifiers)
|
|
|| key.code == KeyCode::Enter
|
|
|| key
|
|
.modifiers
|
|
.intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META)
|
|
|| !matches!(
|
|
key.code,
|
|
KeyCode::Char(_)
|
|
| KeyCode::Enter
|
|
| KeyCode::Esc
|
|
| KeyCode::Left
|
|
| KeyCode::Right
|
|
| KeyCode::Up
|
|
| KeyCode::Down
|
|
| KeyCode::Home
|
|
| KeyCode::End
|
|
| KeyCode::Backspace
|
|
| KeyCode::Delete
|
|
))
|
|
{
|
|
return Ok(());
|
|
}
|
|
|
|
let _ = bottom_pane.handle_key_event(key);
|
|
Ok(())
|
|
}
|