From abf4bb91ec49fbf443ccec0069f9cab5dd1462d8 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 25 Oct 2026 05:00:44 +0000 Subject: [PATCH] Expand keyboard navigation and selection in the transcript (#57221) ## What changed - Add word motions (`w`, `b`, `e`), logical line boundaries, page and half-page motions, and jumps to the transcript's beginning and end. Honor configured pager bindings for navigation. - Add whole-line selection with `V` and active-end swapping with `o`. - Adopt existing mouse selections when entering select mode, preserving selection units and endpoint bounds so keyboard motions can adjust the range. ## Testing Add regression tests for word motion across entries, reverse whole-line selection, empty-line boundaries, configured pager motion, final-grapheme selection, endpoint swapping, and caret placement after copying. GitOrigin-RevId: f0cd05900f2a7a15840777cee4d50659a2c8b134 --- .../tui/src/transcript_view/select_mode.rs | 166 ++++++++++++- .../src/transcript_view/select_mode/motion.rs | 234 ++++++++++++++++-- .../src/transcript_view/select_mode_tests.rs | 205 +++++++++++++++ codex-rs/tui/src/transcript_view/selection.rs | 4 + 4 files changed, 588 insertions(+), 21 deletions(-) diff --git a/codex-rs/tui/src/transcript_view/select_mode.rs b/codex-rs/tui/src/transcript_view/select_mode.rs index 562a812b3b..44e1d571a2 100644 --- a/codex-rs/tui/src/transcript_view/select_mode.rs +++ b/codex-rs/tui/src/transcript_view/select_mode.rs @@ -5,6 +5,7 @@ //! `Selection`. use super::*; +use crate::key_hint::KeyBindingListExt; use crate::text_selection::SelectionUnit; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -25,12 +26,52 @@ pub(super) struct SelectMode { pub(super) struct Mark { anchor: Anchor, active: Anchor, + anchor_edges: Option<(Anchor, Anchor)>, + active_edges: Option<(Anchor, Anchor)>, + unit: SelectionUnit, } #[derive(Clone, Copy)] enum CursorMotion { Character(KeyCode), Rows(isize), + WordForward, + WordBackward, + WordEnd, + LineStart, + LineEnd, + Beginning, + End, +} + +fn pager_motion( + keymap: &crate::keymap::RuntimeKeymap, + key: KeyEvent, + area_height: u16, +) -> Option { + let pager = &keymap.pager; + if pager.jump_top.is_pressed(key) { + return Some(CursorMotion::Beginning); + } + if pager.jump_bottom.is_pressed(key) { + return Some(CursorMotion::End); + } + let page = isize::try_from(area_height.saturating_sub(1).max(1)).unwrap_or(isize::MAX); + let half = isize::try_from(area_height.div_ceil(2)).unwrap_or(isize::MAX); + [ + (&pager.scroll_up, -1), + (&pager.scroll_down, 1), + (&pager.page_up, -page), + (&pager.page_down, page), + (&pager.half_page_up, -half), + (&pager.half_page_down, half), + ] + .into_iter() + .find_map(|(action_bindings, rows)| { + action_bindings + .is_pressed(key) + .then_some(CursorMotion::Rows(rows)) + }) } impl SelectMode { @@ -48,6 +89,7 @@ impl TranscriptView { } self.copy_mode = None; self.cancel_beginning(); + let has_range = self.has_selection_range(); if self.selection.is_none() { let cursor = self .visible @@ -74,6 +116,9 @@ impl TranscriptView { self.hold_position(); } self.select_mode = Some(SelectMode::default()); + if has_range { + self.adopt_select_mouse_range(); + } self.reveal_select_cursor(cells); true } @@ -134,17 +179,24 @@ impl TranscriptView { && matches!(code, KeyCode::Char('v') | KeyCode::Char(' ')) && modifiers == KeyModifiers::NONE { - self.start_select_mark(cells); + self.start_select_mark(cells, SelectionUnit::Character); + return Some(ViewAction::Changed); + } + if pressed && code == KeyCode::Char('v') && modifiers == KeyModifiers::SHIFT { + self.start_select_mark(cells, SelectionUnit::Line); + return Some(ViewAction::Changed); + } + if pressed && code == KeyCode::Char('o') && modifiers == KeyModifiers::NONE { + self.swap_select_ends(cells); return Some(ViewAction::Changed); } - let shift_arrow = modifiers == KeyModifiers::SHIFT && matches!( code, KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down ); if shift_arrow && self.select_mode.is_some_and(|mode| mode.mark.is_none()) { - self.start_select_mark(cells); + self.start_select_mark(cells, SelectionUnit::Character); } let motion = match (code, modifiers) { ( @@ -161,6 +213,38 @@ impl TranscriptView { (KeyCode::Char('l'), KeyModifiers::NONE) => CursorMotion::Character(KeyCode::Right), (KeyCode::Char('k'), KeyModifiers::NONE) => CursorMotion::Rows(-1), (KeyCode::Char('j'), KeyModifiers::NONE) => CursorMotion::Rows(1), + (KeyCode::Char('w'), KeyModifiers::NONE) => CursorMotion::WordForward, + (KeyCode::Char('b'), KeyModifiers::NONE) => CursorMotion::WordBackward, + (KeyCode::Char('e'), KeyModifiers::NONE) => CursorMotion::WordEnd, + (KeyCode::Home | KeyCode::Char('0'), KeyModifiers::NONE) => CursorMotion::LineStart, + (KeyCode::End, KeyModifiers::NONE) + | (KeyCode::Char('$'), KeyModifiers::NONE | KeyModifiers::SHIFT) => { + CursorMotion::LineEnd + } + (KeyCode::PageUp, KeyModifiers::NONE) | (KeyCode::Char('b'), KeyModifiers::CONTROL) => { + CursorMotion::Rows( + -isize::try_from(self.area.height.saturating_sub(1).max(1)) + .unwrap_or(isize::MAX), + ) + } + (KeyCode::PageDown, KeyModifiers::NONE) + | (KeyCode::Char('f'), KeyModifiers::CONTROL) => CursorMotion::Rows( + isize::try_from(self.area.height.saturating_sub(1).max(1)).unwrap_or(isize::MAX), + ), + (KeyCode::Char('u'), KeyModifiers::CONTROL) => CursorMotion::Rows( + -isize::try_from(self.area.height.div_ceil(2)).unwrap_or(isize::MAX), + ), + (KeyCode::Char('d'), KeyModifiers::CONTROL) => CursorMotion::Rows( + isize::try_from(self.area.height.div_ceil(2)).unwrap_or(isize::MAX), + ), + (KeyCode::Char('g'), KeyModifiers::NONE) | (KeyCode::Home, KeyModifiers::CONTROL) => { + CursorMotion::Beginning + } + (KeyCode::Char('g'), KeyModifiers::SHIFT) => CursorMotion::End, + (KeyCode::End, KeyModifiers::CONTROL) => CursorMotion::End, + _ if !self.is_search_editing() => { + pager_motion(&self.disclosure.keymap, key, self.area.height)? + } _ => return None, }; self.move_select_cursor(cells, motion); @@ -172,7 +256,7 @@ impl TranscriptView { ViewAction::ExitSelectMode } - fn start_select_mark(&mut self, cells: &[Arc]) { + fn start_select_mark(&mut self, cells: &[Arc], unit: SelectionUnit) { let Some(mut mode) = self.select_mode else { return; }; @@ -181,13 +265,22 @@ impl TranscriptView { }; let cursor = mode.cursor(selection); mode.mark = match mode.mark { - Some(_) => { + Some(mark) if mark.unit == unit => { self.clear_select_mark(); return; } + Some(mark) => Some(Mark { + anchor_edges: None, + active_edges: None, + unit, + ..mark + }), None => Some(Mark { anchor: cursor, active: cursor, + anchor_edges: None, + active_edges: None, + unit, }), }; mode.preferred_column = None; @@ -208,7 +301,21 @@ impl TranscriptView { selection.set_keyboard_range(cursor, cursor); } - fn reveal_select_cursor(&mut self, cells: &[Arc]) { + fn swap_select_ends(&mut self, cells: &[Arc]) { + let Some(mode) = &mut self.select_mode else { + return; + }; + let Some(mark) = &mut mode.mark else { + return; + }; + std::mem::swap(&mut mark.anchor, &mut mark.active); + std::mem::swap(&mut mark.anchor_edges, &mut mark.active_edges); + mode.preferred_column = None; + self.sync_select_range(cells); + self.reveal_select_cursor(cells); + } + + pub(super) fn reveal_select_cursor(&mut self, cells: &[Arc]) { let Some(mode) = self.select_mode else { return; }; @@ -259,6 +366,53 @@ impl TranscriptView { buf.set_style(area, crate::style::selection_style().underlined()); } } + + pub(super) fn adopt_select_mouse_range(&mut self) { + let has_range = self.has_selection_range(); + let Some(selection) = &self.selection else { + return; + }; + let (start, end, unit) = (selection.start, selection.end, selection.unit()); + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or_default(); + let mark = has_range.then(|| { + if self.anchor_order(cells, start) <= self.anchor_order(cells, end) { + let active = self.before_grapheme(cells, end); + let anchor_end = self + .selection_endpoint_bounds(cells, start, unit) + .map_or(start, |edges| edges.1); + let active_start = self + .selection_endpoint_bounds(cells, active, unit) + .map_or(active, |edges| edges.0); + Mark { + anchor: start, + active, + anchor_edges: Some((start, anchor_end)), + active_edges: Some((active_start, end)), + unit, + } + } else { + let anchor = self.before_grapheme(cells, start); + let anchor_start = self + .selection_endpoint_bounds(cells, anchor, unit) + .map_or(anchor, |edges| edges.0); + let active_end = self + .selection_endpoint_bounds(cells, end, unit) + .map_or(end, |edges| edges.1); + Mark { + anchor, + active: end, + anchor_edges: Some((anchor_start, start)), + active_edges: Some((end, active_end)), + unit, + } + } + }); + if let Some(mode) = &mut self.select_mode { + mode.preferred_column = None; + mode.mark = mark; + } + } } #[cfg(test)] diff --git a/codex-rs/tui/src/transcript_view/select_mode/motion.rs b/codex-rs/tui/src/transcript_view/select_mode/motion.rs index d9198b9348..911f29c623 100644 --- a/codex-rs/tui/src/transcript_view/select_mode/motion.rs +++ b/codex-rs/tui/src/transcript_view/select_mode/motion.rs @@ -1,4 +1,4 @@ -//! Basic cursor motions and marked-range derivation for keyboard transcript selection. +//! Cursor motions and marked-range derivation for keyboard transcript selection. use super::*; use crossterm::event::KeyCode; @@ -13,7 +13,9 @@ impl TranscriptView { let Some(mut mode) = self.select_mode else { return; }; - self.cancel_beginning(); + if !matches!(motion, CursorMotion::Beginning) { + self.cancel_beginning(); + } let Some(selection) = self.selection.as_ref() else { return; }; @@ -39,6 +41,32 @@ impl TranscriptView { mode.preferred_column = column; self.move_select_rows(cells, cursor, rows, column) } + CursorMotion::WordForward => { + mode.preferred_column = None; + self.move_select_word( + cells, cursor, /*backward*/ false, /*to_end*/ false, + ) + } + CursorMotion::WordBackward => { + mode.preferred_column = None; + self.move_select_word(cells, cursor, /*backward*/ true, /*to_end*/ false) + } + CursorMotion::WordEnd => { + mode.preferred_column = None; + self.move_select_word(cells, cursor, /*backward*/ false, /*to_end*/ true) + } + CursorMotion::LineStart | CursorMotion::LineEnd => { + mode.preferred_column = None; + self.move_select_line(cells, cursor, matches!(motion, CursorMotion::LineEnd)) + } + CursorMotion::Beginning | CursorMotion::End => { + mode.preferred_column = None; + if matches!(motion, CursorMotion::Beginning) && self.history.has_unloaded_history() + { + self.jump_to_beginning(cells); + } + self.select_boundary(cells, matches!(motion, CursorMotion::End)) + } }; let Some(next) = next else { self.select_mode = Some(mode); @@ -46,6 +74,7 @@ impl TranscriptView { }; if let Some(mark) = &mut mode.mark { mark.active = next; + mark.active_edges = None; } else if let Some(selection) = &mut self.selection { selection.set_keyboard_range(next, next); } @@ -140,6 +169,96 @@ impl TranscriptView { Some(cursor) } + fn move_select_word( + &mut self, + cells: &[Arc], + cursor: Anchor, + backward: bool, + to_end: bool, + ) -> Option { + let mut index = self.resolve(cells, cursor); + let origin_index = index; + loop { + let layout = self.layout(cells, index)?; + let text = layout.text(); + let offset = if index == origin_index { + text.floor_char_boundary(cursor.offset.min(text.len())) + } else if backward { + text.len() + } else { + 0 + }; + let mut words = text + .split_word_bound_indices() + .filter(|(_, word)| word.chars().any(char::is_alphanumeric)); + let found = if backward { + words + .rfind(|(start, _)| *start < offset) + .map(|(start, _)| start) + } else if to_end { + words + .filter_map(|(start, word)| { + text[..start + word.len()] + .grapheme_indices(/*is_extended*/ true) + .next_back() + .map(|(offset, _)| offset) + }) + .find(|end| index != origin_index || *end > offset) + } else { + words + .find(|(start, _)| { + index != origin_index + || *start > offset + || (cursor.row_bias > 0 && *start == offset) + }) + .map(|(start, _)| start) + }; + if let Some(offset) = found { + return Some(Anchor { + key: self.entry_key(cells, index), + index, + offset, + row_bias: 0, + }); + } + if backward { + index = index.checked_sub(1)?; + } else { + index += 1; + self.layout(cells, index)?; + } + } + } + + fn move_select_line( + &mut self, + cells: &[Arc], + cursor: Anchor, + end: bool, + ) -> Option { + let index = self.resolve(cells, cursor); + let layout = self.layout(cells, index)?; + let range = SelectionUnit::Line.range(layout.text(), cursor.offset); + let offset = if end { + let end = range.end.saturating_sub(usize::from( + range.end > range.start + && layout.text().as_bytes().get(range.end - 1) == Some(&b'\n'), + )); + layout.text()[range.start..end] + .grapheme_indices(/*is_extended*/ true) + .next_back() + .map_or(range.start, |(offset, _)| range.start + offset) + } else { + range.start + }; + Some(Anchor { + key: self.entry_key(cells, index), + index, + offset, + row_bias: 0, + }) + } + pub(super) fn select_boundary( &mut self, cells: &[Arc], @@ -185,21 +304,56 @@ impl TranscriptView { let cells = snapshot.as_deref().unwrap_or(cells); let after_anchor = self.after_grapheme(cells, mark.anchor); let after_active = self.after_grapheme(cells, mark.active); + let origin = match mark.unit { + _ if mark.anchor_edges.is_some() => mark.anchor_edges, + SelectionUnit::Character => Some((mark.anchor, after_anchor)), + SelectionUnit::Word | SelectionUnit::Line => { + self.selection_bounds(cells, mark.anchor, mark.unit) + } + }; + let Some(origin) = origin else { return }; + let active_line = (mark.unit == SelectionUnit::Line) + .then(|| self.selection_bounds(cells, mark.active, SelectionUnit::Line)) + .flatten(); let Some(mut selection) = self.selection.take() else { return; }; - let (start, end) = - if self.anchor_order(cells, mark.anchor) <= self.anchor_order(cells, mark.active) { - (mark.anchor, after_active) - } else { - (after_anchor, mark.active) - }; - selection.set_keyboard_marked_range( - start, - end, - (mark.anchor, after_anchor), - SelectionUnit::Character, - ); + let anchor_order = self.anchor_order(cells, mark.anchor); + let active_order = self.anchor_order(cells, mark.active); + let forward = anchor_order <= active_order; + let (start, end) = match mark.unit { + SelectionUnit::Character | SelectionUnit::Word => { + if forward { + ( + origin.0, + mark.active_edges.map_or(after_active, |edges| edges.1), + ) + } else { + ( + mark.active_edges.map_or(mark.active, |edges| edges.0), + origin.1, + ) + } + } + SelectionUnit::Line => { + let Some(active) = active_line else { + self.selection = Some(selection); + return; + }; + if forward { + ( + origin.0, + mark.active_edges.map_or(active.1, |edges| edges.1), + ) + } else { + ( + mark.active_edges.map_or(active.0, |edges| edges.0), + origin.1, + ) + } + } + }; + selection.set_keyboard_marked_range(start, end, origin, mark.unit); self.pin_selection_range(cells, &mut selection); self.selection = Some(selection); } @@ -224,7 +378,57 @@ impl TranscriptView { } } - fn anchor_order(&self, cells: &[Arc], anchor: Anchor) -> (usize, usize) { + pub(super) fn before_grapheme( + &mut self, + cells: &[Arc], + anchor: Anchor, + ) -> Anchor { + self.move_select_character(cells, anchor, KeyCode::Left) + .unwrap_or(anchor) + } + + pub(super) fn selection_endpoint_bounds( + &mut self, + cells: &[Arc], + anchor: Anchor, + unit: SelectionUnit, + ) -> Option<(Anchor, Anchor)> { + if unit == SelectionUnit::Character { + return Some((anchor, self.after_grapheme(cells, anchor))); + } + self.selection_bounds(cells, anchor, unit) + } + + fn selection_bounds( + &mut self, + cells: &[Arc], + anchor: Anchor, + unit: SelectionUnit, + ) -> Option<(Anchor, Anchor)> { + let index = self.resolve(cells, anchor); + let layout = self.layout(cells, index)?; + let range = unit.range(layout.text(), anchor.offset); + Some(( + Anchor { + key: self.entry_key(cells, index), + index, + offset: range.start, + row_bias: 0, + }, + Anchor { + key: self.entry_key(cells, index), + index, + offset: range.end, + row_bias: 0, + }, + )) + } + + pub(super) fn anchor_order( + &self, + cells: &[Arc], + anchor: Anchor, + ) -> (usize, usize) { (self.resolve(cells, anchor), anchor.offset) } } 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 61eed90e33..a2dc9202f6 100644 --- a/codex-rs/tui/src/transcript_view/select_mode_tests.rs +++ b/codex-rs/tui/src/transcript_view/select_mode_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::clipboard_copy::CopyStatus; use crate::history_cell::PlainHistoryCell; use pretty_assertions::assert_eq; use ratatui::text::Line; @@ -104,6 +105,57 @@ fn character_motion_crosses_line_and_entry_boundaries_without_invisible_stops() press(&mut view, &cells, KeyCode::Left); press(&mut view, &cells, KeyCode::Char('v')); assert_eq!(view.selected_text(&cells).as_deref(), Some("0")); + + let cells = vec![cell(&["ab"]), cell(&["cd"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + let start = Anchor { + key: EntryKey::cell(&cells[0]), + index: 0, + offset: 0, + row_bias: 0, + }; + let end = Anchor { + key: EntryKey::cell(&cells[1]), + index: 1, + offset: 0, + row_bias: 0, + }; + let mut selection = Selection::keyboard(view.capture_snapshot(&cells), start); + selection.set_keyboard_range(start, end); + view.selection = Some(selection); + assert!(view.begin_select_mode(&cells)); + assert_eq!( + view.select_mode + .and_then(|mode| mode.mark) + .expect("marked range") + .active, + Anchor { offset: 1, ..start } + ); + assert_eq!(view.selected_text(&cells).as_deref(), Some("ab\n")); + press(&mut view, &cells, KeyCode::Char('o')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("ab\n")); + press(&mut view, &cells, KeyCode::Char('l')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("b")); + + let cells = vec![cell(&["abc"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + let start = Anchor { + key: EntryKey::cell(&cells[0]), + index: 0, + offset: 1, + row_bias: 0, + }; + let end = Anchor { offset: 3, ..start }; + let mut selection = Selection::keyboard(view.capture_snapshot(&cells), start); + selection.set_keyboard_range(start, end); + view.selection = Some(selection); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('h')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("b")); + press(&mut view, &cells, KeyCode::Char('h')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("ab")); } #[test] @@ -146,3 +198,156 @@ fn enter_without_a_mark_exits_but_enter_with_a_mark_copies_and_follows() { Some(ViewAction::CopyAndFollow(text)) if text == "c" )); } + +#[test] +fn word_motion_crosses_entries_and_word_end_selects_the_destination_word() { + let cells = vec![cell(&["alpha"]), cell(&["beta gamma"])]; + 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('w')); + press(&mut view, &cells, KeyCode::Char('v')); + press(&mut view, &cells, KeyCode::Char('e')); + + assert_eq!(view.selected_text(&cells).as_deref(), Some("beta")); +} + +#[test] +fn reverse_line_selection_copies_complete_lines_in_document_order() { + 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('j')); + press(&mut view, &cells, KeyCode::Char('V')); + press(&mut view, &cells, KeyCode::Char('k')); + + assert_eq!(view.selected_text(&cells).as_deref(), Some("second\nthird")); +} + +#[test] +fn logical_line_end_handles_empty_lines_and_shifted_dollar() { + let cells = vec![cell(&["abc", "", "z"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('g')); + assert!(matches!( + view.handle_key( + KeyEvent::new(KeyCode::Char('$'), KeyModifiers::SHIFT), + &cells, + ), + Some(ViewAction::Changed) + )); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("c")); + + press(&mut view, &cells, KeyCode::Esc); + press(&mut view, &cells, KeyCode::Char('j')); + press(&mut view, &cells, KeyCode::End); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells), None); + + let cells = vec![cell(&["abc", ""])]; + 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::End); +} + +#[test] +fn configured_pager_page_motion_moves_the_select_cursor() { + let cells = vec![Arc::new(PlainHistoryCell::new( + (0..12).map(|row| format!("line {row}").into()).collect(), + )) as Arc]; + let config = serde_json::from_value(serde_json::json!({ + "pager": {"page_down": "x"} + })) + .expect("keymap config"); + let keymap = crate::keymap::RuntimeKeymap::from_config(&config).expect("configured page down"); + let mut view = TranscriptView::default(); + view.set_keymap_bindings(&keymap); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('g')); + + press(&mut view, &cells, KeyCode::Char('v')); + assert!(view.select_mode.is_some_and(|mode| mode.mark.is_some())); + press(&mut view, &cells, KeyCode::Char('x')); + + let cursor = view + .select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")); + let layout = view.layout(&cells, cursor.index).expect("cursor layout"); + assert_eq!( + layout + .row_for_offset(cursor.offset) + .saturating_add_signed(cursor.row_bias.saturating_neg()), + 5 + ); +} + +#[test] +fn transcript_end_character_selection_includes_the_last_grapheme() { + let cells = vec![cell(&["start"]), cell(&["tail🙂"])]; + 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('v')); + + assert_eq!(view.selected_text(&cells).as_deref(), Some("🙂")); +} + +#[test] +fn swapping_the_active_end_preserves_text_and_moves_the_shrinking_endpoint() { + 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('v')); + press(&mut view, &cells, KeyCode::Char('o')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("a")); + press(&mut view, &cells, KeyCode::Char('o')); + for _ in 0..3 { + press(&mut view, &cells, KeyCode::Char('l')); + } + assert_eq!(view.selected_text(&cells).as_deref(), Some("abcd")); + press(&mut view, &cells, KeyCode::Char('o')); + press(&mut view, &cells, KeyCode::Char('l')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("bcd")); +} + +#[test] +fn confirmed_copy_keeps_the_select_mode_caret_on_the_active_grapheme() { + let cells = vec![cell(&["abc"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('v')); + press(&mut view, &cells, KeyCode::Char('l')); + let Some(ViewAction::Copy(text)) = view.handle_key(KeyCode::Char('y').into(), &cells) else { + panic!("copy key should copy the marked range"); + }; + assert_eq!(text, "ab"); + assert_eq!( + view.copy_selected_text_with( + &cells, + &text, + /*clear_selection*/ true, + |_, _| { Ok(CopyStatus::Confirmed) } + ), + Ok(CopyStatus::Confirmed) + ); + assert!(view.select_mode.is_some()); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("b")); +} diff --git a/codex-rs/tui/src/transcript_view/selection.rs b/codex-rs/tui/src/transcript_view/selection.rs index 47ee87dd94..9cd9793034 100644 --- a/codex-rs/tui/src/transcript_view/selection.rs +++ b/codex-rs/tui/src/transcript_view/selection.rs @@ -83,6 +83,10 @@ impl Selection { self.origin = origin; self.unit = unit; } + + pub(super) fn unit(&self) -> SelectionUnit { + self.unit + } } impl TranscriptView {