From 31434401fa3cd111866acdc1bba2a73b561f53d5 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 13 Oct 2026 15:01:21 +0000 Subject: [PATCH] Add keyboard selection support to the transcript view (#53693) ## What changed Add transcript select-mode state with an underlined cursor, grapheme-aware movement via arrow keys or `h`/`j`/`k`/`l`, and vertical movement that preserves the preferred display column. Support marking with `v`, Space, or Shift-arrow keys and copying through the existing selection machinery. Keep the displayed transcript snapshot and reading position paused during selection and after exiting the mode. Add entry and exit methods; this change does not yet bind mode entry to a user-facing key. ## Testing Add unit tests for initial cursor placement, movement across empty lines and entry boundaries, wrapped text, preferred-column preservation, and Enter behavior with and without a marked range. GitOrigin-RevId: e5bc380ea0eb6e2c6157ba73ac063d548a7de145 --- codex-rs/tui/src/transcript_view.rs | 14 +- codex-rs/tui/src/transcript_view/input.rs | 13 + .../tui/src/transcript_view/select_mode.rs | 266 ++++++++++++++++++ .../src/transcript_view/select_mode/motion.rs | 235 ++++++++++++++++ .../src/transcript_view/select_mode_tests.rs | 148 ++++++++++ codex-rs/tui/src/transcript_view/selection.rs | 65 ++++- codex-rs/tui/src/transcript_view/text.rs | 54 +++- 7 files changed, 789 insertions(+), 6 deletions(-) create mode 100644 codex-rs/tui/src/transcript_view/select_mode.rs create mode 100644 codex-rs/tui/src/transcript_view/select_mode/motion.rs create mode 100644 codex-rs/tui/src/transcript_view/select_mode_tests.rs diff --git a/codex-rs/tui/src/transcript_view.rs b/codex-rs/tui/src/transcript_view.rs index 9a5d1a62e4..4a2b803537 100644 --- a/codex-rs/tui/src/transcript_view.rs +++ b/codex-rs/tui/src/transcript_view.rs @@ -17,6 +17,7 @@ mod layout; mod mutations; mod prompt_header; mod search; +mod select_mode; mod selection; mod snapshot; mod text; @@ -38,6 +39,7 @@ use ratatui::widgets::Widget; use layout::LayoutCache; use search::Search; +use select_mode::SelectMode; use selection::Selection; use snapshot::ViewSnapshot; use text::TextLayout; @@ -126,6 +128,7 @@ pub(crate) struct TranscriptView { suppressed_prompt_header: Option, visible: Vec, selection: Option, + select_mode: Option, copy_mode: Option, held_reading: Option, search: Search, @@ -164,6 +167,7 @@ impl Default for TranscriptView { suppressed_prompt_header: None, visible: Vec::new(), selection: None, + select_mode: None, copy_mode: None, held_reading: None, search: Search::default(), @@ -313,6 +317,7 @@ impl TranscriptView { self.unseen_activity = false; } self.render_selection(buf); + self.render_select_cursor(buf); if self.history == TranscriptHistoryState::LoadingBeginning && self.is_following() { self.scroll(current_cells, /*rows*/ 0); } @@ -380,6 +385,7 @@ impl TranscriptView { return; } self.selection = None; + self.select_mode = None; self.copy_mode = None; self.release_live_reading(); self.cache.clear(); @@ -397,7 +403,10 @@ impl TranscriptView { } pub(crate) fn has_active_interaction(&self) -> bool { - self.selection.is_some() || self.search.is_active() || self.is_activity_focused() + self.select_mode.is_some() + || self.selection.is_some() + || self.search.is_active() + || self.is_activity_focused() } pub(crate) fn is_detailed(&self) -> bool { @@ -405,7 +414,7 @@ impl TranscriptView { } pub(crate) fn is_following(&self) -> bool { - self.selection.is_none() && self.position == Position::Latest + self.select_mode.is_none() && self.selection.is_none() && self.position == Position::Latest } /// Hold the current reading position until every older page has arrived. @@ -426,6 +435,7 @@ impl TranscriptView { self.cancel_beginning(); self.position = Position::Latest; self.selection = None; + self.select_mode = None; self.copy_mode = None; self.release_live_reading(); self.unseen_activity = false; diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs index bf3a479bb1..f5bf119402 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -20,6 +20,7 @@ use super::*; pub(crate) enum ViewAction { Changed, + ExitSelectMode, Copy(String), CopyOnSelect(String), PrimarySelection(String), @@ -96,6 +97,7 @@ impl TranscriptView { clear_selection: self.copy_on_select_clear_selection, }, ViewAction::Changed + | ViewAction::ExitSelectMode | ViewAction::PrimarySelection(_) | ViewAction::Copy(_) | ViewAction::CopyAndFollow(_) @@ -159,6 +161,9 @@ impl TranscriptView { if self.copy_mode.is_some() { return true; } + if self.select_mode.is_some() { + return true; + } if self.selection.is_some() { return crate::text_selection::is_copy_key(key) || code == KeyCode::Esc @@ -192,6 +197,11 @@ impl TranscriptView { if let Some(action) = self.handle_copy_mode_key(key, cells) { return Some(action); } + if self.select_mode.is_some() { + return self + .handle_select_mode_key(key, cells) + .or(Some(ViewAction::Changed)); + } if let Some(action) = self.handle_disclosure_key(key, cells) { return Some(action); } @@ -221,6 +231,9 @@ impl TranscriptView { event: MouseEvent, cells: &[Arc], ) -> Option { + if self.select_mode.is_some() { + return self.handle_selection_mouse(event, cells); + } self.handle_mouse_with_mode(event, cells, MouseMode::Interactive) } diff --git a/codex-rs/tui/src/transcript_view/select_mode.rs b/codex-rs/tui/src/transcript_view/select_mode.rs new file mode 100644 index 0000000000..562a812b3b --- /dev/null +++ b/codex-rs/tui/src/transcript_view/select_mode.rs @@ -0,0 +1,266 @@ +//! Keyboard-owned transcript cursor and mark state over the existing selection snapshot. +//! +//! An empty `Selection` is the caret. Marking only records its fixed and active anchors here; +//! copying, retained revisions, serialization, and asynchronous completion remain owned by +//! `Selection`. + +use super::*; +use crate::text_selection::SelectionUnit; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; + +mod motion; + +#[derive(Clone, Copy, Default)] +pub(super) struct SelectMode { + pub(super) mark: Option, + pub(super) preferred_column: Option, +} + +#[derive(Clone, Copy)] +pub(super) struct Mark { + anchor: Anchor, + active: Anchor, +} + +#[derive(Clone, Copy)] +enum CursorMotion { + Character(KeyCode), + Rows(isize), +} + +impl SelectMode { + pub(super) fn cursor(self, selection: &Selection) -> Anchor { + self.mark.map_or(selection.end, |mark| mark.active) + } +} + +impl TranscriptView { + /// Enter at 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; + } + self.copy_mode = None; + self.cancel_beginning(); + if self.selection.is_none() { + let cursor = self + .visible + .iter() + .rev() + .find_map(|visible| { + let offset = visible.layout.first_cursor_position(visible.row)?; + Some(Anchor { + key: visible.key, + index: visible.index, + offset, + row_bias: visible.layout.row_for_offset(offset) as isize + - visible.row as isize, + }) + }) + .or_else(|| self.select_boundary(cells, /*end*/ true)); + let Some(cursor) = cursor else { + return false; + }; + let snapshot = self.capture_snapshot(cells); + self.selection = Some(Selection::keyboard(snapshot, cursor)); + } + if self.position == Position::Latest { + self.hold_position(); + } + self.select_mode = Some(SelectMode::default()); + self.reveal_select_cursor(cells); + true + } + + /// 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() { + return; + } + self.cancel_beginning(); + if self.position == Position::Latest { + self.hold_position(); + } + if let Some(selection) = self.selection.take() { + self.held_reading = Some(selection.snapshot); + } + } + + pub(crate) fn handle_select_mode_key( + &mut self, + key: KeyEvent, + cells: &[Arc], + ) -> Option { + if self.select_mode.is_none() || key.kind == KeyEventKind::Release { + return None; + } + let (code, modifiers) = crate::key_hint::normalize_key_parts(key.code, key.modifiers); + let pressed = key.kind == KeyEventKind::Press; + if crate::text_selection::is_copy_key(key) + || (code == KeyCode::Char('y') && modifiers == KeyModifiers::NONE) + || (code == KeyCode::Enter + && modifiers == KeyModifiers::NONE + && key.kind == KeyEventKind::Press) + { + let Some(text) = self.selected_text(cells).filter(|text| !text.is_empty()) else { + if code == KeyCode::Enter { + return Some(self.exit_select_mode()); + } + return Some(ViewAction::Changed); + }; + return Some(if code == KeyCode::Enter { + ViewAction::CopyAndFollow(text) + } else { + ViewAction::Copy(text) + }); + } + if pressed && code == KeyCode::Esc && modifiers == KeyModifiers::NONE { + if self.select_mode.is_some_and(|mode| mode.mark.is_some()) { + self.clear_select_mark(); + return Some(ViewAction::Changed); + } + return Some(self.exit_select_mode()); + } + if pressed && code == KeyCode::Char('q') && modifiers == KeyModifiers::NONE { + return Some(self.exit_select_mode()); + } + if pressed + && matches!(code, KeyCode::Char('v') | KeyCode::Char(' ')) + && modifiers == KeyModifiers::NONE + { + self.start_select_mark(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); + } + let motion = match (code, modifiers) { + ( + KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down, + KeyModifiers::NONE | KeyModifiers::SHIFT, + ) => { + if matches!(code, KeyCode::Up | KeyCode::Down) { + CursorMotion::Rows(if code == KeyCode::Up { -1 } else { 1 }) + } else { + CursorMotion::Character(code) + } + } + (KeyCode::Char('h'), KeyModifiers::NONE) => CursorMotion::Character(KeyCode::Left), + (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), + _ => return None, + }; + self.move_select_cursor(cells, motion); + Some(ViewAction::Changed) + } + + fn exit_select_mode(&mut self) -> ViewAction { + self.end_select_mode(); + ViewAction::ExitSelectMode + } + + fn start_select_mark(&mut self, cells: &[Arc]) { + let Some(mut mode) = self.select_mode else { + return; + }; + let Some(selection) = self.selection.as_ref() else { + return; + }; + let cursor = mode.cursor(selection); + mode.mark = match mode.mark { + Some(_) => { + self.clear_select_mark(); + return; + } + None => Some(Mark { + anchor: cursor, + active: cursor, + }), + }; + mode.preferred_column = None; + self.select_mode = Some(mode); + self.sync_select_range(cells); + } + + fn clear_select_mark(&mut self) { + let Some(mode) = &mut self.select_mode else { + return; + }; + let Some(selection) = &mut self.selection else { + return; + }; + let cursor = mode.cursor(selection); + mode.mark = None; + mode.preferred_column = None; + selection.set_keyboard_range(cursor, cursor); + } + + fn reveal_select_cursor(&mut self, cells: &[Arc]) { + let Some(mode) = self.select_mode else { + return; + }; + let Some(selection) = self.selection.as_ref() else { + return; + }; + let cursor = mode.cursor(selection); + let index = self.resolve(cells, cursor); + let row = self.layout(cells, index).map_or(/*default*/ 0, |layout| { + layout + .row_for_offset(cursor.offset) + .saturating_add_signed(cursor.row_bias.saturating_neg()) + }); + if !self + .visible + .iter() + .any(|visible| visible.index == index && visible.row == row) + { + self.position = Position::Reading(Anchor { index, ..cursor }); + } + } + + pub(super) fn render_select_cursor(&self, buf: &mut Buffer) { + let (Some(mode), Some(selection)) = (self.select_mode, self.selection.as_ref()) else { + return; + }; + let cursor = mode.cursor(selection); + let Some((y, visible)) = self.visible.iter().enumerate().find(|(_, visible)| { + visible.key == cursor.key + && visible.row + == visible + .layout + .row_for_offset(cursor.offset) + .saturating_add_signed(cursor.row_bias.saturating_neg()) + }) else { + return; + }; + let column = visible + .layout + .column_for_offset_on_row(visible.row, cursor.offset); + if column < self.area.width { + let area = Rect::new( + self.area.x + column, + self.area.y + y as u16, + /*width*/ 1, + /*height*/ 1, + ); + buf.set_style(area, crate::style::selection_style().underlined()); + } + } +} + +#[cfg(test)] +#[path = "select_mode_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/transcript_view/select_mode/motion.rs b/codex-rs/tui/src/transcript_view/select_mode/motion.rs new file mode 100644 index 0000000000..d9198b9348 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/select_mode/motion.rs @@ -0,0 +1,235 @@ +//! Basic cursor motions and marked-range derivation for keyboard transcript selection. + +use super::*; +use crossterm::event::KeyCode; +use unicode_segmentation::UnicodeSegmentation; + +impl TranscriptView { + pub(super) fn move_select_cursor( + &mut self, + cells: &[Arc], + motion: CursorMotion, + ) { + let Some(mut mode) = self.select_mode else { + return; + }; + self.cancel_beginning(); + let Some(selection) = self.selection.as_ref() else { + return; + }; + let cursor = mode.cursor(selection); + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or(cells); + let next = match motion { + CursorMotion::Character(code) => { + mode.preferred_column = None; + self.move_select_character(cells, cursor, code) + } + CursorMotion::Rows(rows) => { + let index = self.resolve(cells, cursor); + let layout = self.layout(cells, index); + let column = mode.preferred_column.or_else(|| { + layout.as_ref().map(|layout| { + let row = layout + .row_for_offset(cursor.offset) + .saturating_add_signed(cursor.row_bias.saturating_neg()); + layout.column_for_offset_on_row(row, cursor.offset) + }) + }); + mode.preferred_column = column; + self.move_select_rows(cells, cursor, rows, column) + } + }; + let Some(next) = next else { + self.select_mode = Some(mode); + return; + }; + if let Some(mark) = &mut mode.mark { + mark.active = next; + } else if let Some(selection) = &mut self.selection { + selection.set_keyboard_range(next, next); + } + self.select_mode = Some(mode); + self.sync_select_range(cells); + self.reveal_select_cursor(cells); + } + + fn move_select_character( + &mut self, + cells: &[Arc], + cursor: Anchor, + code: KeyCode, + ) -> Option { + let mut index = self.resolve(cells, cursor); + let layout = self.layout(cells, index)?; + let offset = layout + .text() + .floor_char_boundary(cursor.offset.min(layout.text().len())); + let local = match code { + KeyCode::Left if offset > 0 => selectable_graphemes(&layout.text()[..offset]) + .rfind(|(offset, _)| layout.is_cursor_position(*offset)) + .map(|(offset, _)| offset), + KeyCode::Right if offset < layout.text().len() => { + selectable_graphemes(&layout.text()[offset..]) + .filter(|(next, _)| layout.is_cursor_position(offset + *next)) + .find(|(next, _)| cursor.row_bias != 0 || *next > 0) + .map(|(next, _)| offset + next) + } + _ => None, + }; + if let Some(offset) = local { + return Some(Anchor { + key: self.entry_key(cells, index), + index, + offset, + row_bias: 0, + }); + } + loop { + index = match code { + KeyCode::Left => index.checked_sub(1)?, + KeyCode::Right => index + 1, + _ => return None, + }; + let next = self.layout(cells, index)?; + let offset = match code { + KeyCode::Left => selectable_graphemes(next.text()) + .rfind(|(offset, _)| next.is_cursor_position(*offset)) + .map(|(offset, _)| offset), + KeyCode::Right => selectable_graphemes(next.text()) + .find(|(offset, _)| next.is_cursor_position(*offset)) + .map(|(offset, _)| offset), + _ => None, + }; + if let Some(offset) = offset { + return Some(Anchor { + key: self.entry_key(cells, index), + index, + offset, + row_bias: 0, + }); + } + } + } + + fn move_select_rows( + &mut self, + cells: &[Arc], + mut cursor: Anchor, + rows: isize, + column: Option, + ) -> Option { + let code = if rows < 0 { KeyCode::Up } else { KeyCode::Down }; + for _ in 0..rows.unsigned_abs() { + let Some(mut next) = self.move_selection_endpoint(cells, cursor, code, column) else { + break; + }; + let index = self.resolve(cells, next); + if let Some(layout) = self.layout(cells, index) { + let row = layout + .row_for_offset(next.offset) + .saturating_add_signed(next.row_bias.saturating_neg()); + next.offset = layout.cursor_position_at(row, column.unwrap_or_default()); + next.row_bias = layout.row_for_offset(next.offset) as isize - row as isize; + } + if next == cursor { + break; + } + cursor = next; + } + Some(cursor) + } + + pub(super) fn select_boundary( + &mut self, + cells: &[Arc], + end: bool, + ) -> Option { + let count = cells.len() + usize::from(self.layout(cells, cells.len()).is_some()); + let mut index = if end { count.checked_sub(1)? } else { 0 }; + loop { + let layout = self.layout(cells, index)?; + let offset = if end { + selectable_graphemes(layout.text()) + .rfind(|(offset, _)| layout.is_cursor_position(*offset)) + .map(|(offset, _)| offset) + } else { + selectable_graphemes(layout.text()) + .find(|(offset, _)| layout.is_cursor_position(*offset)) + .map(|(offset, _)| offset) + }; + if let Some(offset) = offset { + return Some(Anchor { + key: self.entry_key(cells, index), + index, + offset, + row_bias: 0, + }); + } + if end { + index = index.checked_sub(1)?; + } else { + index += 1; + if index >= count { + return None; + } + } + } + } + + pub(super) fn sync_select_range(&mut self, cells: &[Arc]) { + let Some(mark) = self.select_mode.and_then(|mode| mode.mark) else { + return; + }; + let snapshot = self.snapshot_cells(); + 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 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, + ); + self.pin_selection_range(cells, &mut selection); + self.selection = Some(selection); + } + + fn after_grapheme(&mut self, cells: &[Arc], anchor: Anchor) -> Anchor { + let index = self.resolve(cells, anchor); + let Some(layout) = self.layout(cells, index) else { + return anchor; + }; + let offset = layout + .text() + .floor_char_boundary(anchor.offset.min(layout.text().len())); + let row = layout + .row_for_offset(offset) + .saturating_add_signed(anchor.row_bias.saturating_neg()); + let offset = layout.after_cursor_grapheme(row, offset); + Anchor { + key: self.entry_key(cells, index), + index, + offset, + row_bias: 0, + } + } + + fn anchor_order(&self, cells: &[Arc], anchor: Anchor) -> (usize, usize) { + (self.resolve(cells, anchor), anchor.offset) + } +} + +fn selectable_graphemes(text: &str) -> impl DoubleEndedIterator { + text.grapheme_indices(/*is_extended*/ true) + .filter(|(_, grapheme)| !grapheme.contains('\n')) +} diff --git a/codex-rs/tui/src/transcript_view/select_mode_tests.rs b/codex-rs/tui/src/transcript_view/select_mode_tests.rs new file mode 100644 index 0000000000..61eed90e33 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/select_mode_tests.rs @@ -0,0 +1,148 @@ +use super::*; +use crate::history_cell::PlainHistoryCell; +use pretty_assertions::assert_eq; +use ratatui::text::Line; + +fn cell(lines: &[&str]) -> Arc { + Arc::new(PlainHistoryCell::new( + lines + .iter() + .map(|line| Line::from((*line).to_owned())) + .collect(), + )) +} + +fn render(view: &mut TranscriptView, cells: &[Arc]) -> Buffer { + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 6, + ); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer, cells); + buffer +} + +fn press(view: &mut TranscriptView, cells: &[Arc], code: KeyCode) { + assert!(matches!( + view.handle_key(KeyEvent::new(code, KeyModifiers::NONE), cells), + Some(ViewAction::Changed) + )); +} + +#[test] +fn entry_starts_on_the_last_selectable_visible_line() { + for (cells, expected) in [ + (vec![cell(&["top"]), cell(&["bottom"])], "b"), + (vec![cell(&["text"]), cell(&[""])], "t"), + (vec![cell(&["abc", "", "", "", "", "", ""])], "c"), + ] { + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells).as_deref(), Some(expected)); + } + + let cells = vec![cell(&["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + view.scroll(&cells, /*rows*/ -2); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("h")); + + let cells = vec![cell(&["a", "", "", "", "", "", "", "", "", ""])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + assert!(matches!(view.position, Position::Reading(_))); + + let cells = vec![cell(&[""])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(!view.begin_select_mode(&cells)); +} + +#[test] +fn character_motion_crosses_line_and_entry_boundaries_without_invisible_stops() { + let cells = vec![cell(&["a", ""]), cell(&[""]), cell(&["", "b"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Left); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("a")); + + press(&mut view, &cells, KeyCode::Esc); + press(&mut view, &cells, KeyCode::Right); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("b")); + + let cells = vec![cell(&["ab"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + let separator = Anchor { + key: EntryKey::cell(&cells[0]), + index: 0, + offset: 0, + row_bias: 1, + }; + view.selection + .as_mut() + .expect("selection") + .set_keyboard_range(separator, separator); + press(&mut view, &cells, KeyCode::Right); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("a")); + + let wrapped = format!("{} one", "1234567890".repeat(3)); + let cells = vec![cell(&[&wrapped])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Left); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("0")); +} + +#[test] +fn vertical_motion_keeps_the_preferred_display_column() { + let cells = vec![cell(&["abcdef", "x", "abcdef"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + for _ in 0..3 { + press(&mut view, &cells, KeyCode::Right); + } + press(&mut view, &cells, KeyCode::Up); + press(&mut view, &cells, KeyCode::Up); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells).as_deref(), Some("d")); +} + +#[test] +fn enter_without_a_mark_exits_but_enter_with_a_mark_copies_and_follows() { + let cells = vec![cell(&["copy me"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + assert!(matches!( + view.handle_key(KeyCode::Enter.into(), &cells), + Some(ViewAction::ExitSelectMode) + )); + + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('v')); + assert!(matches!( + view.handle_key( + KeyEvent::new_with_kind(KeyCode::Char('v'), KeyModifiers::NONE, KeyEventKind::Repeat,), + &cells, + ), + Some(ViewAction::Changed) + )); + assert!(matches!( + view.handle_key(KeyCode::Enter.into(), &cells), + Some(ViewAction::CopyAndFollow(text)) if text == "c" + )); +} diff --git a/codex-rs/tui/src/transcript_view/selection.rs b/codex-rs/tui/src/transcript_view/selection.rs index 9d3c9d4852..b6cae02895 100644 --- a/codex-rs/tui/src/transcript_view/selection.rs +++ b/codex-rs/tui/src/transcript_view/selection.rs @@ -41,6 +41,51 @@ pub(super) struct Selection { preferred_column: Option, } +impl Selection { + pub(super) fn keyboard(snapshot: ViewSnapshot, cursor: Anchor) -> Self { + Self { + pending_copy: None, + primary_owner: None, + snapshot, + start: cursor, + end: cursor, + dragging: false, + moved: true, + moved_vertically: false, + pointer_origin_row: 0, + resume_on_empty: false, + pointer: None, + pressed_link: None, + origin: (cursor, cursor), + unit: SelectionUnit::Character, + preferred_column: None, + } + } + + pub(super) fn set_keyboard_range(&mut self, start: Anchor, end: Anchor) { + if (self.start, self.end) != (start, end) { + self.primary_owner = None; + self.pending_copy = None; + } + self.start = start; + self.end = end; + self.origin = (start, end); + self.preferred_column = None; + } + + pub(super) fn set_keyboard_marked_range( + &mut self, + start: Anchor, + end: Anchor, + origin: (Anchor, Anchor), + unit: SelectionUnit, + ) { + self.set_keyboard_range(start, end); + self.origin = origin; + self.unit = unit; + } +} + impl TranscriptView { pub(super) fn select_copy_range( &mut self, @@ -139,6 +184,14 @@ impl TranscriptView { let Some(selection) = self.selection.take() else { return; }; + if let Some(mode) = &mut self.select_mode { + let cursor = mode.cursor(&selection); + mode.mark = None; + let mut selection = selection; + selection.set_keyboard_range(cursor, cursor); + self.selection = Some(selection); + return; + } let empty = (selection.start.key, selection.start.offset) == (selection.end.key, selection.end.offset); if empty && selection.resume_on_empty { @@ -394,7 +447,9 @@ impl TranscriptView { self.publish_primary(tui, text); return None; } - ViewAction::Changed | ViewAction::OpenLink(_) => return None, + ViewAction::Changed | ViewAction::ExitSelectMode | ViewAction::OpenLink(_) => { + return None; + } }; let selection_copy = self.selection_copy(action); let clear_selection = selection_copy.clear_selection(); @@ -695,7 +750,11 @@ impl TranscriptView { } } - fn pin_selection_range(&mut self, cells: &[Arc], selection: &mut Selection) { + pub(super) fn pin_selection_range( + &mut self, + cells: &[Arc], + selection: &mut Selection, + ) { selection.start.index = self.resolve(cells, selection.start); selection.end.index = self.resolve(cells, selection.end); for index in selection.start.index.min(selection.end.index) @@ -710,7 +769,7 @@ impl TranscriptView { } } - fn move_selection_endpoint( + pub(super) fn move_selection_endpoint( &mut self, cells: &[Arc], end: Anchor, diff --git a/codex-rs/tui/src/transcript_view/text.rs b/codex-rs/tui/src/transcript_view/text.rs index 282925dfd4..4aac01ddda 100644 --- a/codex-rs/tui/src/transcript_view/text.rs +++ b/codex-rs/tui/src/transcript_view/text.rs @@ -314,6 +314,53 @@ impl TextLayout { row.source.end } + /// Resolve a keyboard caret to a grapheme start while leaving truly empty rows blank. + pub(super) fn cursor_position_at(&self, row: usize, column: u16) -> usize { + let offset = self.position_at(row, column); + let Some(row) = self.rows.get(row) else { + return offset; + }; + if row.source.start == row.source.end || offset < row.source.end { + return offset; + } + self.text[row.source.clone()] + .grapheme_indices(/*is_extended*/ true) + .next_back() + .map_or(row.source.start, |(offset, _)| row.source.start + offset) + } + + /// Return the first selectable source grapheme on a display row, excluding synthetic rows. + pub(super) fn first_cursor_position(&self, row: usize) -> Option { + let row = self.rows.get(row)?; + self.text[row.source.clone()] + .grapheme_indices(/*is_extended*/ true) + .next() + .map(|(offset, _)| row.source.start + offset) + } + + /// Whether a source grapheme is actually painted on a display row. + pub(super) fn is_cursor_position(&self, offset: usize) -> bool { + self.rows.iter().any(|row| row.source.contains(&offset)) + } + + /// Advance one grapheme inside the caret's display row without crossing an empty row. + pub(super) fn after_cursor_grapheme(&self, row: usize, offset: usize) -> usize { + let Some(row) = self.rows.get(row) else { + return offset; + }; + if row.source.start == row.source.end { + return offset; + } + let offset = self + .text + .floor_char_boundary(offset.clamp(row.source.start, row.source.end)); + offset + + self.text[offset..row.source.end] + .graphemes(/*is_extended*/ true) + .next() + .map_or(/*default*/ 0, str::len) + } + /// Find the display row containing a source position, including omitted wrapping whitespace. pub(super) fn row_for_offset(&self, offset: usize) -> usize { let before_control = if let Some(control_row) = self.disclosure_row() { @@ -332,7 +379,12 @@ impl TextLayout { } pub(super) fn column_for_offset(&self, offset: usize) -> u16 { - let Some(row) = self.rows.get(self.row_for_offset(offset)) else { + self.column_for_offset_on_row(self.row_for_offset(offset), offset) + } + + /// Resolve a source position within a specific display row when offsets are shared. + pub(super) fn column_for_offset_on_row(&self, row: usize, offset: usize) -> u16 { + let Some(row) = self.rows.get(row) else { return 0; }; let offset = self