From 09f5e9d51a158f39b2e7db879d81e345067399f6 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 16:01:13 +0900 Subject: [PATCH] feat: add single-pod text selection --- Cargo.lock | 1 + crates/tui/Cargo.toml | 1 + crates/tui/src/app.rs | 6 + crates/tui/src/lib.rs | 1 + crates/tui/src/single_pod.rs | 218 ++++++++++++++++-- crates/tui/src/text_selection.rs | 367 +++++++++++++++++++++++++++++++ crates/tui/src/ui.rs | 218 ++++++++++++++++-- 7 files changed, 770 insertions(+), 42 deletions(-) create mode 100644 crates/tui/src/text_selection.rs diff --git a/Cargo.lock b/Cargo.lock index 76baae4e..54fe9d04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3958,6 +3958,7 @@ dependencies = [ name = "tui" version = "0.1.0" dependencies = [ + "base64", "client", "crossterm 0.28.1", "llm-worker", diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 48112528..b035e62d 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -12,6 +12,7 @@ e2e-test = [] client = { workspace = true } protocol = { workspace = true } ratatui = { version = "0.30.0", features = ["scrolling-regions"] } +base64 = "0.22.1" crossterm = "0.28" tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "process"] } serde_json = { workspace = true } diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 25fb1e84..0ee24fa3 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -20,6 +20,7 @@ use crate::composer_history::{ use crate::input::InputBuffer; use crate::scroll::Scroll; use crate::task::TaskStore; +use crate::text_selection::TextSelectionState; use crate::view_mode::Mode; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -293,6 +294,10 @@ pub struct App { /// `[Session TaskStore snapshot]` system messages — no protocol /// surface added on the Pod side. pub task_store: TaskStore, + /// Transient single-Pod transcript text selection. This is viewport-local + /// UI state only; it is never sent to the Pod, persisted, or appended to + /// session history/model context. + pub text_selection: TextSelectionState, /// Whether the right-side task pane is currently open. pub task_pane_open: bool, /// Top entry index of the task pane's visible window. Clamped on @@ -351,6 +356,7 @@ impl App { rewind_refresh_fence: false, greeting: None, task_store: TaskStore::new(), + text_selection: TextSelectionState::default(), task_pane_open: false, task_pane_scroll: 0, queued_inputs: VecDeque::new(), diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 579d5f25..bfd3321d 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -18,6 +18,7 @@ pub mod setup_model; mod single_pod; mod spawn; mod task; +mod text_selection; mod tool; mod ui; mod view_mode; diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index 8a423538..93e16b15 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -10,8 +10,8 @@ use std::thread; use std::time::Duration; use crossterm::event::{ - self, DisableMouseCapture, Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, MouseEvent, - MouseEventKind, + self, DisableMouseCapture, Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, MouseButton, + MouseEvent, MouseEventKind, }; use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::{Command, execute}; @@ -23,6 +23,7 @@ use ratatui::backend::CrosstermBackend; use session_store::SegmentId; use tokio::sync::mpsc; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use client::{PodClient, PodRuntimeCommand}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; @@ -33,20 +34,19 @@ use crate::{multi_pod, picker, spawn, ui}; type FullscreenTerminal = Terminal>; -/// Enable the narrowest standard xterm mouse mode that still reports wheel -/// events. Crossterm's `EnableMouseCapture` also enables button-event -/// tracking (`?1002h`), which requests drag-motion reports and interferes -/// with terminal text selection more aggressively. Normal tracking (`?1000h`) -/// reports button presses, releases, and wheel notches, but does not request -/// drag-motion reports; the TUI ignores the non-wheel events. +/// Enable SGR coordinates plus button-event tracking for Yoi-owned drag text +/// selection in the single-Pod transcript. This intentionally opts out of +/// terminal-native selection while the alternate screen is active, but still +/// avoids all-motion tracking (`?1003h`). #[derive(Debug, Clone, Copy)] -struct EnableWheelMouseCapture; +struct EnableSinglePodMouseCapture; -impl Command for EnableWheelMouseCapture { +impl Command for EnableSinglePodMouseCapture { fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - // 1000: normal mouse tracking (includes wheel button presses) // 1006: SGR extended coordinates used by crossterm's parser - f.write_str("\x1B[?1000h\x1B[?1006h") + // 1000: normal mouse tracking (button presses/releases and wheel) + // 1002: button-event tracking (drag reports while a button is held) + f.write_str("\x1B[?1006h\x1B[?1000h\x1B[?1002h") } #[cfg(windows)] @@ -60,6 +60,45 @@ impl Command for EnableWheelMouseCapture { } } +fn copy_to_terminal_clipboard(out: &mut W, text: &str) -> io::Result<()> { + let encoded = BASE64_STANDARD.encode(text.as_bytes()); + write!(out, "\x1B]52;c;{}\x07", encoded)?; + out.flush() +} + +fn copy_selection_to_writer(app: &mut App, out: &mut W) -> bool { + let Some(text) = app.text_selection.copy_text() else { + return false; + }; + + let result = copy_to_terminal_clipboard(out, &text); + app.text_selection.clear(); + match result { + Ok(()) => { + app.flash_actionbar_notice( + "Copied selected text to terminal clipboard.", + ActionbarNoticeLevel::Info, + ActionbarNoticeSource::Tui, + Duration::from_secs(3), + ); + } + Err(_) => { + app.flash_actionbar_notice( + "Copy failed: terminal clipboard write failed.", + ActionbarNoticeLevel::Error, + ActionbarNoticeSource::Tui, + Duration::from_secs(5), + ); + } + } + true +} + +fn copy_selection_to_terminal(app: &mut App) -> bool { + let mut stdout = io::stdout(); + copy_selection_to_writer(app, &mut stdout) +} + fn resolve_socket(pod_name: &str, override_path: Option) -> PathBuf { if let Some(p) = override_path { return p; @@ -294,10 +333,9 @@ pub(crate) async fn run_spawn( fn enter_fullscreen() -> Result> { let mut stdout = io::stdout(); - // Enable only normal mouse tracking for wheel events. Avoid crossterm's - // full mouse capture because it requests drag-motion events and breaks - // terminal-native text selection. - execute!(stdout, EnterAlternateScreen, EnableWheelMouseCapture)?; + // Enable button-event tracking so the transcript can own drag selection; + // avoid all-motion capture because hover-motion reports are unnecessary. + execute!(stdout, EnterAlternateScreen, EnableSinglePodMouseCapture)?; let backend = CrosstermBackend::new(stdout); Ok(Terminal::new(backend)?) } @@ -310,7 +348,7 @@ fn enter_fullscreen_existing( execute!( terminal.backend_mut(), EnterAlternateScreen, - EnableWheelMouseCapture + EnableSinglePodMouseCapture )?; Ok(()) } @@ -761,8 +799,25 @@ const PANE_SCROLL_LINES: usize = 5; fn handle_mouse(app: &mut App, mouse: MouseEvent) { match mouse.kind { - MouseEventKind::ScrollUp => app.scroll.scroll_up(WHEEL_LINES), - MouseEventKind::ScrollDown => app.scroll.scroll_down(WHEEL_LINES), + MouseEventKind::ScrollUp => { + app.text_selection.clear(); + app.scroll.scroll_up(WHEEL_LINES); + } + MouseEventKind::ScrollDown => { + app.text_selection.clear(); + app.scroll.scroll_down(WHEEL_LINES); + } + MouseEventKind::Down(MouseButton::Left) if app.rewind_picker.is_none() => { + if !app.text_selection.begin_drag(mouse.column, mouse.row) { + app.text_selection.clear(); + } + } + MouseEventKind::Drag(MouseButton::Left) if app.rewind_picker.is_none() => { + app.text_selection.update_drag(mouse.column, mouse.row); + } + MouseEventKind::Up(MouseButton::Left) if app.rewind_picker.is_none() => { + app.text_selection.finish_drag(mouse.column, mouse.row); + } _ => {} } } @@ -983,6 +1038,25 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { } } + if key.modifiers.is_empty() { + match key.code { + KeyCode::Esc if app.text_selection.clear() => return None, + KeyCode::Char('y') if app.text_selection.has_selection() => { + if !copy_selection_to_terminal(app) { + app.text_selection.clear(); + app.flash_actionbar_notice( + "Selection contains no copyable text.", + ActionbarNoticeLevel::Warn, + ActionbarNoticeSource::Tui, + Duration::from_secs(3), + ); + } + return None; + } + _ => {} + } + } + match key.code { KeyCode::Esc => { // Close the popup if it's still showing (covers the @@ -1128,18 +1202,116 @@ fn handle_pause_or_quit(app: &mut App) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::text_selection::{HistoryViewport, SelectionRow}; use protocol::{Event, RewindTarget, RewindTargetId, Segment}; #[test] - fn wheel_mouse_capture_uses_normal_tracking_without_drag_capture() { + fn single_pod_mouse_capture_enables_drag_without_all_motion() { let mut ansi = String::new(); - Command::write_ansi(&EnableWheelMouseCapture, &mut ansi).unwrap(); + Command::write_ansi(&EnableSinglePodMouseCapture, &mut ansi).unwrap(); - assert_eq!(ansi, "\x1B[?1000h\x1B[?1006h"); - assert!(!ansi.contains("?1002h")); + assert!(ansi.contains("?1000h")); + assert!(ansi.contains("?1002h")); + assert!(ansi.contains("?1006h")); assert!(!ansi.contains("?1003h")); } + #[test] + fn mouse_drag_updates_selection_state() { + let mut app = App::new("pod".into()); + app.text_selection.set_history_snapshot( + HistoryViewport { + x: 1, + y: 2, + width: 20, + height: 3, + top_offset: 0, + total_lines: 1, + }, + vec![SelectionRow::new("alpha".into(), true)], + ); + + handle_mouse( + &mut app, + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 2, + row: 2, + modifiers: KeyModifiers::NONE, + }, + ); + handle_mouse( + &mut app, + MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 4, + row: 2, + modifiers: KeyModifiers::NONE, + }, + ); + handle_mouse( + &mut app, + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 4, + row: 2, + modifiers: KeyModifiers::NONE, + }, + ); + + assert_eq!(app.text_selection.copy_text().as_deref(), Some("lph")); + assert!(!app.text_selection.active().unwrap().dragging); + } + + #[test] + fn esc_clears_selection_without_editing_composer() { + let mut app = App::new("pod".into()); + app.text_selection.set_history_snapshot( + HistoryViewport { + x: 0, + y: 0, + width: 10, + height: 1, + top_offset: 0, + total_lines: 1, + }, + vec![SelectionRow::new("hello".into(), true)], + ); + assert!(app.text_selection.begin_drag(0, 0)); + + assert!(handle_key(&mut app, key(KeyCode::Esc)).is_none()); + assert!(!app.text_selection.has_selection()); + assert!(app.input.is_empty()); + } + + #[test] + fn copy_selection_writes_osc52_and_clears_selection() { + let mut app = App::new("pod".into()); + app.text_selection.set_history_snapshot( + HistoryViewport { + x: 0, + y: 0, + width: 10, + height: 1, + top_offset: 0, + total_lines: 1, + }, + vec![SelectionRow::new("hello".into(), true)], + ); + assert!(app.text_selection.begin_drag(0, 0)); + assert!(app.text_selection.update_drag(4, 0)); + + let mut out = Vec::new(); + assert!(copy_selection_to_writer(&mut app, &mut out)); + + assert_eq!(String::from_utf8(out).unwrap(), "\x1B]52;c;aGVsbG8=\x07"); + assert!(!app.text_selection.has_selection()); + assert!( + app.current_actionbar_notice(std::time::Instant::now()) + .is_some() + ); + } + #[tokio::test] async fn terminal_event_is_selected_before_ready_pod_event() { let (tx, mut rx) = mpsc::unbounded_channel(); diff --git a/crates/tui/src/text_selection.rs b/crates/tui/src/text_selection.rs new file mode 100644 index 00000000..3be406a2 --- /dev/null +++ b/crates/tui/src/text_selection.rs @@ -0,0 +1,367 @@ +//! Local, non-persistent text selection state for the single-Pod transcript view. +//! +//! This module deliberately stores only the most recent rendered history rows and +//! the active drag endpoints. Selected/copied text never leaves TUI-local state +//! unless the user explicitly presses the copy key, and even then the caller is +//! responsible for using a non-history clipboard path. + +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SelectionPoint { + pub row: usize, + pub col: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HistoryViewport { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, + pub top_offset: usize, + pub total_lines: usize, +} + +impl HistoryViewport { + pub fn contains(self, col: u16, row: u16) -> bool { + col >= self.x + && col < self.x.saturating_add(self.width) + && row >= self.y + && row < self.y.saturating_add(self.height) + } + + pub fn point_at(self, col: u16, row: u16) -> Option { + if !self.contains(col, row) { + return None; + } + Some(SelectionPoint { + row: self.top_offset + row.saturating_sub(self.y) as usize, + col: col.saturating_sub(self.x) as usize, + }) + } + + pub fn clamped_point_at(self, col: u16, row: u16) -> Option { + if self.width == 0 || self.height == 0 || self.total_lines == 0 { + return None; + } + let max_col = self.x.saturating_add(self.width.saturating_sub(1)); + let max_row = self.y.saturating_add(self.height.saturating_sub(1)); + let col = col.clamp(self.x, max_col); + let row = row.clamp(self.y, max_row); + let absolute_row = self.top_offset + row.saturating_sub(self.y) as usize; + if absolute_row >= self.total_lines { + return None; + } + Some(SelectionPoint { + row: absolute_row, + col: col.saturating_sub(self.x) as usize, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectionRow { + pub text: String, + /// Whether this rendered row belongs to selectable transcript text. Tool + /// calls, thinking blocks, greetings, notices, stats, and other non-text + /// items are intentionally false. + pub selectable: bool, +} + +impl SelectionRow { + pub fn new(text: String, selectable: bool) -> Self { + Self { text, selectable } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ActiveSelection { + pub anchor: SelectionPoint, + pub focus: SelectionPoint, + pub dragging: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct TextSelectionState { + active: Option, + viewport: Option, + rows: Vec, +} + +impl TextSelectionState { + pub fn set_history_snapshot(&mut self, viewport: HistoryViewport, rows: Vec) { + let rows_changed = self.rows != rows; + self.viewport = Some(viewport); + self.rows = rows; + if rows_changed { + self.active = None; + } else { + self.drop_selection_if_stale(); + } + } + + pub fn clear_history_snapshot(&mut self) { + self.viewport = None; + self.rows.clear(); + self.active = None; + } + + #[cfg(test)] + pub fn active(&self) -> Option { + self.active + } + + pub fn has_selection(&self) -> bool { + self.active.is_some() + } + + pub fn clear(&mut self) -> bool { + self.active.take().is_some() + } + + pub fn begin_drag(&mut self, col: u16, row: u16) -> bool { + let Some(point) = self + .viewport + .and_then(|viewport| viewport.point_at(col, row)) + else { + return false; + }; + if !self.row_is_selectable(point.row) { + self.active = None; + return false; + } + self.active = Some(ActiveSelection { + anchor: point, + focus: point, + dragging: true, + }); + true + } + + pub fn update_drag(&mut self, col: u16, row: u16) -> bool { + let Some(mut active) = self.active else { + return false; + }; + if !active.dragging { + return false; + } + let Some(point) = self + .viewport + .and_then(|viewport| viewport.clamped_point_at(col, row)) + else { + return false; + }; + active.focus = point; + self.active = Some(active); + true + } + + pub fn finish_drag(&mut self, col: u16, row: u16) -> bool { + let updated = self.update_drag(col, row); + if let Some(active) = self.active.as_mut() { + active.dragging = false; + true + } else { + updated + } + } + + pub fn copy_text(&self) -> Option { + let active = self.active?; + selected_text_from_rows(&self.rows, active.anchor, active.focus) + } + + pub fn range_for_row(&self, row: usize) -> Option<(usize, usize)> { + let active = self.active?; + let (start, end) = ordered_points(active.anchor, active.focus); + if row < start.row || row > end.row || !self.row_is_selectable(row) { + return None; + } + let row_width = display_width(&self.rows.get(row)?.text); + let (from, to) = if start.row == end.row { + ( + start.col.min(row_width), + end.col.saturating_add(1).min(row_width), + ) + } else if row == start.row { + (start.col.min(row_width), row_width) + } else if row == end.row { + (0, end.col.saturating_add(1).min(row_width)) + } else { + (0, row_width) + }; + if from >= to { None } else { Some((from, to)) } + } + + fn row_is_selectable(&self, row: usize) -> bool { + self.rows.get(row).is_some_and(|row| row.selectable) + } + + fn drop_selection_if_stale(&mut self) { + let Some(active) = self.active else { + return; + }; + if active.anchor.row >= self.rows.len() || active.focus.row >= self.rows.len() { + self.active = None; + } + } +} + +pub fn selected_text_from_rows( + rows: &[SelectionRow], + anchor: SelectionPoint, + focus: SelectionPoint, +) -> Option { + let (start, end) = ordered_points(anchor, focus); + if start.row >= rows.len() || end.row >= rows.len() { + return None; + } + + let mut copied = Vec::new(); + for row_idx in start.row..=end.row { + let row = &rows[row_idx]; + if !row.selectable { + continue; + } + let row_width = display_width(&row.text); + let (from, to) = if start.row == end.row { + ( + start.col.min(row_width), + end.col.saturating_add(1).min(row_width), + ) + } else if row_idx == start.row { + (start.col.min(row_width), row_width) + } else if row_idx == end.row { + (0, end.col.saturating_add(1).min(row_width)) + } else { + (0, row_width) + }; + copied.push(slice_display_cols(&row.text, from, to)); + } + + if copied.iter().any(|line| !line.is_empty()) { + Some(copied.join("\n")) + } else { + None + } +} + +pub fn ordered_points(a: SelectionPoint, b: SelectionPoint) -> (SelectionPoint, SelectionPoint) { + if (a.row, a.col) <= (b.row, b.col) { + (a, b) + } else { + (b, a) + } +} + +pub fn display_width(text: &str) -> usize { + UnicodeWidthStr::width(text) +} + +pub fn slice_display_cols(text: &str, start: usize, end: usize) -> String { + if start >= end { + return String::new(); + } + + let mut out = String::new(); + let mut col = 0usize; + for c in text.chars() { + let width = UnicodeWidthChar::width(c).unwrap_or(0); + let next = col.saturating_add(width); + if next > start && col < end { + out.push(c); + } + col = next; + if col >= end { + break; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coordinate_mapping_uses_history_inner_origin_and_scroll_offset() { + let viewport = HistoryViewport { + x: 2, + y: 3, + width: 10, + height: 5, + top_offset: 7, + total_lines: 20, + }; + + assert_eq!( + viewport.point_at(4, 5), + Some(SelectionPoint { row: 9, col: 2 }) + ); + assert_eq!(viewport.point_at(1, 5), None); + assert_eq!(viewport.point_at(4, 8), None); + } + + #[test] + fn selection_state_drag_update_and_clear() { + let mut state = TextSelectionState::default(); + state.set_history_snapshot( + HistoryViewport { + x: 0, + y: 0, + width: 20, + height: 3, + top_offset: 0, + total_lines: 3, + }, + vec![ + SelectionRow::new("alpha".into(), true), + SelectionRow::new("tool".into(), false), + SelectionRow::new("omega".into(), true), + ], + ); + + assert!(state.begin_drag(1, 0)); + assert!(state.update_drag(2, 2)); + assert_eq!(state.copy_text().as_deref(), Some("lpha\nome")); + assert!(state.finish_drag(2, 2)); + assert!(!state.active().unwrap().dragging); + assert!(state.clear()); + assert!(!state.has_selection()); + } + + #[test] + fn selected_text_preserves_blank_separator_between_text_items() { + let rows = vec![ + SelectionRow::new("first".into(), true), + SelectionRow::new("".into(), true), + SelectionRow::new("second".into(), true), + ]; + + let copied = selected_text_from_rows( + &rows, + SelectionPoint { row: 0, col: 0 }, + SelectionPoint { row: 2, col: 5 }, + ) + .unwrap(); + assert_eq!(copied, "first\n\nsecond"); + } + + #[test] + fn non_text_rows_are_skipped_during_extraction() { + let rows = vec![ + SelectionRow::new("user".into(), true), + SelectionRow::new("tool output".into(), false), + SelectionRow::new("assistant".into(), true), + ]; + + let copied = selected_text_from_rows( + &rows, + SelectionPoint { row: 0, col: 0 }, + SelectionPoint { row: 2, col: 8 }, + ) + .unwrap(); + assert_eq!(copied, "user\nassistant"); + } +} diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index c83306d1..4f05744a 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -31,6 +31,7 @@ use crate::app::{ActionbarNoticeLevel, App, CompletionState, alert_source_label, use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState}; use crate::command::CommandCandidate; use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore}; +use crate::text_selection::{HistoryViewport, SelectionRow}; use crate::view_mode::Mode; pub fn draw(frame: &mut Frame, app: &mut App) { @@ -306,55 +307,90 @@ fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) - needed.clamp(1, cap) } +pub struct HistoryRow { + pub line: Line<'static>, + pub text: String, + pub selectable: bool, +} + +impl HistoryRow { + fn new(line: Line<'static>, selectable: bool) -> Self { + let text = line_text(&line); + Self { + line, + text, + selectable, + } + } + + fn selection_row(&self) -> SelectionRow { + SelectionRow::new(self.text.clone(), self.selectable) + } +} + /// Pre-rendered history lines plus the line indices at which each turn /// begins (used for Ctrl-[/] jumps). pub struct HistoryLayout { - pub lines: Vec>, + pub rows: Vec, pub turn_starts: Vec, } pub fn compute_history(app: &App, width: u16) -> HistoryLayout { // Step 1: collect logical lines from each block (unwrapped). - let mut logical: Vec> = Vec::new(); + let mut logical: Vec<(Line<'static>, bool)> = Vec::new(); let mut logical_turn_starts: Vec = Vec::new(); let mut first = true; + let mut previous_selectable = false; let mut i = 0; while i < app.blocks.len() { + let block = &app.blocks[i]; + let current_selectable = block_is_selectable_text(block); if !first { - logical.push(Line::from("")); + // Preserve a deterministic blank-line separator when copying + // across text-like items. Tool/non-text item rows remain + // unselectable, but separators adjacent to text are copied as + // blank lines so cross-item extraction is stable. + logical.push((Line::from(""), previous_selectable || current_selectable)); } first = false; - let block = &app.blocks[i]; if matches!(block, Block::TurnHeader { .. }) { logical_turn_starts.push(logical.len()); } if matches!(block, Block::ToolCall(_)) { let out = crate::tool::render_tool(&app.cache, &app.blocks, i, width, app.mode); - logical.extend(out.lines); + logical.extend(out.lines.into_iter().map(|line| (line, false))); i += out.consumed.max(1); + previous_selectable = false; continue; } - render_block_into(&mut logical, block, width, app.mode); + let mut block_lines = Vec::new(); + render_block_into(&mut block_lines, block, width, app.mode); + logical.extend( + block_lines + .into_iter() + .map(|line| (line, current_selectable)), + ); + previous_selectable = current_selectable; i += 1; } // Step 2: pre-wrap every logical line to char-based terminal rows so // scroll math is exact. Track the logical → wrapped mapping so // turn-start indices get translated into wrapped-row coordinates. - let mut lines: Vec> = Vec::with_capacity(logical.len()); + let mut rows: Vec = Vec::with_capacity(logical.len()); let mut logical_to_wrapped: Vec = Vec::with_capacity(logical.len() + 1); - for line in logical { - logical_to_wrapped.push(lines.len()); - wrap_line_into(line, width, &mut lines); + for (line, selectable) in logical { + logical_to_wrapped.push(rows.len()); + wrap_history_row_into(line, selectable, width, &mut rows); } - logical_to_wrapped.push(lines.len()); + logical_to_wrapped.push(rows.len()); let turn_starts = logical_turn_starts .into_iter() - .map(|i| logical_to_wrapped.get(i).copied().unwrap_or(lines.len())) + .map(|i| logical_to_wrapped.get(i).copied().unwrap_or(rows.len())) .collect(); - HistoryLayout { lines, turn_starts } + HistoryLayout { rows, turn_starts } } /// Horizontal gutter around the log area. Applied via a @@ -369,6 +405,7 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) { app.scroll.total_lines = 0; app.scroll.tail_top_offset = 0; app.scroll.turn_starts.clear(); + app.text_selection.clear_history_snapshot(); return; } @@ -389,21 +426,23 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) { let outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING)); let inner = outer_block.inner(history_area); if inner.width == 0 || inner.height == 0 { + app.text_selection.clear_history_snapshot(); return; } if let Some(picker) = app.rewind_picker.as_mut() { + app.text_selection.clear_history_snapshot(); draw_rewind_picker(frame, history_area, inner, outer_block, picker); return; } - let HistoryLayout { lines, turn_starts } = compute_history(app, inner.width); + let HistoryLayout { rows, turn_starts } = compute_history(app, inner.width); - // `lines` is already pre-wrapped: 1 entry == 1 terminal row. Scroll + // `rows` is already pre-wrapped: 1 entry == 1 terminal row. Scroll // math degenerates to index arithmetic. - let tail_top = lines.len().saturating_sub(inner.height as usize); + let tail_top = rows.len().saturating_sub(inner.height as usize); app.scroll.area_height = inner.height; - app.scroll.total_lines = lines.len(); + app.scroll.total_lines = rows.len(); app.scroll.tail_top_offset = tail_top; app.scroll.turn_starts = turn_starts; @@ -413,8 +452,30 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) { app.scroll.top_offset = app.scroll.top_offset.min(tail_top); } - let end = (app.scroll.top_offset + inner.height as usize).min(lines.len()); - let visible: Vec> = lines[app.scroll.top_offset..end].to_vec(); + app.text_selection.set_history_snapshot( + HistoryViewport { + x: inner.x, + y: inner.y, + width: inner.width, + height: inner.height, + top_offset: app.scroll.top_offset, + total_lines: rows.len(), + }, + rows.iter().map(HistoryRow::selection_row).collect(), + ); + + let end = (app.scroll.top_offset + inner.height as usize).min(rows.len()); + let visible: Vec> = rows[app.scroll.top_offset..end] + .iter() + .enumerate() + .map(|(offset, row)| { + let absolute_row = app.scroll.top_offset + offset; + match app.text_selection.range_for_row(absolute_row) { + Some((from, to)) => highlight_line_selection(&row.line, from, to), + None => row.line.clone(), + } + }) + .collect(); // Pre-wrapped input → render without ratatui's word-wrap (which // would otherwise re-wrap mid-row at word boundaries and desync the @@ -717,6 +778,88 @@ fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec>) push_row(&mut current, &mut row_width, out); } +fn wrap_history_row_into( + line: Line<'static>, + selectable: bool, + width: u16, + out: &mut Vec, +) { + let mut wrapped = Vec::new(); + wrap_line_into(line, width, &mut wrapped); + out.extend( + wrapped + .into_iter() + .map(|line| HistoryRow::new(line, selectable)), + ); +} + +fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() +} + +fn highlight_line_selection(line: &Line<'static>, start: usize, end: usize) -> Line<'static> { + if start >= end { + return line.clone(); + } + + let highlight = Style::default().fg(Color::Black).bg(Color::Cyan); + let mut col = 0usize; + let mut spans = Vec::new(); + for span in &line.spans { + let mut plain = String::new(); + let mut selected = String::new(); + let push_pending = + |plain: &mut String, selected: &mut String, spans: &mut Vec>| { + if !plain.is_empty() { + spans.push(Span::styled(std::mem::take(plain), span.style)); + } + if !selected.is_empty() { + spans.push(Span::styled( + std::mem::take(selected), + span.style.patch(highlight), + )); + } + }; + + let mut in_selected = false; + for ch in span.content.chars() { + let width = UnicodeWidthChar::width(ch).unwrap_or(0); + let next = col.saturating_add(width); + let selected_char = next > start && col < end; + if selected_char != in_selected { + push_pending(&mut plain, &mut selected, &mut spans); + in_selected = selected_char; + } + if selected_char { + selected.push(ch); + } else { + plain.push(ch); + } + col = next; + } + push_pending(&mut plain, &mut selected, &mut spans); + } + + Line { + spans, + style: line.style, + alignment: line.alignment, + } +} + +/// Only text-like transcript Items are selectable/copyable. Tool calls and +/// other non-text Items remain visible but unselectable, so their rendered +/// diagnostics/output are never copied through this path. +fn block_is_selectable_text(block: &Block) -> bool { + matches!( + block, + Block::UserMessage { .. } | Block::SystemMessage { .. } | Block::AssistantText { .. } + ) +} + fn render_block_into(lines: &mut Vec>, block: &Block, width: u16, mode: Mode) { match block { Block::Greeting(g) => match mode { @@ -1668,4 +1811,41 @@ mod tests { Some("retrying LLM request".into()) ); } + + #[test] + fn history_rows_mark_text_items_selectable_and_non_text_unselectable() { + let mut app = App::new("pod".to_string()); + app.blocks = vec![ + Block::UserMessage { + segments: vec![Segment::Text { + content: "hello".to_string(), + }], + }, + Block::AssistantText { + text: "world".to_string(), + }, + Block::Thinking(ThinkingBlock { + text: "private reasoning".to_string(), + state: ThinkingState::Finished { elapsed_secs: None }, + }), + ]; + + let layout = compute_history(&app, 80); + let rows: Vec<_> = layout + .rows + .iter() + .map(|row| (row.text.as_str(), row.selectable)) + .collect(); + + assert!(rows.contains(&("hello", true))); + assert!(rows.contains(&("world", true))); + assert!( + rows.iter() + .any(|(text, selectable)| text.contains("private reasoning") && !*selectable) + ); + assert!( + rows.iter() + .any(|(text, selectable)| text.is_empty() && *selectable) + ); + } }