diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 170dcf1b44..6fed3e3561 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -141,6 +141,8 @@ pub struct TuiGlobalKeymap { pub open_transcript: Option, /// Find text in the full transcript. pub find_transcript: Option, + /// Enter keyboard selection mode for the transcript. + pub select_transcript: Option, /// Focus activity groups in the owned transcript to inspect their details. pub focus_activity: Option, /// Open the external editor for the current draft. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7b101bd261..3dd9740369 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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, diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index c185f40f02..884cfe34ba 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -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, /// Find text in the full transcript. pub(crate) find_transcript: Vec, + /// Enter keyboard selection mode for the transcript. + pub(crate) select_transcript: Vec, /// Focus activity groups in the owned transcript to inspect their details. pub(crate) focus_activity: Vec, /// 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() ) ); diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index ac7447af53..cfb430b5ce 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -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, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index 865dffcc7b..40733cf61b 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -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 { )) } -fn dispatch_event(action: KeymapActionId) -> Option { +pub(crate) fn dispatch_event(action: KeymapActionId) -> Option { 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 `{}`. \ diff --git a/codex-rs/tui/src/keymap/chords/leader_defaults.rs b/codex-rs/tui/src/keymap/chords/leader_defaults.rs new file mode 100644 index 0000000000..425f8ca271 --- /dev/null +++ b/codex-rs/tui/src/keymap/chords/leader_defaults.rs @@ -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); +} diff --git a/codex-rs/tui/src/keymap/chords/quick_dial_defaults.rs b/codex-rs/tui/src/keymap/chords/quick_dial_defaults.rs deleted file mode 100644 index b1849f7ab1..0000000000 --- a/codex-rs/tui/src/keymap/chords/quick_dial_defaults.rs +++ /dev/null @@ -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); -} diff --git a/codex-rs/tui/src/keymap/select_transcript_tests.rs b/codex-rs/tui/src/keymap/select_transcript_tests.rs new file mode 100644 index 0000000000..2975beae09 --- /dev/null +++ b/codex-rs/tui/src/keymap/select_transcript_tests.rs @@ -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")); +} diff --git a/codex-rs/tui/src/pager_overlay/transcript.rs b/codex-rs/tui/src/pager_overlay/transcript.rs index 44097d4515..62e27083e5 100644 --- a/codex-rs/tui/src/pager_overlay/transcript.rs +++ b/codex-rs/tui/src/pager_overlay/transcript.rs @@ -26,6 +26,10 @@ pub(crate) struct TranscriptOverlay { notice: Option, pub(crate) key_chord_hint: Option>, pub(crate) browsing_footer: Option>, + find_transcript: Vec, + select_transcript: Vec, + 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); diff --git a/codex-rs/tui/src/pager_overlay/transcript_tests.rs b/codex-rs/tui/src/pager_overlay/transcript_tests.rs index 4dd388f9cd..1853aff3ab 100644 --- a/codex-rs/tui/src/pager_overlay/transcript_tests.rs +++ b/codex-rs/tui/src/pager_overlay/transcript_tests.rs @@ -28,6 +28,87 @@ fn transcript_overlay(cells: Vec>) -> 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] + }; + 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) diff --git a/codex-rs/tui/src/transcript_view/footer.rs b/codex-rs/tui/src/transcript_view/footer.rs index 191e339ecc..addb462eb1 100644 --- a/codex-rs/tui/src/transcript_view/footer.rs +++ b/codex-rs/tui/src/transcript_view/footer.rs @@ -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", diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs index c52b388d9a..1ce29ba3af 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -262,7 +262,25 @@ impl TranscriptView { event: MouseEvent, cells: &[Arc], ) -> Option { - 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( diff --git a/codex-rs/tui/src/transcript_view/select_mode.rs b/codex-rs/tui/src/transcript_view/select_mode.rs index 5a2077f7ae..1cdc1df878 100644 --- a/codex-rs/tui/src/transcript_view/select_mode.rs +++ b/codex-rs/tui/src/transcript_view/select_mode.rs @@ -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]) -> 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]) { + 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() { diff --git a/codex-rs/tui/src/transcript_view/select_mode_tests.rs b/codex-rs/tui/src/transcript_view/select_mode_tests.rs index 1694572aa2..457a2a2a7d 100644 --- a/codex-rs/tui/src/transcript_view/select_mode_tests.rs +++ b/codex-rs/tui/src/transcript_view/select_mode_tests.rs @@ -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 { 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::>() + .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, + )); +} diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__select_mode__tests__select_mode_renders_cursor_and_scannable_footer_hints.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__select_mode__tests__select_mode_renders_cursor_and_scannable_footer_hints.snap new file mode 100644 index 0000000000..2c0e63cbf1 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__select_mode__tests__select_mode_renders_cursor_and_scannable_footer_hints.snap @@ -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, + ] +}