Fix transcript selection input routing and preserve composer drafts (#57224)

## Why

Leaving transcript selection with a held key can send repeated input to the composer and submit a preserved draft.

## What changed

- Route the transcript selection shortcut and selection controls in both the owned transcript and transcript overlay, preserving drafts and ignoring pastes outside search.
- Give fixed selection controls and pager motions precedence over conflicting key chords while allowing search and voice controls.
- Consume held exit keys across selection dismissal and asynchronous copy completion, and require fresh presses for composer submit and queue actions.
- Initialize overlay selection against the refreshed transcript frame and complete selection navigation when older history finishes loading.

## Testing

Add regression coverage for draft preservation, repeated `Enter` events, selection and search key precedence, refreshed-frame selection, and pending copy exit-key cleanup.

GitOrigin-RevId: 6cca9f399af1d49ff79337ae745cfc1564a14b88
This commit is contained in:
Felipe Coury
2026-10-26 20:10:19 +00:00
committed by copyberry
parent 9f622c2abb
commit 964aeda627
17 changed files with 649 additions and 42 deletions
+22 -2
View File
@@ -887,6 +887,23 @@ impl App {
self.chat_widget.acknowledge_program_status();
}
let idle_draw = matches!(event, TuiEvent::Draw);
if let TuiEvent::Key(key) = &event {
self.backtrack.observe_pending_select_exit_key(*key);
}
if let TuiEvent::Key(key) = &event
&& let Some(binding) = self.backtrack.select_exit_key
{
if binding.parts() == crate::key_hint::KeyBinding::from_event(*key).parts() {
if key.kind == KeyEventKind::Release
|| !self.enhanced_keys_supported
|| !crate::tui::reports_key_releases()
{
self.backtrack.select_exit_key = None;
}
return Ok(AppRunControl::Continue);
}
self.backtrack.select_exit_key = None;
}
if self.handle_rendered_selection_event(tui, &event)? {
return Ok(AppRunControl::Continue);
}
@@ -993,9 +1010,12 @@ impl App {
})
&& app.keymap.chat.toggle_voice.is_pressed(key)
};
// Find consumes otherwise-unhandled keys; let enabled voice controls reach App.
// Transcript interactions consume otherwise-unhandled keys; let enabled voice controls
// reach App.
if !matches!(&event, TuiEvent::Key(key)
if voice_toggle(self, *key) && !self.transcript_view.owns_interaction_key(*key))
if voice_toggle(self, *key)
&& (!self.transcript_view.owns_interaction_key(*key)
|| self.transcript_view.is_select_mode()))
&& self.handle_owned_transcript_event(tui, app_server, &event)?
{
return Ok(AppRunControl::Continue);
+7
View File
@@ -69,11 +69,18 @@ impl App {
let follow =
self.transcript_view
.finish_copy(&self.transcript_cells, completion, current);
let pending_exit_key = if current {
self.backtrack
.finish_pending_select_exit_key(completion.0, /*select_mode_closed*/ true)
} else {
None
};
if follow == Some(true) {
if self.backtrack.overlay_preview_active {
self.close_transcript_overlay(tui);
}
self.transcript_view.jump_to_latest();
self.backtrack.select_exit_key = pending_exit_key;
}
}
if !current || matches!(event, TuiEvent::FocusLost | TuiEvent::Resume) {
+7 -2
View File
@@ -287,8 +287,13 @@ impl App {
};
let continue_to_start = previous == TranscriptHistoryState::LoadingBeginning;
if continue_to_start && !self.scrollback_has_older_history {
self.transcript_view
.jump_to_entry(&self.transcript_cells, /*index*/ 0);
if self.transcript_view.is_select_mode() {
self.transcript_view
.complete_select_beginning(&self.transcript_cells);
} else {
self.transcript_view
.jump_to_entry(&self.transcript_cells, /*index*/ 0);
}
}
if self.scrollback_has_older_history
&& (continue_to_start
+24 -4
View File
@@ -9,6 +9,8 @@ use crate::app_backtrack::SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE;
use crate::keymap::bindings_for_action;
use crate::keymap::keymap_action_ids;
mod select_mode;
impl App {
pub(super) fn should_recover_vim_insert_escape(&self, key_event: KeyEvent) -> bool {
let active_contexts = self.active_keymap_contexts();
@@ -90,14 +92,16 @@ impl App {
if self.overlay.is_none() && self.chat_widget.handle_quick_dial_menu(key_event) {
return None;
}
if matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key_event))
let select_mode_chord = self.select_mode_owns_chord(tui, key_event);
if (matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key_event))
|| (tui.is_owned_screen()
&& self.overlay.is_none()
&& self.chat_widget.no_modal_or_popup_active()
&& self.transcript_view.owns_interaction_key(key_event)
&& (self.transcript_view.has_active_interaction()
|| self.backtrack.overlay_preview_active
|| crate::transcript_view::JumpTarget::from_key(key_event).is_none()))
|| crate::transcript_view::JumpTarget::from_key(key_event).is_none())))
&& !select_mode_chord
{
let close_chord = tui.is_owned_screen()
&& self.overlay.is_none()
@@ -232,7 +236,13 @@ impl App {
} else {
KeymapContext::Pager
};
let contexts = KeymapContextSet::new(context).with_voice_toggle(&self.keymap);
let contexts =
if matches!(overlay, Overlay::Transcript(_)) && context == KeymapContext::Pager {
KeymapContextSet::transcript()
} else {
KeymapContextSet::new(context)
}
.with_voice_toggle(&self.keymap);
return if self.backtrack.overlay_preview_active && context == KeymapContext::Pager {
KeymapContextSet::browsing().with_voice_toggle(&self.keymap)
} else {
@@ -242,6 +252,9 @@ impl App {
if self.transcript_view.is_search_editing() && self.chat_widget.no_modal_or_popup_active() {
return KeymapContextSet::new(KeymapContext::Editor).with_voice_toggle(&self.keymap);
}
if self.transcript_view.is_select_mode() && self.chat_widget.no_modal_or_popup_active() {
return KeymapContextSet::transcript().with_voice_toggle(&self.keymap);
}
if self.transcript_view.is_activity_focused() && self.chat_widget.no_modal_or_popup_active()
{
return KeymapContextSet::activity().with_voice_toggle(&self.keymap);
@@ -583,8 +596,11 @@ impl App {
}
let find_transcript = self.keymap.app.find_transcript.is_pressed(key_event);
let select_transcript = self.keymap.app.select_transcript.is_pressed(key_event);
if app_keymap_shortcuts_available
&& (self.keymap.app.open_transcript.is_pressed(key_event) || find_transcript)
&& (self.keymap.app.open_transcript.is_pressed(key_event)
|| find_transcript
|| select_transcript)
{
self.scrollback_has_older_history = self
.chat_widget
@@ -595,6 +611,10 @@ impl App {
overlay.set_keymap_bindings(&self.keymap);
overlay.begin_search();
}
if select_transcript && let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
overlay.set_keymap_bindings(&self.keymap);
overlay.begin_select_mode();
}
return;
}
+146
View File
@@ -0,0 +1,146 @@
//! Transcript selection chord routing kept outside the central application input path.
use super::super::*;
impl App {
pub(crate) fn transcript_select_exit_key(event: &TuiEvent) -> Option<KeyBinding> {
let TuiEvent::Key(key) = event else {
return None;
};
let (code, modifiers) = crate::key_hint::normalize_key_parts(key.code, key.modifiers);
(key.kind == KeyEventKind::Press
&& modifiers == KeyModifiers::NONE
&& matches!(code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q')))
.then(|| KeyBinding::from_event(*key))
}
pub(super) fn select_mode_owns_chord(&self, tui: &tui::Tui, key_event: KeyEvent) -> bool {
let select_mode = matches!(
&self.overlay,
Some(Overlay::Transcript(overlay)) if overlay.is_select_mode()
) || (tui.is_owned_screen()
&& self.overlay.is_none()
&& self.transcript_view.is_select_mode());
if !select_mode {
return false;
}
let contexts = self.active_keymap_contexts();
let search_editing = matches!(
&self.overlay,
Some(Overlay::Transcript(overlay)) if overlay.is_search_editing()
) || (self.overlay.is_none()
&& self.transcript_view.is_search_editing());
let (code, modifiers) =
crate::key_hint::normalize_key_parts(key_event.code, key_event.modifiers);
let fixed_search_control = search_editing
&& (code == KeyCode::Esc
|| (modifiers == KeyModifiers::NONE
&& matches!(code, KeyCode::Enter | KeyCode::PageUp | KeyCode::PageDown))
|| (modifiers == KeyModifiers::CONTROL
&& matches!(code, KeyCode::Char('c' | 'n' | 'p'))));
let fixed_select_control = !search_editing
&& key_event.kind != KeyEventKind::Release
&& (crate::text_selection::is_copy_key(key_event)
|| matches!(
(code, modifiers),
(
KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q' | '/' | 'o' | 'y'),
KeyModifiers::NONE
) | (
KeyCode::Char(' '),
KeyModifiers::NONE | KeyModifiers::CONTROL
) | (
KeyCode::Char('v' | 'n' | 'g' | '$'),
KeyModifiers::NONE | KeyModifiers::SHIFT
) | (
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down,
KeyModifiers::NONE | KeyModifiers::SHIFT
) | (
KeyCode::Char('h' | 'l' | 'k' | 'j' | 'w' | 'b' | 'e' | '0'),
KeyModifiers::NONE
) | (
KeyCode::Home | KeyCode::End,
KeyModifiers::NONE | KeyModifiers::CONTROL
) | (KeyCode::PageUp | KeyCode::PageDown, KeyModifiers::NONE)
| (KeyCode::Char('b' | 'f' | 'u' | 'd'), KeyModifiers::CONTROL)
));
let pager_motion = !search_editing
&& [
&self.keymap.pager.scroll_up,
&self.keymap.pager.scroll_down,
&self.keymap.pager.page_up,
&self.keymap.pager.page_down,
&self.keymap.pager.half_page_up,
&self.keymap.pager.half_page_down,
&self.keymap.pager.jump_top,
&self.keymap.pager.jump_bottom,
]
.into_iter()
.any(|bindings| bindings.is_pressed(key_event));
let active_action = |action: crate::keymap::KeymapActionId| {
contexts.contains_action(action)
&& if search_editing {
action.context == crate::keymap::KeymapContext::Editor
|| matches!(
(action.context, action.action),
(crate::keymap::KeymapContext::Chat, "toggle_voice")
)
} else {
matches!(
(action.context, action.action),
(crate::keymap::KeymapContext::Global, "find_transcript")
| (crate::keymap::KeymapContext::Chat, "toggle_voice")
| (
crate::keymap::KeymapContext::Pager,
"find"
| "scroll_up"
| "scroll_down"
| "page_up"
| "page_down"
| "half_page_up"
| "half_page_down"
| "jump_top"
| "jump_bottom"
)
) || (self.overlay.is_some()
&& action.context == crate::keymap::KeymapContext::Pager
&& matches!(action.action, "close" | "close_transcript"))
}
};
let completed_action = |dispatch| {
self.keymap.chords.bindings.iter().any(|binding| {
active_action(binding.action)
&& crate::keymap::dispatch_event(binding.action) == Some(dispatch)
})
};
if fixed_search_control || fixed_select_control || pager_motion {
false
} else if self.key_chord_matcher.is_pending() {
match self
.key_chord_matcher
.clone()
.advance(key_event, &self.keymap.chords, contexts)
{
crate::keymap::KeyChordMatch::Completed(dispatch) => completed_action(dispatch),
crate::keymap::KeyChordMatch::Pending(prefix) => {
let (code, modifiers) = prefix.parts();
self.keymap.chords.bindings.iter().any(|binding| {
active_action(binding.action)
&& crate::keymap::chord_stroke_matches(
binding.chord.prefix,
KeyEvent::new(code, modifiers),
)
})
}
crate::keymap::KeyChordMatch::Ignored => true,
crate::keymap::KeyChordMatch::PassThrough
| crate::keymap::KeyChordMatch::Cancelled => false,
}
} else {
self.keymap.chords.bindings.iter().any(|binding| {
active_action(binding.action)
&& crate::keymap::chord_stroke_matches(binding.chord.prefix, key_event)
})
}
}
}
+66 -9
View File
@@ -485,6 +485,7 @@ impl App {
| crossterm::event::MouseEventKind::ScrollDown
) {
self.transcript_view.end_selection(&self.transcript_cells);
self.transcript_view.end_select_mode();
self.transcript_view.cancel_search();
self.transcript_view.clear_activity_focus();
}
@@ -532,6 +533,16 @@ impl App {
{
self.chat_widget.end_composer_drag();
}
if let TuiEvent::Key(key) = event
&& self.chat_widget.no_modal_or_popup_active()
&& !self.transcript_view.is_select_mode()
&& self.keymap.app.select_transcript.is_pressed(*key)
{
self.transcript_view
.begin_select_mode(&self.transcript_cells);
tui.frame_requester().schedule_frame();
return Ok(true);
}
// Read-only Escape belongs to session navigation, even when the transcript is scrolled.
// Search and selection still consume Escape first to dismiss their interaction.
if let TuiEvent::Key(key) = event
@@ -627,7 +638,24 @@ impl App {
{
return Ok(false);
}
let was_select_mode = self.transcript_view.is_select_mode();
let select_exit_key = Self::transcript_select_exit_key(event);
let action = match event {
TuiEvent::Key(key) if was_select_mode && !self.transcript_view.is_search_editing() => {
if let Some(action) = self
.transcript_view
.handle_select_mode_key(*key, &self.transcript_cells)
{
Some(action)
} else {
if self.keymap.app.find_transcript.is_pressed(*key)
|| self.keymap.pager.find.is_pressed(*key)
{
self.transcript_view.begin_search();
}
Some(ViewAction::Changed)
}
}
TuiEvent::Key(key)
if !self.transcript_view.is_search_editing()
&& self.keymap.app.find_transcript.is_pressed(*key) =>
@@ -645,6 +673,11 @@ impl App {
.transcript_view
.handle_mouse(*mouse, &self.transcript_cells),
TuiEvent::Paste(text) => {
if self.transcript_view.is_select_mode()
&& !self.transcript_view.is_search_editing()
{
return Ok(true);
}
self.transcript_view.end_selection(&self.transcript_cells);
if self.transcript_view.paste_search(text) {
Some(ViewAction::Changed)
@@ -677,15 +710,39 @@ impl App {
}
return self.handle_owned_backtrack_event(tui, event);
};
if let Some((characters, result)) = self.transcript_view.copy_action(
tui,
&self.transcript_cells,
&action,
crate::clipboard_copy::CopyFormat::Markdown,
) {
self.transcript_view.show_copy_feedback(&result, characters);
} else if let ViewAction::OpenLink(url) = action {
self.open_url_in_browser(url);
let resume_following = matches!(action, ViewAction::CopyAndFollow(_));
if !matches!(action, ViewAction::ExitSelectMode) {
if let Some((characters, result)) = self.transcript_view.copy_action(
tui,
&self.transcript_cells,
&action,
crate::clipboard_copy::CopyFormat::Markdown,
) {
self.transcript_view.show_copy_feedback(&result, characters);
if resume_following {
match result {
Ok(crate::clipboard_copy::CopyStatus::Confirmed) => {
self.transcript_view.jump_to_latest();
}
Ok(crate::clipboard_copy::CopyStatus::Pending(id)) => {
if let Some(key) = select_exit_key {
self.backtrack.pending_select_exit_key = Some((id, key));
}
}
Ok(
crate::clipboard_copy::CopyStatus::Unconfirmed
| crate::clipboard_copy::CopyStatus::Busy,
)
| Err(_) => {}
}
}
} else if let ViewAction::OpenLink(url) = action {
self.open_url_in_browser(url);
}
}
if was_select_mode && !self.transcript_view.is_select_mode() {
self.backtrack.pending_select_exit_key = None;
self.backtrack.select_exit_key = select_exit_key;
}
self.request_owned_history(tui, app_server);
tui.frame_requester().schedule_frame();
@@ -867,6 +867,138 @@ async fn owned_search_and_selection_consume_input_before_composer_and_backtrack(
Ok(())
}
#[tokio::test]
async fn keyboard_select_mode_preserves_the_draft_and_absorbs_enter_repeat() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
attach_thread(&mut app, ThreadId::new());
app.transcript_cells = vec![user_cell("select this transcript")];
app.chat_widget
.apply_external_edit("composer draft".to_string());
app.keymap.app.find_transcript = vec![crate::key_hint::KeyBinding::from_event(
KeyCode::Char('v').into(),
)];
app.keymap.pager.find = vec![crate::key_hint::KeyBinding::from_event(
KeyCode::Enter.into(),
)];
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?;
for key in [
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
KeyCode::Char('[').into(),
] {
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key))
.await?;
}
assert!(app.transcript_view.is_select_mode());
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::Char('v').into()),
)
.await?;
assert!(app.transcript_view.is_select_mode());
assert!(!app.transcript_view.is_search_editing());
for event in [
TuiEvent::Paste("ignored".to_string()),
TuiEvent::Key(KeyCode::Esc.into()),
] {
app.handle_tui_event(&mut tui, &mut app_server, event)
.await?;
}
assert!(app.transcript_view.is_select_mode());
assert!(app.backtrack.select_exit_key.is_none());
for (kind, exit_guard) in [
(KeyEventKind::Press, true),
(KeyEventKind::Repeat, false),
(KeyEventKind::Release, false),
] {
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new_with_kind(
KeyCode::Enter,
KeyModifiers::NONE,
kind,
)),
)
.await?;
assert_eq!(app.backtrack.select_exit_key.is_some(), exit_guard);
}
assert!(!app.transcript_view.is_select_mode());
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"composer draft"
);
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn inline_select_mode_routes_find_and_returns_to_the_preserved_draft() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
attach_thread(&mut app, ThreadId::new());
app.transcript_cells = vec![user_cell("needle in history")];
app.chat_widget
.apply_external_edit("preserved inline draft".to_string());
app.keymap = RuntimeKeymap::from_config(&serde_json::from_value(serde_json::json!({
"global": {"find_transcript": "f4"},
"pager": {"scroll_down": "f4"}
}))?)
.expect("pager motion takes precedence in select mode");
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
app.open_transcript_overlay(&mut tui);
for key in [
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
KeyCode::Char('[').into(),
] {
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key))
.await?;
}
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::F(4).into()),
)
.await?;
assert!(matches!(
&app.overlay,
Some(Overlay::Transcript(overlay))
if overlay.is_select_mode() && !overlay.is_search_editing()
));
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::F(3).into()),
)
.await?;
assert!(matches!(
&app.overlay,
Some(Overlay::Transcript(overlay)) if overlay.is_search_editing()
));
for key in [KeyCode::Esc, KeyCode::Char('q')] {
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key.into()))
.await?;
}
assert!(app.overlay.is_none());
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"preserved inline draft"
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn inline_transcript_search_draws_and_escape_precedes_backtrack() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
+76 -4
View File
@@ -868,7 +868,10 @@ async fn preset_menu_keys_take_precedence_over_transcript_find() -> Result<()> {
async fn transcript_fixed_keys_take_precedence_over_pager_chord_prefixes() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
app.keymap = RuntimeKeymap::from_config(&serde_json::from_value(serde_json::json!({
"global": {"copy": ["ctrl-x ctrl-u"]},
"global": {
"copy": ["ctrl-x ctrl-u"],
"find_transcript": ["ctrl-space f"]
},
"pager": {
"scroll_up": ["ctrl-space x", "ctrl-home x", "ctrl-end x"],
"close_transcript": ["ctrl-x ctrl-u"]
@@ -884,6 +887,19 @@ async fn transcript_fixed_keys_take_precedence_over_pager_chord_prefixes() -> Re
assert_eq!(app.route_key_chord_event(&mut tui, key), Some(key));
assert!(!app.key_chord_matcher.is_pending());
}
if let Some(crate::pager_overlay::Overlay::Transcript(overlay)) = &mut app.overlay {
overlay.begin_select_mode();
let fixed = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL);
assert_eq!(app.route_key_chord_event(&mut tui, fixed), Some(fixed));
assert!(!app.key_chord_matcher.is_pending());
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char('q').into(),
)
.await?;
}
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
press(&mut app, &mut tui, &mut app_server, ctrl('u')).await?;
assert!(app.overlay.is_none());
@@ -938,6 +954,40 @@ async fn transcript_fixed_keys_take_precedence_over_pager_chord_prefixes() -> Re
assert!(!app.transcript_view.is_detailed());
assert!(!app.key_chord_matcher.is_pending());
}
app.keymap = RuntimeKeymap::from_config(&toml::from_str(
"[global]\nfind_transcript = 'f6 f'\n[pager]\nscroll_down = 'f6'",
)?)
.expect("valid pager motion and global chord");
app.transcript_cells = vec![std::sync::Arc::new(
crate::history_cell::PlainHistoryCell::new(vec!["first".into(), "second".into()]),
)];
tui.set_owned_screen(/*owned*/ true)?;
app.render_owned_transcript(
&mut tui,
ratatui::layout::Size::new(/*width*/ 80, /*height*/ 12),
)?;
assert!(app.transcript_view.begin_select_mode(&app.transcript_cells));
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char('g').into(),
)
.await?;
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char('v').into(),
)
.await?;
let before = app.transcript_view.selected_text(&app.transcript_cells);
press(&mut app, &mut tui, &mut app_server, KeyCode::F(6).into()).await?;
assert_ne!(
app.transcript_view.selected_text(&app.transcript_cells),
before
);
assert!(!app.key_chord_matcher.is_pending());
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
Ok(())
@@ -946,6 +996,13 @@ async fn transcript_fixed_keys_take_precedence_over_pager_chord_prefixes() -> Re
#[tokio::test]
async fn selection_after_live_commit_uses_the_refreshed_frame() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
let config: TuiKeymap = toml::from_str(
"[global]\ntoggle_side_conversation = []\nfind_transcript = 'ctrl-7 j'\n[pager]\nscroll_down = 'ctrl-7 j'",
)?;
let runtime =
RuntimeKeymap::from_config(&config).map_err(|error| color_eyre::eyre::eyre!(error))?;
app.chat_widget.apply_keymap_update(config, &runtime);
app.keymap = runtime;
let area = ratatui::layout::Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12,
);
@@ -960,18 +1017,33 @@ async fn selection_after_live_commit_uses_the_refreshed_frame() -> Result<()> {
overlay.insert_cell(std::sync::Arc::new(
crate::history_cell::PlainHistoryCell::new(vec!["committed output".into()]),
));
overlay.begin_select_mode();
app.overlay = Some(crate::pager_overlay::Overlay::Transcript(overlay));
// The active cell has completed, but no Draw has reached the overlay yet.
app.handle_tui_event(
assert!(matches!(
&app.overlay,
Some(crate::pager_overlay::Overlay::Transcript(overlay)) if overlay.is_select_mode()
));
press(&mut app, &mut tui, &mut app_server, ctrl('/')).await?;
assert!(app.key_chord_matcher.is_pending());
let repeat = KeyEvent::new_with_kind(
KeyCode::Char('/'),
KeyModifiers::CONTROL,
KeyEventKind::Repeat,
);
press(&mut app, &mut tui, &mut app_server, repeat).await?;
assert!(app.key_chord_matcher.is_pending());
press(
&mut app,
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)),
KeyCode::Char('j').into(),
)
.await?;
let Some(crate::pager_overlay::Overlay::Transcript(overlay)) = &mut app.overlay else {
panic!("overlay closed")
};
assert!(overlay.has_active_interaction());
assert!(overlay.has_active_interaction() && !overlay.is_search_editing());
let mut buffer = ratatui::buffer::Buffer::empty(area);
overlay.render(area, &mut buffer);
let text = buffer
+87 -1
View File
@@ -75,9 +75,43 @@ pub(crate) struct BacktrackState {
pub(crate) nth_user_message: usize,
/// True when the transcript overlay is showing a backtrack preview.
pub(crate) overlay_preview_active: bool,
/// Consume held-key input after a fresh press acts on transcript select mode.
pub(crate) select_exit_key: Option<crate::key_hint::KeyBinding>,
/// Preserve the initiating key while a select-and-follow copy is pending.
pub(crate) pending_select_exit_key: Option<(u64, crate::key_hint::KeyBinding)>,
origin: Option<browsing::BrowsingOrigin>,
}
impl BacktrackState {
pub(crate) fn observe_pending_select_exit_key(&mut self, event: KeyEvent) {
if event.kind == KeyEventKind::Release
&& self.pending_select_exit_key.is_some_and(|(_, binding)| {
binding.parts() == crate::key_hint::KeyBinding::from_event(event).parts()
})
{
self.pending_select_exit_key = None;
}
}
pub(crate) fn finish_pending_select_exit_key(
&mut self,
completed_id: u64,
select_mode_closed: bool,
) -> Option<crate::key_hint::KeyBinding> {
match self.pending_select_exit_key {
Some((id, key)) if id == completed_id => {
self.pending_select_exit_key = None;
select_mode_closed.then_some(key)
}
Some((id, _)) if id < completed_id => {
self.pending_select_exit_key = None;
None
}
_ => None,
}
}
}
/// A user-visible backtrack choice that can be reopened in the current thread.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct BacktrackSelection {
@@ -357,6 +391,7 @@ impl App {
/// source of truth for the active cell and its cache invalidation key, and because `App` owns
/// overlay lifecycle and frame scheduling for animations.
fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
let select_exit_key = Self::transcript_select_exit_key(&event);
if matches!(
&event,
TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained
@@ -378,6 +413,8 @@ impl App {
))
&& let Some(Overlay::Transcript(t)) = &mut self.overlay
{
let was_select_mode = t.is_select_mode();
let pending_follow_before = t.pending_follow_id();
tui.set_overlay_input(tui::OverlayInput::Transcript)?;
t.motion = crate::motion::MotionMode::from_animations_enabled(
self.local_settings.tui.animations && self.local_settings.tui.effects.shimmer,
@@ -407,8 +444,10 @@ impl App {
if result.is_err() {
let _ = tui.set_overlay_input(tui::OverlayInput::Default);
}
result?;
let completed_copy = result?;
let close_overlay = t.is_done();
let select_mode_closed = was_select_mode && (!t.is_select_mode() || close_overlay);
let pending_follow_id = t.pending_follow_id();
if !close_overlay
&& active_key.is_some_and(|key| key.animation_tick.is_some())
&& t.is_scrolled_to_bottom()
@@ -416,6 +455,21 @@ impl App {
tui.frame_requester()
.schedule_frame_in(std::time::Duration::from_millis(50));
}
if pending_follow_id != pending_follow_before
&& let (Some(id), Some(key)) = (pending_follow_id, select_exit_key)
{
self.backtrack.pending_select_exit_key = Some((id, key));
}
let completed_exit_key = completed_copy.and_then(|completed_id| {
self.backtrack
.finish_pending_select_exit_key(completed_id, select_mode_closed)
});
if select_mode_closed {
self.backtrack.select_exit_key = select_exit_key.or(completed_exit_key);
if select_exit_key.is_some() {
self.backtrack.pending_select_exit_key = None;
}
}
if close_overlay {
self.close_transcript_overlay(tui);
tui.frame_requester().schedule_frame();
@@ -740,6 +794,38 @@ mod tests {
.collect()
}
#[test]
fn pending_select_exit_key_ends_on_completion_or_release() {
let key = crate::key_hint::KeyBinding::from_event(KeyCode::Enter.into());
let mut state = BacktrackState {
pending_select_exit_key: Some((7, key)),
..BacktrackState::default()
};
assert_eq!(
state.finish_pending_select_exit_key(
/*completed_id*/ 7, /*select_mode_closed*/ false,
),
None
);
assert_eq!(state.pending_select_exit_key, None);
state.pending_select_exit_key = Some((8, key));
state.observe_pending_select_exit_key(KeyEvent::new_with_kind(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
KeyEventKind::Release,
));
assert_eq!(state.pending_select_exit_key, None);
state.pending_select_exit_key = Some((9, key));
assert_eq!(
state.finish_pending_select_exit_key(
/*completed_id*/ 9, /*select_mode_closed*/ true,
),
Some(key)
);
assert_eq!(state.pending_select_exit_key, None);
}
fn turn(turn_id: &str, status: TurnStatus, user_messages: usize) -> Turn {
Turn {
id: turn_id.to_string(),
@@ -129,6 +129,8 @@
//! Multiline pastes continue blockquote prefixes.
//! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is
//! running, `Tab` submits just like Enter so input is never dropped.
//! Submit and queue bindings act only on fresh key presses; repeated events are consumed so a held
//! key cannot dispatch a preserved draft after another input layer closes.
//! Vim Replace shares Insert's composer actions; only textarea editing differs.
//! Literal completion/paste edits discard stale Replace recovery; subsequent typing records anew.
//! Token markers added during Replace are removed when restoring overwritten characters.
@@ -3528,6 +3530,11 @@ impl ChatComposer {
} else {
self.footer.mode = reset_mode_after_activity(self.footer.mode);
}
if key_event.kind == KeyEventKind::Repeat
&& (self.queue_keys.is_pressed(key_event) || self.submit_keys.is_pressed(key_event))
{
return (InputResult::None, true);
}
if self.queue_keys.is_pressed(key_event)
&& (self.is_task_running || self.queue_submissions || !self.is_bang_shell_command())
{
+2
View File
@@ -66,6 +66,8 @@ pub(crate) use chords::KeyChordMatch;
pub(crate) use chords::KeyChordMatcher;
pub(crate) use chords::KeymapContextSet;
pub(crate) use chords::RuntimeChordKeymap;
pub(crate) use chords::chord_stroke_matches;
pub(crate) use chords::dispatch_event;
pub(crate) use chords::is_dispatch_token;
pub(crate) use chords::is_dispatch_token_event;
+3 -1
View File
@@ -404,6 +404,8 @@ Reduce `tui.keymap.global.leader` alternatives or `leader ...` bindings."
!(contexts.0 & TRANSCRIPT_CLOSE != 0
&& binding.action.context == KeymapContext::Pager
&& binding.action.action == "close_transcript"),
!(contexts.0 & TRANSCRIPT_ACTIONS != 0
&& binding.action.context == KeymapContext::Pager),
contexts.is_warnings() && binding.action.context != KeymapContext::List,
contexts.is_warnings() && binding.action.context == KeymapContext::Global,
)
@@ -690,7 +692,7 @@ pub(crate) fn is_dispatch_token_event(event: KeyEvent) -> bool {
)
}
fn chord_stroke_matches(binding: KeyBinding, key_event: KeyEvent) -> bool {
pub(crate) fn chord_stroke_matches(binding: KeyBinding, key_event: KeyEvent) -> bool {
let ctrl_slash = crate::key_hint::ctrl(KeyCode::Char('/'));
let ctrl_seven = crate::key_hint::ctrl(KeyCode::Char('7'));
+1 -1
View File
@@ -92,7 +92,7 @@ impl Overlay {
};
tui.set_overlay_input(input)?;
let result = match self {
Overlay::Transcript(o) => o.handle_event(tui, event),
Overlay::Transcript(o) => o.handle_event(tui, event).map(drop),
Overlay::Static(o) => o.handle_event(tui, event),
Overlay::Analytics(o) => o.handle_event(tui, event),
};
+26 -9
View File
@@ -78,6 +78,7 @@ impl TranscriptOverlay {
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let browsing = self.browsing_footer.is_some()
&& !self.pending_select_mode
&& !self.view.has_active_interaction()
&& self.view.history != TranscriptHistoryState::Failed;
let chrome_height = if browsing { 2 } else { 5 };
@@ -190,10 +191,18 @@ impl TranscriptOverlay {
Ok(())
}
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
if let Some(completion) = tui.clipboard.poll() {
pub(crate) fn handle_event(
&mut self,
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<Option<u64>> {
let completed_copy = if let Some(completion) = tui.clipboard.poll() {
let id = completion.0;
self.finish_pending_copy(completion);
}
Some(id)
} else {
None
};
if matches!(event, TuiEvent::Resume | TuiEvent::FocusLost) {
self.view.cancel_primary();
self.view.end_drag();
@@ -219,7 +228,7 @@ impl TranscriptOverlay {
}
TuiEvent::Paste(text) => {
if self.view.is_select_mode() && !self.view.is_search_editing() {
return Ok(());
return Ok(completed_copy);
}
if self.view.is_search_editing() {
self.view.end_selection(&self.cells);
@@ -232,7 +241,7 @@ impl TranscriptOverlay {
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
}
self.draw(tui)?;
return Ok(());
return Ok(completed_copy);
}
TuiEvent::FocusLost => None,
};
@@ -240,7 +249,7 @@ impl TranscriptOverlay {
self.apply_action(tui, action);
tui.frame_requester().schedule_frame();
}
Ok(())
Ok(completed_copy)
}
pub(crate) fn is_done(&self) -> bool {
@@ -263,19 +272,27 @@ impl TranscriptOverlay {
}
pub(crate) fn begin_select_mode(&mut self) {
self.pending_select_mode = !self.view.begin_select_mode(&self.cells);
self.pending_select_mode = true;
}
pub(crate) fn is_search_editing(&self) -> bool {
self.view.is_search_editing()
}
pub(crate) fn is_select_mode(&self) -> bool {
self.pending_select_mode || self.view.is_select_mode()
}
pub(crate) fn owns_interaction_key(&self, key: KeyEvent) -> bool {
self.view.owns_interaction_key(key)
self.pending_select_mode || self.view.owns_interaction_key(key)
}
pub(crate) fn has_active_interaction(&self) -> bool {
self.view.has_active_interaction()
self.pending_select_mode || self.view.has_active_interaction()
}
pub(crate) fn pending_follow_id(&self) -> Option<u64> {
self.pending_follow_close.map(|(id, _)| id)
}
pub(crate) fn needs_history(&mut self) -> bool {
+4
View File
@@ -116,6 +116,10 @@ pub(crate) fn running_in_vscode_terminal() -> bool {
keyboard_modes::running_in_vscode_terminal()
}
pub(crate) fn reports_key_releases() -> bool {
keyboard_modes::reports_key_releases()
}
fn should_emit_notification(condition: NotificationCondition, terminal_focused: bool) -> bool {
match condition {
NotificationCondition::Unfocused => !terminal_focused,
+8
View File
@@ -33,6 +33,7 @@ pub(super) static ALTERNATE_SCREEN: AlternateScreen = AlternateScreen {
mouse_capture_disabled: AtomicBool::new(/*v*/ false),
input_configured: AtomicBool::new(/*v*/ false),
keyboard_active: AtomicBool::new(/*v*/ false),
parent_reports_key_releases: AtomicBool::new(/*v*/ false),
link_pointer: super::link_pointer::LinkPointer::new(),
};
@@ -47,6 +48,8 @@ pub(super) struct AlternateScreen {
input_configured: AtomicBool,
// An editor can take over the screen after Codex has popped its keyboard mode.
keyboard_active: AtomicBool,
// Restore the main screen's input classification when this screen's keyboard frame is popped.
parent_reports_key_releases: AtomicBool,
}
/// Report pointer motion so the owned transcript can update its return-to-bottom hover state.
@@ -87,6 +90,8 @@ impl AlternateScreen {
// Stdout retains queued bytes on a flush error; cleanup must follow that pending entry.
self.active.store(/*val*/ true, Ordering::Relaxed);
writer.flush()?;
self.parent_reports_key_releases
.store(keyboard_modes::reports_key_releases(), Ordering::Relaxed);
let mouse_capture = keyboard_modes::enable_keyboard_enhancement(writer);
self.keyboard_active.store(
!cfg!(windows) && !keyboard_modes::keyboard_enhancement_disabled(),
@@ -174,6 +179,9 @@ impl AlternateScreen {
let result = execute!(writer, PopKeyboardEnhancementFlags);
if result.is_ok() {
self.keyboard_active.store(/*val*/ false, Ordering::Relaxed);
keyboard_modes::set_reports_key_releases(
self.parent_reports_key_releases.load(Ordering::Relaxed),
);
}
result
} else {
+31 -9
View File
@@ -8,6 +8,8 @@
use std::fmt;
use std::io::Write;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use codex_terminal_detection::TerminalName;
use codex_terminal_detection::terminal_info;
@@ -18,6 +20,7 @@ use crossterm::event::PushKeyboardEnhancementFlags;
use ratatui::crossterm::execute;
const DISABLE_KEYBOARD_ENHANCEMENT_ENV_VAR: &str = "CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT";
static REPORTS_KEY_RELEASES: AtomicBool = AtomicBool::new(/*v*/ false);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum VscodeDetection {
@@ -210,6 +213,7 @@ mod windows_term_program_tests;
pub(super) fn enable_keyboard_enhancement(writer: &mut impl Write) -> super::tmux::MouseCapture {
let tmux_options = super::tmux::options();
if keyboard_enhancement_disabled() {
set_reports_key_releases(/*reports*/ false);
return tmux_options.mouse_capture;
}
@@ -220,14 +224,18 @@ pub(super) fn enable_keyboard_enhancement(writer: &mut impl Write) -> super::tmu
None
};
let _ = execute!(
let flags = keyboard_enhancement_flags(
terminal_info().name,
running_in_tmux_session,
tmux_extended_keys_format,
);
let result = execute!(
writer,
DisableModifyOtherKeys,
PushKeyboardEnhancementFlags(keyboard_enhancement_flags(
terminal_info().name,
running_in_tmux_session,
tmux_extended_keys_format
))
PushKeyboardEnhancementFlags(flags)
);
set_reports_key_releases(
result.is_ok() && flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES),
);
if tmux_should_enable_modify_other_keys_for(running_in_tmux_session, tmux_extended_keys_format)
@@ -258,6 +266,14 @@ fn keyboard_enhancement_flags(
}
}
pub(super) fn reports_key_releases() -> bool {
REPORTS_KEY_RELEASES.load(Ordering::Relaxed)
}
pub(super) fn set_reports_key_releases(reports: bool) {
REPORTS_KEY_RELEASES.store(reports, Ordering::Relaxed);
}
fn running_in_tmux_session() -> bool {
tmux_session_detected(
std::env::var("TMUX").ok().as_deref(),
@@ -280,16 +296,22 @@ fn tmux_should_enable_modify_other_keys_for(
}
pub(super) fn restore_keyboard_enhancement_stack(writer: &mut impl Write) {
let _ = execute!(writer, PopKeyboardEnhancementFlags, DisableModifyOtherKeys);
if execute!(writer, PopKeyboardEnhancementFlags, DisableModifyOtherKeys).is_ok() {
set_reports_key_releases(/*reports*/ false);
}
}
pub(super) fn reset_keyboard_reporting_after_exit(writer: &mut impl Write) {
let _ = execute!(
if execute!(
writer,
PopKeyboardEnhancementFlags,
ResetKeyboardEnhancementFlags,
DisableModifyOtherKeys
);
)
.is_ok()
{
set_reports_key_releases(/*reports*/ false);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]