Wire keyboard selection into transcript overlays (#57223)

## What changed

- Add `tui.keymap.global.select_transcript` with an implicit `leader [` shortcut that follows the configured leader, respects explicit remapping or unbinding, and yields to existing bindings.
- Connect transcript overlays to keyboard selection, including Find, mouse selection, and navigation to the beginning of paged history. Give selection commands priority over remapped pager shortcuts.
- Close selection overlays on exit or after a confirmed copy-and-follow operation, preserving the pending close decision while clipboard confirmation is asynchronous.
- Show selection navigation and copy hints that adapt to the available footer width.

## Testing

Add coverage for shortcut dispatch, remapping, unbinding, and conflicts; overlay exit behavior; selection across history paging; mouse and keyboard selection interaction; and cursor and footer rendering snapshots.

GitOrigin-RevId: 8a920e5fa2b06ff0544d829ba30f8ecb654db0ee
This commit is contained in:
Felipe Coury
2026-10-26 20:09:54 +00:00
committed by copyberry
parent 285fc74305
commit 9f622c2abb
15 changed files with 869 additions and 99 deletions
+2
View File
@@ -141,6 +141,8 @@ pub struct TuiGlobalKeymap {
pub open_transcript: Option<KeybindingsSpec>,
/// Find text in the full transcript.
pub find_transcript: Option<KeybindingsSpec>,
/// Enter keyboard selection mode for the transcript.
pub select_transcript: Option<KeybindingsSpec>,
/// Focus activity groups in the owned transcript to inspect their details.
pub focus_activity: Option<KeybindingsSpec>,
/// Open the external editor for the current draft.
+10
View File
@@ -5152,6 +5152,7 @@
"open_external_editor": null,
"open_transcript": null,
"queue": null,
"select_transcript": null,
"submit": null,
"toggle_fast_mode": null,
"toggle_raw_output": null,
@@ -6007,6 +6008,14 @@
],
"description": "Queue the current composer draft while a task is running."
},
"select_transcript": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Enter keyboard selection mode for the transcript."
},
"submit": {
"allOf": [
{
@@ -6176,6 +6185,7 @@
"open_external_editor": null,
"open_transcript": null,
"queue": null,
"select_transcript": null,
"submit": null,
"toggle_fast_mode": null,
"toggle_raw_output": null,
+58 -4
View File
@@ -50,6 +50,10 @@ mod voice_tests;
#[path = "keymap/global_find_tests.rs"]
mod global_find_tests;
#[cfg(test)]
#[path = "keymap/select_transcript_tests.rs"]
mod select_transcript_tests;
pub(crate) use bindings::KeymapActionId;
pub(crate) use bindings::KeymapContext;
pub(crate) use bindings::bindings_for_action;
@@ -102,6 +106,8 @@ pub(crate) struct AppKeymap {
pub(crate) open_transcript: Vec<KeyBinding>,
/// Find text in the full transcript.
pub(crate) find_transcript: Vec<KeyBinding>,
/// Enter keyboard selection mode for the transcript.
pub(crate) select_transcript: Vec<KeyBinding>,
/// Focus activity groups in the owned transcript to inspect their details.
pub(crate) focus_activity: Vec<KeyBinding>,
/// Open retained warnings without replacing the composer.
@@ -723,6 +729,11 @@ impl RuntimeKeymap {
&defaults.app.find_transcript,
"tui.keymap.global.find_transcript",
)?,
select_transcript: resolve_bindings(
keymap.global.select_transcript.as_ref(),
&defaults.app.select_transcript,
"tui.keymap.global.select_transcript",
)?,
focus_activity: resolve_bindings(
keymap.global.focus_activity.as_ref(),
&focus_activity_defaults,
@@ -1456,6 +1467,10 @@ impl RuntimeKeymap {
keymap.global.find_transcript.as_ref(),
app.find_transcript.as_slice(),
),
(
keymap.global.select_transcript.as_ref(),
app.select_transcript.as_slice(),
),
(
keymap.global.focus_activity.as_ref(),
app.focus_activity.as_slice(),
@@ -1647,7 +1662,7 @@ impl RuntimeKeymap {
}
}
resolved.configure_search(keymap)?;
chords::quick_dial_defaults::register(keymap, &mut resolved);
chords::leader_defaults::register(keymap, &mut resolved);
resolved.validate_conflicts()?;
chords::validate_chord_conflicts(&resolved)?;
chords::install_dispatch_bindings(&mut resolved)?;
@@ -1676,6 +1691,7 @@ impl RuntimeKeymap {
open_agents: default_bindings![],
open_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
find_transcript: default_bindings![plain(KeyCode::F(3))],
select_transcript: default_bindings![],
focus_activity: default_bindings![plain(KeyCode::F(4))],
open_warnings: default_bindings![plain(KeyCode::F(2))],
open_external_editor: default_bindings![ctrl(KeyCode::Char('g'))],
@@ -2052,6 +2068,7 @@ impl RuntimeKeymap {
("open_agents", self.app.open_agents.as_slice()),
("open_transcript", self.app.open_transcript.as_slice()),
("find_transcript", self.app.find_transcript.as_slice()),
("select_transcript", self.app.select_transcript.as_slice()),
("focus_activity", self.app.focus_activity.as_slice()),
("open_warnings", self.app.open_warnings.as_slice()),
(
@@ -2159,6 +2176,7 @@ impl RuntimeKeymap {
("open_agents", self.app.open_agents.as_slice()),
("open_transcript", self.app.open_transcript.as_slice()),
("find_transcript", self.app.find_transcript.as_slice()),
("select_transcript", self.app.select_transcript.as_slice()),
("focus_activity", self.app.focus_activity.as_slice()),
("open_warnings", self.app.open_warnings.as_slice()),
(
@@ -2218,6 +2236,7 @@ impl RuntimeKeymap {
("open_agents", self.app.open_agents.as_slice()),
("open_transcript", self.app.open_transcript.as_slice()),
("find_transcript", self.app.find_transcript.as_slice()),
("select_transcript", self.app.select_transcript.as_slice()),
("focus_activity", self.app.focus_activity.as_slice()),
("open_warnings", self.app.open_warnings.as_slice()),
(
@@ -2336,6 +2355,30 @@ impl RuntimeKeymap {
validate_unique("pager", context_bindings(KeymapContext::Pager))?;
validate_unique(
"transcript",
[
(
"global.select_transcript",
self.app.select_transcript.as_slice(),
),
("pager.scroll_up", self.pager.scroll_up.as_slice()),
("pager.scroll_down", self.pager.scroll_down.as_slice()),
("pager.page_up", self.pager.page_up.as_slice()),
("pager.page_down", self.pager.page_down.as_slice()),
("pager.half_page_up", self.pager.half_page_up.as_slice()),
("pager.half_page_down", self.pager.half_page_down.as_slice()),
("pager.jump_top", self.pager.jump_top.as_slice()),
("pager.jump_bottom", self.pager.jump_bottom.as_slice()),
("pager.close", self.pager.close.as_slice()),
(
"pager.close_transcript",
self.pager.close_transcript.as_slice(),
),
("pager.find", self.pager.find.as_slice()),
],
)?;
validate_no_reserved(
"pager",
context_bindings(KeymapContext::Pager),
@@ -2352,6 +2395,10 @@ impl RuntimeKeymap {
"global.find_transcript",
self.app.find_transcript.as_slice(),
),
(
"global.select_transcript",
self.app.select_transcript.as_slice(),
),
]),
)?;
@@ -4140,9 +4187,14 @@ mod tests {
assert_eq!(
(
runtime.composer.history_search_previous,
runtime.chat.open_presets
runtime.chat.open_presets,
runtime.app.select_transcript
),
(vec![key_hint::ctrl(KeyCode::Char('r'))], Vec::new())
(
vec![key_hint::ctrl(KeyCode::Char('r'))],
Vec::new(),
Vec::new()
)
);
let defaults = RuntimeKeymap::defaults();
for leader in ["page-up", "page-down"] {
@@ -4153,11 +4205,13 @@ mod tests {
(
runtime.pager.page_up,
runtime.pager.page_down,
runtime.chat.open_presets
runtime.chat.open_presets,
runtime.app.select_transcript
),
(
defaults.pager.page_up.clone(),
defaults.pager.page_down.clone(),
Vec::new(),
Vec::new()
)
);
+10 -3
View File
@@ -114,9 +114,15 @@ impl KeymapActionId {
(Chat, List) => self.action != "open_presets",
(List, Chat) => other.action != "open_presets",
// Activity focus and Find remain available during list navigation.
(Global, List) => matches!(self.action, "focus_activity" | "find_transcript"),
(List, Global) => matches!(other.action, "focus_activity" | "find_transcript"),
// Activity focus, Find, and selection remain available during list navigation.
(Global, List) => matches!(
self.action,
"focus_activity" | "find_transcript" | "select_transcript"
),
(List, Global) => matches!(
other.action,
"focus_activity" | "find_transcript" | "select_transcript"
),
_ => self.context.overlaps(other.context),
}
@@ -281,6 +287,7 @@ define_runtime_action_bindings! {
open_agents,
open_transcript,
find_transcript,
select_transcript,
focus_activity,
open_warnings => runtime_only,
open_external_editor,
+42 -5
View File
@@ -34,7 +34,7 @@ const FIRST_DISPATCH_FUNCTION_KEY: u8 = codex_config::types::MAX_FUNCTION_KEY +
const LAST_DISPATCH_FUNCTION_KEY: u8 = u8::MAX;
const MAX_LEADER_CHORD_BINDINGS: usize = 1024;
pub(super) mod quick_dial_defaults;
pub(super) mod leader_defaults;
const LIST_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
("cancel", ctrl(KeyCode::Char('c'))),
("resume_picker.toggle_transcript", ctrl(KeyCode::Char('t'))),
@@ -52,6 +52,7 @@ const WARNINGS_FOCUS: u32 = 1 << 16;
const VOICE_TOGGLE: u32 = 1 << 17;
const RAW_KEY_CAPTURE: u32 = 1 << 18;
const VOICE_TOGGLE_BLOCKED: u32 = 1 << 19;
const TRANSCRIPT_ACTIONS: u32 = 1 << 20;
const TRANSCRIPT_BROWSING: u32 = 1 << 14;
@@ -81,6 +82,15 @@ impl KeymapContextSet {
)
}
/// Transcript overlays admit pager bindings plus the explicit selection and Find actions.
pub(crate) const fn transcript() -> Self {
Self(
context_bit(KeymapContext::Pager)
| context_bit(KeymapContext::Global)
| TRANSCRIPT_ACTIONS,
)
}
/// Warning pages admit navigation plus their own toggle and copy.
pub(crate) const fn warnings() -> Self {
Self(context_bit(KeymapContext::List) | context_bit(KeymapContext::Global) | WARNINGS_FOCUS)
@@ -154,13 +164,19 @@ impl KeymapContextSet {
self.contains(action.context)
&& (self.0 & ACTIVITY_FOCUS == 0
|| action.context != KeymapContext::Global
|| matches!(action.action, "focus_activity" | "find_transcript"))
|| matches!(
action.action,
"focus_activity" | "find_transcript" | "select_transcript"
))
&& (self.0 & TRANSCRIPT_CLOSE == 0
|| action.context != KeymapContext::Pager
|| action.action == "close_transcript")
&& (self.0 & TRANSCRIPT_BROWSING == 0
|| action.context != KeymapContext::Global
|| action.action == "open_transcript")
|| matches!(action.action, "open_transcript" | "select_transcript"))
&& (self.0 & TRANSCRIPT_ACTIONS == 0
|| action.context != KeymapContext::Global
|| matches!(action.action, "find_transcript" | "select_transcript"))
&& (!self.is_warnings()
|| action.context != KeymapContext::List
|| action.action != "accept")
@@ -214,9 +230,17 @@ impl RuntimeChordBinding {
self.spec.starts_with("leader ")
}
/// Leader consumers also remain active beside pager actions on native transcript surfaces.
/// Transcript selection remains active beside pager actions on native transcript surfaces.
fn overlaps(&self, action: KeymapActionId) -> bool {
self.action.overlaps(action)
|| (matches!(
(self.action.context, self.action.action),
(KeymapContext::Global, "select_transcript")
) || matches!(
(action.context, action.action),
(KeymapContext::Global, "select_transcript")
)) && KeymapContextSet::transcript().contains_action(self.action)
&& KeymapContextSet::transcript().contains_action(action)
|| self.is_leader()
&& [
KeymapContextSet::browsing(),
@@ -644,7 +668,7 @@ fn dispatch_binding(action: KeymapActionId) -> Option<KeyBinding> {
))
}
fn dispatch_event(action: KeymapActionId) -> Option<KeyEvent> {
pub(crate) fn dispatch_event(action: KeymapActionId) -> Option<KeyEvent> {
let (code, modifiers) = dispatch_binding(action)?.parts();
Some(KeyEvent::new(code, modifiers))
}
@@ -745,6 +769,19 @@ Unbind or remap the existing shortcut before using it as a chord prefix.",
if previous.action != binding.action
&& (previous.overlaps(binding.action) || binding.overlaps(previous.action))
&& previous.chord == binding.chord
&& !matches!(
(
(previous.action.context, previous.action.action),
(binding.action.context, binding.action.action),
),
(
(KeymapContext::Global, "find_transcript"),
(KeymapContext::Pager, "find")
) | (
(KeymapContext::Pager, "find"),
(KeymapContext::Global, "find_transcript")
)
)
{
return Err(format!(
"Ambiguous `{}` = `{}`: the same chord is already assigned to `{}`. \
@@ -0,0 +1,82 @@
//! Install implicit preset and selection shortcuts only for free leader chords.
//! Explicit bindings, including an explicit unbind, always take priority.
//! Implicit chords fit within the shared leader expansion limit.
use super::KeyChord;
use super::KeymapActionId;
use super::KeymapContext;
use super::MAX_LEADER_CHORD_BINDINGS;
use super::RuntimeChordBinding;
use super::RuntimeKeymap;
use super::normalize_chord_binding;
use super::runtime_action_bindings;
use codex_config::types::TuiKeymap;
use crossterm::event::KeyCode;
pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeymap) {
let mut remaining = MAX_LEADER_CHORD_BINDINGS.saturating_sub(
resolved
.chords
.bindings
.iter()
.filter(|binding| binding.is_leader())
.count(),
);
for (configured, context, action, completion, spec) in [
(
keymap.global.select_transcript.as_ref(),
KeymapContext::Global,
"select_transcript",
'[',
"leader [",
),
(
keymap.chat.open_presets.as_ref(),
KeymapContext::Chat,
"open_presets",
'p',
"leader p",
),
] {
if remaining == 0 {
break;
}
if configured.is_some() {
continue;
}
let action = KeymapActionId { context, action };
for prefix in resolved.chords.leader.clone() {
let binding = RuntimeChordBinding {
action,
chord: KeyChord {
prefix,
completion: crate::key_hint::plain(KeyCode::Char(completion)),
},
spec: spec.into(),
};
if !resolved.chords.bindings.iter().any(|existing| {
binding.overlaps(existing.action) && existing.chord == binding.chord
}) && !runtime_action_bindings(resolved)
// Owned transcript navigation also runs beside the composer.
.filter(|single| {
binding.overlaps(single.id) || single.id.context == KeymapContext::Pager
})
.any(|single| {
single
.bindings
.iter()
.any(|key| normalize_chord_binding(*key) == prefix)
})
{
std::sync::Arc::make_mut(&mut resolved.chords)
.bindings
.push(binding);
remaining -= 1;
if remaining == 0 {
break;
}
}
}
}
resolved.chat.chord_hints = std::sync::Arc::clone(&resolved.chords);
}
@@ -1,68 +0,0 @@
//! Install the implicit preset shortcut only for free leader chords.
//! Explicit bindings, including an explicit unbind, always take priority.
//! Implicit chords fit within the shared leader expansion limit.
use super::KeyChord;
use super::KeymapActionId;
use super::KeymapContext;
use super::MAX_LEADER_CHORD_BINDINGS;
use super::RuntimeChordBinding;
use super::RuntimeKeymap;
use super::normalize_chord_binding;
use super::runtime_action_bindings;
use codex_config::types::TuiKeymap;
use crossterm::event::KeyCode;
pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeymap) {
let mut remaining = MAX_LEADER_CHORD_BINDINGS.saturating_sub(
resolved
.chords
.bindings
.iter()
.filter(|binding| binding.is_leader())
.count(),
);
if keymap.chat.open_presets.is_some() || remaining == 0 {
return;
}
let action = KeymapActionId {
context: KeymapContext::Chat,
action: "open_presets",
};
for prefix in resolved.chords.leader.clone() {
let binding = RuntimeChordBinding {
action,
chord: KeyChord {
prefix,
completion: crate::key_hint::plain(KeyCode::Char('p')),
},
spec: "leader p".into(),
};
if !resolved
.chords
.bindings
.iter()
.any(|existing| binding.overlaps(existing.action) && existing.chord == binding.chord)
&& !runtime_action_bindings(resolved)
// Owned transcript navigation also runs beside the composer.
.filter(|single| {
binding.overlaps(single.id) || single.id.context == KeymapContext::Pager
})
.any(|single| {
single
.bindings
.iter()
.any(|key| normalize_chord_binding(*key) == prefix)
})
{
std::sync::Arc::make_mut(&mut resolved.chords)
.bindings
.push(binding);
remaining -= 1;
if remaining == 0 {
break;
}
}
}
resolved.chat.chord_hints = std::sync::Arc::clone(&resolved.chords);
}
@@ -0,0 +1,99 @@
use super::*;
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
fn binding(spec: &str) -> KeybindingsSpec {
KeybindingsSpec::One(KeybindingSpec(spec.to_string()))
}
fn dispatch_select(
runtime: &RuntimeKeymap,
prefix: crossterm::event::KeyEvent,
contexts: KeymapContextSet,
) -> bool {
let mut matcher = KeyChordMatcher::default();
assert!(matches!(
matcher.advance(prefix, &runtime.chords, contexts),
KeyChordMatch::Pending(_)
));
let KeyChordMatch::Completed(key) =
matcher.advance(KeyCode::Char('[').into(), &runtime.chords, contexts)
else {
return false;
};
runtime.app.select_transcript.is_pressed(key)
}
#[test]
fn select_transcript_uses_the_shared_leader_in_main_activity_and_overlay_contexts() {
let runtime = RuntimeKeymap::defaults();
for contexts in [
KeymapContextSet::new(KeymapContext::Global),
KeymapContextSet::activity(),
KeymapContextSet::transcript(),
] {
assert!(dispatch_select(
&runtime,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
contexts
));
}
}
#[test]
fn select_transcript_can_be_remapped_or_unbound() {
let mut config = TuiKeymap::default();
config.global.leader = Some(binding("f6"));
let runtime = RuntimeKeymap::from_config(&config).expect("remapped leader");
assert!(dispatch_select(
&runtime,
KeyCode::F(6).into(),
KeymapContextSet::new(KeymapContext::Global)
));
config.global.select_transcript = Some(KeybindingsSpec::Many(Vec::new()));
let runtime = RuntimeKeymap::from_config(&config).expect("unbound selection action");
assert!(runtime.app.select_transcript.is_empty());
assert!(
runtime
.chords
.bindings
.iter()
.all(|binding| binding.action.action != "select_transcript")
);
}
#[test]
fn default_select_suffix_yields_but_explicit_conflicts_fail() {
let mut config = TuiKeymap::default();
config.global.open_transcript = Some(binding("leader ["));
let runtime = RuntimeKeymap::from_config(&config).expect("existing leader consumer wins");
assert!(
runtime
.chords
.bindings
.iter()
.all(|binding| binding.action.action != "select_transcript")
);
config.global.open_transcript = None;
config.pager.scroll_up = Some(binding("ctrl-x"));
let runtime = RuntimeKeymap::from_config(&config).expect("leader prefix consumer wins");
assert!(
runtime
.chords
.bindings
.iter()
.all(|binding| binding.action.action != "select_transcript")
);
config.pager.scroll_up = None;
config.global.open_transcript = Some(binding("leader ["));
config.global.select_transcript = Some(binding("leader ["));
let error = RuntimeKeymap::from_config(&config).expect_err("explicit conflict");
assert!(error.contains("select_transcript") && error.contains("open_transcript"));
}
+119 -16
View File
@@ -26,6 +26,10 @@ pub(crate) struct TranscriptOverlay {
notice: Option<String>,
pub(crate) key_chord_hint: Option<Vec<(String, String)>>,
pub(crate) browsing_footer: Option<Line<'static>>,
find_transcript: Vec<crate::key_hint::KeyBinding>,
select_transcript: Vec<crate::key_hint::KeyBinding>,
pub(super) pending_follow_close: Option<(u64, bool)>,
pending_select_mode: bool,
is_done: bool,
}
@@ -63,6 +67,10 @@ impl TranscriptOverlay {
notice: None,
key_chord_hint: None,
browsing_footer: None,
find_transcript: Vec::new(),
select_transcript: Vec::new(),
pending_follow_close: None,
pending_select_mode: false,
is_done: false,
}
}
@@ -81,6 +89,14 @@ impl TranscriptOverlay {
content_height,
);
self.view.render(self.content_area, buf, &self.cells);
if self.pending_select_mode {
self.pending_select_mode = false;
if self.view.begin_select_mode(&self.cells) {
self.view.render(self.content_area, buf, &self.cells);
} else {
self.is_done = true;
}
}
if let Some(index) = self.pending_highlight.take() {
self.view.ensure_entry_visible(&self.cells, index);
self.view.render(self.content_area, buf, &self.cells);
@@ -175,26 +191,15 @@ impl TranscriptOverlay {
}
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
if let Some(completion) = tui.clipboard.poll()
&& let Some(follow) =
self.view
.finish_copy(&self.cells, completion, /*current*/ true)
{
self.notice = Some(match &completion.1 {
Ok(status) => status.message("selection"),
Err(error) => format!("Copy failed: {error}"),
});
if follow {
self.view.jump_to_latest();
self.is_done = self.browsing_footer.is_some();
}
if let Some(completion) = tui.clipboard.poll() {
self.finish_pending_copy(completion);
}
if matches!(event, TuiEvent::Resume | TuiEvent::FocusLost) {
self.view.cancel_primary();
self.view.end_drag();
}
// Apply a queued prompt jump before navigation can move away from it.
if self.pending_highlight.is_some()
if (self.pending_select_mode || self.pending_highlight.is_some())
&& (matches!(&event, TuiEvent::Key(key) if key.kind != KeyEventKind::Release)
|| matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != MouseEventKind::Moved))
{
@@ -213,6 +218,9 @@ impl TranscriptOverlay {
self.view.handle_mouse(mouse, &self.cells)
}
TuiEvent::Paste(text) => {
if self.view.is_select_mode() && !self.view.is_search_editing() {
return Ok(());
}
if self.view.is_search_editing() {
self.view.end_selection(&self.cells);
}
@@ -244,6 +252,9 @@ impl TranscriptOverlay {
}
pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.find_transcript.clone_from(&keymap.app.find_transcript);
self.select_transcript
.clone_from(&keymap.app.select_transcript);
self.view.set_keymap_bindings(keymap);
}
@@ -251,6 +262,10 @@ impl TranscriptOverlay {
self.view.begin_search();
}
pub(crate) fn begin_select_mode(&mut self) {
self.pending_select_mode = !self.view.begin_select_mode(&self.cells);
}
pub(crate) fn is_search_editing(&self) -> bool {
self.view.is_search_editing()
}
@@ -279,7 +294,11 @@ impl TranscriptOverlay {
if previous == TranscriptHistoryState::LoadingBeginning
&& state == TranscriptHistoryState::Complete
{
self.view.jump_to_entry(&self.cells, /*index*/ 0);
if self.view.is_select_mode() {
self.view.complete_select_beginning(&self.cells);
} else {
self.view.jump_to_entry(&self.cells, /*index*/ 0);
}
}
previous
}
@@ -421,12 +440,40 @@ impl TranscriptOverlay {
return None;
}
if !self.view.is_search_editing()
&& !self.view.is_select_mode()
&& (!self.view.has_active_interaction() || !self.view.owns_interaction_key(key))
&& self.keymap.find.is_pressed(key)
{
self.view.begin_search();
return Some(ViewAction::Changed);
}
if !self.view.is_search_editing()
&& !self.view.is_select_mode()
&& self.find_transcript.is_pressed(key)
{
self.view.begin_search();
return Some(ViewAction::Changed);
}
if !self.view.is_search_editing()
&& !self.view.is_select_mode()
&& self.select_transcript.is_pressed(key)
{
self.begin_select_mode();
return Some(ViewAction::Changed);
}
if self.view.is_select_mode() && !self.view.is_search_editing() {
if let Some(action) = self.view.handle_select_mode_key(key, &self.cells) {
return Some(action);
}
if self.keymap.find.is_pressed(key) || self.find_transcript.is_pressed(key) {
self.view.begin_search();
return Some(ViewAction::Changed);
}
if self.keymap.close.is_pressed(key) || self.keymap.close_transcript.is_pressed(key) {
self.is_done = true;
}
return Some(ViewAction::Changed);
}
if self.view.has_active_interaction()
&& let Some(action) = self.view.handle_key(key, &self.cells)
{
@@ -446,14 +493,41 @@ impl TranscriptOverlay {
self.view.cancel_beginning();
}
fn finish_select_follow(&mut self, close_overlay: bool) {
self.view.jump_to_latest();
if close_overlay {
self.is_done = true;
}
}
fn apply_action(&mut self, tui: &mut tui::Tui, action: ViewAction) {
self.notice = None;
if let Some((_, result)) = self.view.copy_action(
let resume_following = matches!(action, ViewAction::CopyAndFollow(_));
let close_overlay = self.view.is_select_mode() || self.browsing_footer.is_some();
if matches!(action, ViewAction::ExitSelectMode) {
self.is_done = true;
} else if let Some((_, result)) = self.view.copy_action(
tui,
&self.cells,
&action,
crate::clipboard_copy::CopyFormat::Markdown,
) {
if resume_following {
match result {
Ok(crate::clipboard_copy::CopyStatus::Confirmed) => {
self.pending_follow_close = None;
self.finish_select_follow(close_overlay);
}
Ok(crate::clipboard_copy::CopyStatus::Pending(id)) => {
self.pending_follow_close = Some((id, close_overlay));
}
Ok(
crate::clipboard_copy::CopyStatus::Unconfirmed
| crate::clipboard_copy::CopyStatus::Busy,
)
| Err(_) => {}
}
}
self.notice = Some(match result {
Ok(status) => status.message("selection"),
Err(error) => error,
@@ -465,6 +539,35 @@ impl TranscriptOverlay {
}
}
pub(super) fn finish_pending_copy(
&mut self,
completion: &(u64, crate::clipboard_copy::worker::CopyResult),
) {
let close_overlay = match self.pending_follow_close {
Some((id, close_overlay)) if id == completion.0 => {
self.pending_follow_close = None;
Some(close_overlay)
}
Some((id, _)) if id < completion.0 => {
self.pending_follow_close = None;
None
}
_ => None,
};
if let Some(follow) = self
.view
.finish_copy(&self.cells, completion, /*current*/ true)
{
self.notice = Some(match &completion.1 {
Ok(status) => status.message("selection"),
Err(error) => format!("Copy failed: {error}"),
});
if follow {
self.finish_select_follow(close_overlay.unwrap_or(false));
}
}
}
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let first = Rect::new(area.x, area.y, area.width, /*height*/ 1).intersection(area);
render_navigation_hints(first, buf, &self.keymap);
@@ -28,6 +28,87 @@ fn transcript_overlay(cells: Vec<Arc<dyn HistoryCell>>) -> TranscriptOverlay {
TranscriptOverlay::new(cells, default_pager_keymap())
}
#[test]
fn empty_pending_select_mode_closes_instead_of_retaining_overlay_focus() {
let mut overlay = transcript_overlay(Vec::new());
overlay.begin_select_mode();
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 8,
);
overlay.render(area, &mut Buffer::empty(area));
assert!(overlay.is_done());
}
#[tokio::test]
async fn select_enter_closes_and_remapped_action_marks_in_the_inline_overlay() -> Result<()> {
let cells = || {
vec![Arc::new(TestCell {
lines: vec!["copy me".into()],
}) as Arc<dyn HistoryCell>]
};
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 8,
);
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut remapped = transcript_overlay(cells());
let config = serde_json::from_value(serde_json::json!({
"global": {"find_transcript": "v"}
}))?;
let keymap =
crate::keymap::RuntimeKeymap::from_config(&config).expect("configured select shortcut");
remapped.set_keymap_bindings(&keymap);
remapped.render(area, &mut Buffer::empty(area));
remapped.begin_select_mode();
remapped.handle_event(&mut tui, TuiEvent::Key(KeyCode::Char('v').into()))?;
assert_eq!(
remapped.view.selected_text(&remapped.cells).as_deref(),
Some("c")
);
let mut find = transcript_overlay(cells());
find.set_keymap_bindings(&keymap);
find.render(area, &mut Buffer::empty(area));
find.begin_search();
find.handle_event(&mut tui, TuiEvent::Key(KeyCode::Char('v').into()))?;
assert!(find.view.is_search_editing());
assert!(!find.view.is_select_mode());
let mut enter = transcript_overlay(cells());
let mut pager_find = keymap.clone();
pager_find.pager.find = vec![crate::key_hint::KeyBinding::from_event(
KeyCode::Enter.into(),
)];
enter.set_keymap_bindings(&pager_find);
enter.render(area, &mut Buffer::empty(area));
enter.begin_select_mode();
enter.handle_event(&mut tui, TuiEvent::Key(KeyCode::Enter.into()))?;
assert!(enter.is_done());
let mut ordinary = transcript_overlay(vec![Arc::new(history_cell::AgentMarkdownCell::new(
"copy me".to_string(),
std::path::Path::new("/"),
))]);
ordinary.render(area, &mut Buffer::empty(area));
ordinary.browsing_footer = Some("browsing prompts".into());
assert!(
ordinary
.view
.begin_copy_mode(&ordinary.cells, /*guard*/ None)
);
ordinary.handle_event(&mut tui, TuiEvent::Key(KeyCode::Enter.into()))?;
if let Some((id, close_overlay)) = ordinary.pending_follow_close {
assert!(close_overlay);
ordinary.browsing_footer = None;
ordinary.finish_pending_copy(&(id, Ok(crate::clipboard_copy::CopyStatus::Confirmed)));
}
assert!(ordinary.is_done());
Ok(())
}
#[test]
fn scrolling_prepend_and_resize_preserve_reading_content() {
let cells = (0..30)
+41 -1
View File
@@ -48,7 +48,40 @@ impl TranscriptView {
},
]
.into(),
cursor_column: self.selection.is_none().then_some(cursor),
cursor_column: Some(cursor),
is_interactive: true,
});
}
if self.select_mode.is_some() {
let hints = if has_selected_text {
let copy = crate::key_hint::ctrl(KeyCode::Char('c')).display_label();
[
format!(
"arrows/hjkl/w/b/e/shift+arrows move · home/end/0/$/g/G/⌃home/⌃end ends · pgup/pgdn/⌃b/⌃f/⌃u/⌃d page · v/space/V mark · o swap · / find · y/{copy} copy · enter copy & latest · esc clear · q close"
),
format!(
"arrows/hjkl move · o swap · y/{copy} copy · enter copy & latest · esc clear · q close"
),
format!("y/{copy} copy · enter copy & latest · esc clear · q close"),
"enter copy · q close".to_owned(),
]
} else {
[
"arrows/hjkl/w/b/e move · home/end/0/$/g/G/⌃home/⌃end ends · pgup/pgdn/⌃b/⌃f/⌃u/⌃d page · v/space/V/shift+arrows mark · / find · n/N next · enter/q/esc close"
.to_owned(),
"arrows/hjkl/w/b/e move · 0/$/g/G ends · v/space/V mark · / find · enter/q/esc close"
.to_owned(),
"arrows/hjkl move · v/space/V mark · / find · enter/q/esc close".to_owned(),
"v mark · enter/q close".to_owned(),
]
};
return Some(TranscriptFooter {
text: first_fitting_line(
hints.iter().map(String::as_str).map(navigation_line),
width,
)
.into(),
cursor_column: None,
is_interactive: true,
});
}
@@ -238,8 +271,15 @@ pub(super) fn navigation_line(navigation: &str) -> Line<'static> {
}
let action = [
" copy & follow",
" copy & latest",
" clear selection",
" copy+↓",
" move",
" ends",
" page",
" mark",
" find",
" swap",
" previous",
" accept",
" cancel",
+19 -1
View File
@@ -262,7 +262,25 @@ impl TranscriptView {
event: MouseEvent,
cells: &[Arc<dyn HistoryCell>],
) -> Option<ViewAction> {
self.handle_mouse_with_mode(event, cells, MouseMode::Selection)
let endpoint_changed = matches!(
event.kind,
MouseEventKind::Down(MouseButton::Left)
| MouseEventKind::Drag(MouseButton::Left)
| MouseEventKind::Up(MouseButton::Left)
);
let before = self
.selection
.as_ref()
.map(|selection| (selection.start, selection.end, selection.unit()));
let action = self.handle_mouse_with_mode(event, cells, MouseMode::Selection);
let after = self
.selection
.as_ref()
.map(|selection| (selection.start, selection.end, selection.unit()));
if endpoint_changed && before != after {
self.adopt_select_mouse_range();
}
action
}
fn handle_mouse_with_mode(
@@ -92,7 +92,6 @@ impl SelectMode {
impl TranscriptView {
/// Enter at the accepted Find hit, preferring the bottommost visible selectable position.
#[cfg_attr(not(test), expect(dead_code, reason = "used by a later stack layer"))]
pub(crate) fn begin_select_mode(&mut self, cells: &[Arc<dyn HistoryCell>]) -> bool {
if self.select_mode.is_some() {
return true;
@@ -136,6 +135,17 @@ impl TranscriptView {
true
}
pub(crate) fn is_select_mode(&self) -> bool {
self.select_mode.is_some()
}
/// Finish an explicit beginning traversal after the last older page is installed.
pub(crate) fn complete_select_beginning(&mut self, cells: &[Arc<dyn HistoryCell>]) {
if self.select_mode.is_some() {
self.move_select_cursor(cells, CursorMotion::Beginning);
}
}
/// Leave the mode while retaining its displayed revision at the paused reading position.
pub(crate) fn end_select_mode(&mut self) {
if self.select_mode.take().is_none() {
@@ -1,8 +1,13 @@
use super::*;
use crate::clipboard_copy::CopyStatus;
use crate::history_cell::PlainHistoryCell;
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use pretty_assertions::assert_eq;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
fn cell(lines: &[&str]) -> Arc<dyn HistoryCell> {
Arc::new(PlainHistoryCell::new(
@@ -608,3 +613,194 @@ fn accepted_find_pins_an_expanded_match_before_select_mode_exits() {
expanded
);
}
#[test]
fn beginning_motion_finishes_at_the_first_grapheme_after_paging() {
let mut cells = vec![cell(&["newer"])];
let mut view = TranscriptView {
history: TranscriptHistoryState::Partial,
..Default::default()
};
render(&mut view, &cells);
assert!(view.begin_select_mode(&cells));
press(&mut view, &cells, KeyCode::Char('g'));
assert_eq!(view.history, TranscriptHistoryState::LoadingBeginning);
cells.insert(/*index*/ 0, cell(&["older"]));
view.history_loaded(&cells, 0..1);
view.history = TranscriptHistoryState::Complete;
view.complete_select_beginning(&cells);
press(&mut view, &cells, KeyCode::Char('v'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("o"));
}
#[test]
fn shift_click_extends_from_the_keyboard_marks_fixed_anchor() {
let cells = vec![cell(&["abcdef"])];
let mut view = TranscriptView::default();
render(&mut view, &cells);
assert!(view.begin_select_mode(&cells));
press(&mut view, &cells, KeyCode::Char('g'));
press(&mut view, &cells, KeyCode::Right);
press(&mut view, &cells, KeyCode::Right);
press(&mut view, &cells, KeyCode::Char('v'));
press(&mut view, &cells, KeyCode::Right);
press(&mut view, &cells, KeyCode::Right);
assert_eq!(view.selected_text(&cells).as_deref(), Some("cde"));
view.handle_mouse(
MouseEvent {
kind: MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 0,
row: 0,
modifiers: KeyModifiers::SHIFT,
},
&cells,
);
assert_eq!(view.selected_text(&cells).as_deref(), Some("abc"));
}
#[test]
fn existing_mouse_selection_keeps_its_word_or_line_unit() {
let word_cells = vec![cell(&["alpha beta"])];
let mut word_view = TranscriptView::default();
render(&mut word_view, &word_cells);
word_view.begin_selection(
&word_cells,
/*column*/ 1,
/*row*/ 0,
/*clicks*/ 2,
);
assert!(word_view.begin_select_mode(&word_cells));
press(&mut word_view, &word_cells, KeyCode::Char('h'));
assert_eq!(
word_view.selected_text(&word_cells).as_deref(),
Some(&"alpha"[..4])
);
let line_cells = vec![cell(&["first", "second"])];
let mut line_view = TranscriptView::default();
render(&mut line_view, &line_cells);
line_view.begin_selection(
&line_cells,
/*column*/ 1,
/*row*/ 1,
/*clicks*/ 3,
);
assert!(line_view.begin_select_mode(&line_cells));
press(&mut line_view, &line_cells, KeyCode::Char('k'));
assert_eq!(
line_view.selected_text(&line_cells).as_deref(),
Some("first\nsecond")
);
}
#[test]
fn passive_mouse_events_do_not_reanchor_a_reverse_line_mark() {
let cells = vec![cell(&["first", "second", "third"])];
let mut view = TranscriptView::default();
render(&mut view, &cells);
assert!(view.begin_select_mode(&cells));
press(&mut view, &cells, KeyCode::Char('g'));
press(&mut view, &cells, KeyCode::Char('j'));
press(&mut view, &cells, KeyCode::Char('V'));
press(&mut view, &cells, KeyCode::Char('k'));
for kind in [MouseEventKind::Moved, MouseEventKind::ScrollDown] {
view.handle_mouse(
MouseEvent {
kind,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
},
&cells,
);
}
view.handle_mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 80,
row: 80,
modifiers: KeyModifiers::NONE,
},
&cells,
);
press(&mut view, &cells, KeyCode::Char('j'));
assert_eq!(view.selected_text(&cells).as_deref(), Some("second\n"));
}
#[test]
fn select_mode_renders_cursor_and_scannable_footer_hints() {
let cells = vec![cell(&["alpha beta", "gamma"])];
let mut view = TranscriptView::default();
render(&mut view, &cells);
assert!(view.begin_select_mode(&cells));
let buffer = render(&mut view, &cells);
let [unmarked_wide, footer, unmarked_narrow] = [240, 64, 32].map(|width| {
view.footer_with_navigation(
width,
crate::motion::MotionMode::Reduced,
/*latest_navigation*/ "",
)
.expect("select footer")
});
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
/*width*/ 64,
footer.text.height() as u16,
);
let mut footer_buffer = Buffer::empty(area);
Paragraph::new(footer.text).render(area, &mut footer_buffer);
press(&mut view, &cells, KeyCode::Char('g'));
press(&mut view, &cells, KeyCode::Char('v'));
for _ in 0..5 {
press(&mut view, &cells, KeyCode::Right);
}
let marked_buffer = render(&mut view, &cells);
let [marked_wide, marked] = [240, 64].map(|width| {
view.footer_with_navigation(
width,
crate::motion::MotionMode::Reduced,
/*latest_navigation*/ "",
)
.expect("select footer")
});
press(&mut view, &cells, KeyCode::Esc);
press(&mut view, &cells, KeyCode::Char('/'));
assert!(view.paste_search("beta"));
while view.advance_search(&cells) {}
let find_buffer = render(&mut view, &cells);
let find_footer = view
.footer_with_navigation(
/*width*/ 64,
crate::motion::MotionMode::Reduced,
/*latest_navigation*/ "",
)
.expect("find footer");
let find_footer_text = find_footer
.text
.to_string()
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n");
press(&mut view, &cells, KeyCode::Enter);
press(&mut view, &cells, KeyCode::Char('v'));
press(&mut view, &cells, KeyCode::Right);
let find_marked_buffer = render(&mut view, &cells);
insta::assert_snapshot!(format!(
"{buffer:?}\nunmarked wide: {}\nunmarked:\n{footer_buffer:?}\nunmarked narrow: {}\nmarked:\n{marked_buffer:?}\nmarked wide: {}\nmarked: {}\nfind:\n{find_buffer:?}\nfind footer: {}\nfind cursor: {:?}\nfind marked:\n{find_marked_buffer:?}",
unmarked_wide.text,
unmarked_narrow.text,
marked_wide.text,
marked.text,
find_footer_text,
find_footer.cursor_column,
));
}
@@ -0,0 +1,99 @@
---
source: tui/src/transcript_view/select_mode_tests.rs
assertion_line: 627
expression: "format!(\"{buffer:?}\\nunmarked wide: {}\\nunmarked:\\n{footer_buffer:?}\\nunmarked narrow: {}\\nmarked:\\n{marked_buffer:?}\\nmarked wide: {}\\nmarked: {}\\nfind:\\n{find_buffer:?}\\nfind footer: {}\\nfind cursor: {:?}\\nfind marked:\\n{find_marked_buffer:?}\",\nunmarked_wide.text, unmarked_narrow.text, marked_wide.text, marked.text,\nfind_footer.text, find_footer.cursor_column,)"
---
Buffer {
area: Rect { x: 0, y: 0, width: 32, height: 6 },
content: [
"alpha beta ",
"gamma ",
" ",
" ",
" ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | UNDERLINED | REVERSED,
x: 1, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
unmarked wide: arrows/hjkl/w/b/e move · home/end/0/$/g/G/⌃home/⌃end ends · pgup/pgdn/⌃b/⌃f/⌃u/⌃d page · v/space/V/shift+arrows mark · / find · n/N next · enter/q/esc close
unmarked:
Buffer {
area: Rect { x: 0, y: 0, width: 64, height: 1 },
content: [
"arrows/hjkl move · v/space/V mark · / find · enter/q/esc close ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 19, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 28, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 37, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 45, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 56, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 62, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
unmarked narrow: v mark · enter/q close
marked:
Buffer {
area: Rect { x: 0, y: 0, width: 32, height: 6 },
content: [
"alpha beta ",
"gamma ",
" ",
" ",
" ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 5, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | UNDERLINED | REVERSED,
x: 6, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
marked wide: arrows/hjkl/w/b/e/shift+arrows move · home/end/0/$/g/G/⌃home/⌃end ends · pgup/pgdn/⌃b/⌃f/⌃u/⌃d page · v/space/V mark · o swap · / find · y/⌃c copy · enter copy & latest · esc clear · q close
marked: y/⌃c copy · enter copy & latest · esc clear · q close
find:
Buffer {
area: Rect { x: 0, y: 0, width: 32, height: 6 },
content: [
"alpha beta ",
"gamma ",
" ",
" ",
" ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 5, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | UNDERLINED | REVERSED,
x: 6, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 10, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
find footer: Find: beta
enter accept · ⌃p older · ⌃n newer · esc cancel
find cursor: Some(10)
find marked:
Buffer {
area: Rect { x: 0, y: 0, width: 32, height: 6 },
content: [
"alpha beta ",
"gamma ",
" ",
" ",
" ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 6, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
x: 7, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | UNDERLINED | REVERSED,
x: 8, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}