diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4b4b5..c1b5e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to Subconscious Code are documented here. This project uses ## [Unreleased] +### Changed + +- Mouse-wheel history navigation now moves one transcript row per event, and + transcript selections remain anchored while scrolling across viewports. +- The TUI stops repainting on idle poll timeouts, allowing terminal tabs to + become quiescent while no turn or input is active. + +### Fixed + +- Copying a transcript selection now includes the complete range between its + endpoints, including history rows outside the current viewport. +- The incremental-session durability test now waits for the asynchronous + writer's flush instead of racing its filesystem thread in fast CI runners. + ## [0.1.2] - 2026-09-03 ### Added diff --git a/crates/rc-rt/tests/runtime.rs b/crates/rc-rt/tests/runtime.rs index 09390e2..5b5a908 100644 --- a/crates/rc-rt/tests/runtime.rs +++ b/crates/rc-rt/tests/runtime.rs @@ -735,7 +735,22 @@ async fn session_store_is_replayable_while_the_next_request_is_still_running() { rt.action(UserAction::Submit("checkpoint this".into())); second_started.notified().await; - let loaded = rc_session::load(&path).unwrap(); + // Persistence runs on its own blocking writer thread. The second model + // request proves all three records have been enqueued, but it must not be + // used as a scheduling signal that the writer thread has already flushed + // them. Wait briefly for the documented durable state while the request is + // still paused, instead of racing the filesystem immediately. + let loaded = tokio::time::timeout(Duration::from_secs(1), async { + loop { + let loaded = rc_session::load(&path).unwrap(); + if loaded.messages.len() == 3 { + break loaded; + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .unwrap_or_else(|_| rc_session::load(&path).unwrap()); assert_eq!(loaded.messages.len(), 3, "user/call/result must be durable"); assert!(matches!(loaded.messages[2], Turn::ToolResult { .. })); diff --git a/crates/rc-tui/src/app.rs b/crates/rc-tui/src/app.rs index ffca22c..18bfe76 100644 --- a/crates/rc-tui/src/app.rs +++ b/crates/rc-tui/src/app.rs @@ -24,8 +24,7 @@ use serde_json::Value; use crate::complete::{self, Completion}; use crate::diff; use crate::theme; -use crate::view::Selection; -use crate::view::{self, CompletionMenu, PendingAsk, ViewState}; +use crate::view::{self, CompletionMenu, PendingAsk, Selection, TranscriptSelection, ViewState}; use crate::Term; /// Poll cadence while a turn is in flight. `crossterm::event::poll` only wakes @@ -34,9 +33,12 @@ use crate::Term; /// spinner smooth. The cost is a ~125 Hz idle-ish wake while busy, which is /// negligible next to the work the loop is already doing. const TICK_BUSY: Duration = Duration::from_millis(8); -/// Poll cadence while idle. Nothing is streaming, so there's no latency to -/// optimize — a slower tick saves CPU (and battery) while the user reads. -const TICK_IDLE: Duration = Duration::from_millis(33); +/// Poll cadence while idle. Idle timeouts only check the runtime channel; they +/// no longer repaint the terminal. This modest wake keeps out-of-band state +/// responsive without making iTerm2 treat an untouched session as active. +const TICK_IDLE: Duration = Duration::from_millis(250); +/// Fine-grained history navigation: one terminal row per wheel event. +const WHEEL_LINES: i32 = 1; /// A normal human cannot type the first character of the next prompt this /// quickly after Enter, but a terminal that has lost bracketed-paste framing /// can. Hold plain submissions briefly so an Enter-delimited paste can be @@ -143,13 +145,18 @@ pub(crate) fn run( // with the engine from frame one instead of claiming "default" until // something happens to change it. app.view.mode = initial_mode; + let mut redraw = true; loop { - app.drain_events(); - app.flush_pending_submit_if_due(); - terminal.draw(|f| view::draw(f, &mut app.view))?; - // After the draw, never before: `view::draw` is what harvests the - // selected text out of the finished buffer. - app.flush_copy(); + let stream_changed = app.drain_events(); + let submit_changed = app.flush_pending_submit_if_due(); + if should_draw(redraw, stream_changed, submit_changed, app.view.busy) { + terminal.draw(|f| view::draw(f, &mut app.view))?; + // After the draw, never before: `view::draw` is what harvests the + // selected text out of the finished buffer. + redraw = app.flush_copy(); + } else { + redraw = false; + } if app.quit { break; } @@ -157,7 +164,7 @@ pub(crate) fn run( // broadcast channel buffers anything that arrives between wakes, so a // longer idle tick loses no events — it only delays *display*, which // doesn't matter when there's nothing to display. - let tick = if app.view.busy { TICK_BUSY } else { TICK_IDLE }; + let tick = app.next_tick(); if event::poll(tick)? { match event::read()? { // Keyboard drives the composer and the keymap. Only Press events @@ -176,16 +183,37 @@ pub(crate) fn run( Event::Mouse(ev) => app.handle_mouse(ev), _ => {} } + redraw = true; } } app.runtime.shutdown_blocking(Duration::from_secs(5)); Ok(app.outcome) } +/// Idle poll timeouts deliberately do not cause terminal output. Besides +/// avoiding needless CPU/render work, this lets terminal emulators mark a tab +/// as quiescent instead of perpetually active while the user is only reading. +fn should_draw(input_dirty: bool, stream_dirty: bool, submit_dirty: bool, busy: bool) -> bool { + input_dirty || stream_dirty || submit_dirty || busy +} + impl App { + fn next_tick(&self) -> Duration { + if let Some(pending) = &self.pending_submit { + return PASTE_RESCUE_WINDOW.saturating_sub(pending.staged.elapsed()); + } + if self.view.busy { + TICK_BUSY + } else { + TICK_IDLE + } + } + /// Pull every available event off the stream and fold it into the view state. - fn drain_events(&mut self) { + fn drain_events(&mut self) -> bool { + let mut changed = false; while let Some(ev) = self.stream.try_next() { + changed = true; match ev { Ok(e) => self.apply(e), Err(n) => self.view.transcript.push(Line::from(format!( @@ -193,6 +221,7 @@ impl App { ))), } } + changed } fn apply(&mut self, ev: AgentEvent) { @@ -811,7 +840,7 @@ impl App { self.submit_prompt(prompt); } - fn flush_pending_submit_if_due(&mut self) { + fn flush_pending_submit_if_due(&mut self) -> bool { let due = self .pending_submit .as_ref() @@ -819,6 +848,7 @@ impl App { if due { self.resolve_pending_submit(false); } + due } /// Recompute the completion menu from the current composer buffer. Clears @@ -924,73 +954,135 @@ impl App { } } - /// Mouse wheel scroll. Each notch moves a few lines; reaching the bottom + /// Mouse wheel scroll. Each notch moves one line; reaching the bottom /// re-pins to follow, reaching the top holds at line 0. Single-pass (one /// `total_lines` computation) so a fast trackpad swipe doesn't re-parse the /// streaming markdown once per line. fn handle_mouse(&mut self, ev: MouseEvent) { - const WHEEL_LINES: i32 = 3; match ev.kind { - // Scrolling moves the text out from under a highlight, so the - // selection can't survive it. MouseEventKind::ScrollUp => { - self.clear_selection(); - self.scroll_by(-WHEEL_LINES) + let transcript_selection = self.view.transcript_selection.is_some(); + if !transcript_selection { + self.clear_selection(); + } + self.scroll_by(-WHEEL_LINES); + if transcript_selection && self.view.selection_dragging { + self.extend_transcript_selection_to_viewport_edge(true); + } } MouseEventKind::ScrollDown => { - self.clear_selection(); - self.scroll_by(WHEEL_LINES) + let transcript_selection = self.view.transcript_selection.is_some(); + if !transcript_selection { + self.clear_selection(); + } + self.scroll_by(WHEEL_LINES); + if transcript_selection && self.view.selection_dragging { + self.extend_transcript_selection_to_viewport_edge(false); + } } // A press no longer acts immediately: not until the button comes // up do we know whether this was a click (toggle a block) or a // drag (select text). MouseEventKind::Down(MouseButton::Left) => { + self.clear_selection(); self.view.copy_notice = None; - self.view.selection = Some(Selection { - anchor: (ev.column, ev.row), - head: (ev.column, ev.row), - }); + self.view.selection_dragging = true; + if let Some(point) = self.view.transcript_point_at(ev.column, ev.row) { + self.view.transcript_selection = Some(TranscriptSelection { + anchor: point, + head: point, + }); + } else { + self.view.selection = Some(Selection { + anchor: (ev.column, ev.row), + head: (ev.column, ev.row), + }); + } } MouseEventKind::Drag(MouseButton::Left) => { + if let Some(point) = self.view.transcript_point_at(ev.column, ev.row) { + if let Some(sel) = self.view.transcript_selection.as_mut() { + sel.head = point; + return; + } + } if let Some(sel) = self.view.selection.as_mut() { sel.head = (ev.column, ev.row); } } - MouseEventKind::Up(MouseButton::Left) => match self.view.selection { - // Dragged: copy on release, with no extra keystroke — the - // selection *is* the copy gesture. - Some(sel) if !sel.is_empty() => self.view.copy_pending = true, - // Never moved: a plain click, which keeps its old meaning. - _ => { + MouseEventKind::Up(MouseButton::Left) => { + if let Some(point) = self.view.transcript_point_at(ev.column, ev.row) { + if let Some(sel) = self.view.transcript_selection.as_mut() { + sel.head = point; + } + } + self.view.selection_dragging = false; + let dragged = self + .view + .transcript_selection + .is_some_and(|sel| !sel.is_empty()) + || self.view.selection.is_some_and(|sel| !sel.is_empty()); + if dragged { + // Copy on release, with no extra keystroke — the selection + // itself is the copy gesture. + self.view.copy_pending = true; + } else { + // Never moved: a plain click, which keeps its old meaning. self.clear_selection(); self.toggle_expandable_at(ev.column, ev.row); } - }, + } _ => {} } } + /// Extend an active transcript drag to the newly exposed edge after a + /// wheel event. Logical transcript indices make this work even though the + /// anchor has already left the visible terminal buffer. + fn extend_transcript_selection_to_viewport_edge(&mut self, upper: bool) { + let total = self.total_lines(); + if total == 0 { + return; + } + let top = self.current_top(total); + let line = if upper { + top + } else { + top.saturating_add(self.view.area_height.saturating_sub(1)) + .min(total - 1) + }; + if let Some(selection) = self.view.transcript_selection.as_mut() { + selection.head.line = line; + selection.head.visual_row = if upper { 0 } else { usize::MAX }; + selection.head.column = if upper { 0 } else { u16::MAX }; + } + } + /// Drop any selection and the text harvested for it. fn clear_selection(&mut self) { self.view.selection = None; + self.view.transcript_selection = None; + self.view.selection_dragging = false; self.view.selection_text = None; } /// Copy a finished drag to the clipboard. Called right after a draw, - /// because the text is read out of the rendered buffer and only exists - /// once the frame has been painted. - fn flush_copy(&mut self) { + /// because screen selections are harvested from the finished buffer and + /// transcript selections are refreshed against their logical range there. + fn flush_copy(&mut self) -> bool { if !self.view.copy_pending { - return; + return false; } self.view.copy_pending = false; let Some(text) = self.view.selection_text.clone() else { - return; + return false; }; if copy_to_clipboard(&text).is_ok() { let n = text.chars().count(); self.view.copy_notice = Some((format!("copied {n} chars"), Instant::now())); + return true; } + false } /// Move the held scroll position by `delta` lines (negative = up). Clamps @@ -1249,9 +1341,12 @@ impl App { self.view .transcript .push(mk(" Alt+↑/↓ recall prompt history")); - self.view.transcript.push(mk( - " drag select text with your terminal, then copy", - )); + self.view + .transcript + .push(mk(" drag select and copy text on release")); + self.view + .transcript + .push(mk(" drag+wheel extend selection through history")); self.view.transcript.push(mk( " Ctrl+O toggle wheel scrolling / native selection", )); @@ -1452,10 +1547,9 @@ impl App { /// Hand the mouse back to the terminal, or take it again. /// - /// Mouse capture is what makes the wheel scroll the transcript, but it - /// also means the terminal never sees a drag — so there is no way to - /// select text, and copy/paste out of `sc` is impossible. Neither state is - /// right all the time, so it is a toggle rather than a default. + /// Mouse capture gives the app wheel navigation and in-app copy. Releasing + /// it remains useful for terminals whose clipboard policy blocks OSC 52 + /// and which therefore need to own selection themselves. fn toggle_mouse_capture(&mut self) { self.view.mouse_capture = !self.view.mouse_capture; let mut out = std::io::stdout(); @@ -2686,6 +2780,20 @@ mod tests { assert_eq!(tmux, "\x1bPtmux;\x1b\x1b]52;c;Y29weSBtZQ==\x07\x1b\\"); } + #[test] + fn idle_poll_timeout_does_not_request_a_terminal_redraw() { + assert!(!should_draw(false, false, false, false)); + assert!(should_draw(true, false, false, false)); + assert!(should_draw(false, true, false, false)); + assert!(should_draw(false, false, true, false)); + assert!(should_draw(false, false, false, true)); + } + + #[test] + fn wheel_navigation_is_one_line_per_event() { + assert_eq!(WHEEL_LINES, 1); + } + /// A selection reads the same whichever way it was dragged. #[test] fn selection_orders_row_major_in_both_directions() { diff --git a/crates/rc-tui/src/view.rs b/crates/rc-tui/src/view.rs index 7193c9d..380a72d 100644 --- a/crates/rc-tui/src/view.rs +++ b/crates/rc-tui/src/view.rs @@ -113,6 +113,16 @@ struct ToolHitbox { height: u16, } +/// One transcript row currently visible on screen. The source index is stable +/// across viewport changes, unlike the screen row, so a selection can remain +/// attached to history while the user scrolls. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct VisibleTranscriptRow { + screen_row: u16, + source_index: usize, + source_row: usize, +} + impl ToolHitbox { fn contains(self, column: u16, row: u16) -> bool { column >= self.x @@ -242,16 +252,24 @@ pub(crate) struct ViewState { /// `Some` it owns both the frame and the keymap, so there's no half-state /// where a keystroke lands in the composer hidden behind it. pub menu_overlay: Option, - /// The live mouse selection, in *screen* cells. `None` when nothing is - /// selected. Screen coordinates rather than transcript offsets because - /// what the user is selecting is what they can see — after wrapping, - /// markdown styling and collapsing, the rendered buffer is the only place - /// that text exists in the shape they are pointing at. + /// A live selection outside the transcript, in screen cells. Transcript + /// drags use [`Self::transcript_selection`] so they stay attached to + /// history when the viewport moves. pub selection: Option, + /// A transcript selection in stable logical-line coordinates. This is what + /// allows a drag to span more than one viewport and lets its highlight + /// remain correct when history is scrolled after copying. + pub transcript_selection: Option, + /// True between left-button down and up. Wheel events during that interval + /// extend a transcript selection; later browsing only preserves it. + pub selection_dragging: bool, + /// Screen-row to logical transcript-row mapping from the most recent draw. + /// Mouse handling consumes it before the next frame is rendered. + visible_transcript_rows: Vec, /// The selected text, harvested from the render buffer during [`draw`]. pub selection_text: Option, - /// Set on mouse-up: the run loop copies [`Self::selection_text`] to the - /// clipboard after the next draw (the text only exists once drawn). + /// Set on mouse-up: the run loop copies [`Self::selection_text`] after the + /// next draw, once screen or transcript selection harvesting is complete. pub copy_pending: bool, /// A short-lived "copied N chars" confirmation and when it was shown. pub copy_notice: Option<(String, Instant)>, @@ -260,11 +278,10 @@ pub(crate) struct ViewState { /// Resolved once at startup — it is the fact that scrolls off the top of a /// long transcript and never comes back, so the persistent chrome owns it. pub location: String, - /// Whether mouse capture is on. While it is, the app receives every drag - /// and the terminal never sees one, so text cannot be selected — the one - /// thing a terminal is otherwise always good for. Off hands the mouse back - /// for selection and copy, at the cost of wheel scrolling. Toggled with - /// Ctrl+O (`/select`). + /// Whether mouse capture is on. While it is, the app handles wheel history + /// and copies drags itself. Off hands both gestures back to the terminal as + /// a fallback for clipboard policies that reject app-initiated copy. + /// Toggled with Ctrl+O (`/select`). pub mouse_capture: bool, } @@ -280,6 +297,38 @@ pub(crate) struct Selection { pub head: (u16, u16), } +/// A point in retained transcript history. Columns remain display columns; +/// line indices are stable while a user is browsing because new content is +/// appended below them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct TranscriptPoint { + pub line: usize, + pub visual_row: usize, + pub column: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct TranscriptSelection { + pub anchor: TranscriptPoint, + pub head: TranscriptPoint, +} + +impl TranscriptSelection { + pub fn ordered(&self) -> (TranscriptPoint, TranscriptPoint) { + let a = (self.anchor.line, self.anchor.visual_row, self.anchor.column); + let b = (self.head.line, self.head.visual_row, self.head.column); + if a <= b { + (self.anchor, self.head) + } else { + (self.head, self.anchor) + } + } + + pub fn is_empty(&self) -> bool { + self.anchor == self.head + } +} + impl Selection { /// The selection as (start, end) in reading order, whichever way it was /// dragged. @@ -348,12 +397,29 @@ impl ViewState { location: String::new(), mouse_capture: false, selection: None, + transcript_selection: None, + selection_dragging: false, + visible_transcript_rows: Vec::new(), selection_text: None, copy_pending: false, copy_notice: None, } } + /// Resolve a screen cell from the last frame to its stable transcript + /// coordinate. Rows outside the transcript return `None`, preserving the + /// existing screen-space selection behavior for status/composer text. + pub(crate) fn transcript_point_at(&self, column: u16, row: u16) -> Option { + self.visible_transcript_rows + .iter() + .find(|visible| visible.screen_row == row) + .map(|visible| TranscriptPoint { + line: visible.source_index, + visual_row: visible.source_row, + column, + }) + } + /// Whether the pre-conversation welcome card is showing right now (the /// logo splash, `cwd`, key hints) rather than the transcript: nothing in /// the transcript, nothing streaming, no turn in flight, no pending ask. @@ -813,6 +879,8 @@ pub(crate) fn draw(frame: &mut Frame, state: &mut ViewState) { // A modal owns the screen; a selection made behind it would highlight // cells that are no longer there. state.selection = None; + state.transcript_selection = None; + state.visible_transcript_rows.clear(); state.selection_text = None; return; } @@ -870,6 +938,10 @@ pub(crate) fn draw(frame: &mut Frame, state: &mut ViewState) { /// dragged across. Reading them back means "copy" always matches what the eye /// selected, with no second, divergent path through the transcript model. fn apply_selection(frame: &mut Frame, state: &mut ViewState) { + if let Some(selection) = state.transcript_selection { + apply_transcript_selection(frame, state, selection); + return; + } let Some(selection) = state.selection else { state.selection_text = None; return; @@ -909,12 +981,97 @@ fn apply_selection(frame: &mut Frame, state: &mut ViewState) { state.selection_text = (!text.trim().is_empty()).then_some(text); } +/// Highlight the visible portion of a transcript selection and harvest the +/// entire logical range, including rows currently above or below the viewport. +/// The clipboard therefore follows history rather than the terminal screen. +fn apply_transcript_selection( + frame: &mut Frame, + state: &mut ViewState, + selection: TranscriptSelection, +) { + if selection.is_empty() { + state.selection_text = None; + return; + } + let (start, end) = selection.ordered(); + let width = frame.area().width; + let visible = state.visible_transcript_rows.clone(); + let buf = frame.buffer_mut(); + + for row in visible { + let row_key = (row.source_index, row.source_row); + let start_key = (start.line, start.visual_row); + let end_key = (end.line, end.visual_row); + if row_key < start_key || row_key > end_key { + continue; + } + let from = if row_key == start_key { + start.column + } else { + 0 + }; + let to = if row_key == end_key { + end.column + } else { + width.saturating_sub(1) + }; + for col in from..=to.min(width.saturating_sub(1)) { + buf[(col, row.screen_row)].set_style(Style::new().add_modifier(Modifier::REVERSED)); + } + } + + let mut text = String::new(); + for index in start.line..=end.line { + let Some(line) = selectable_transcript_line(state, index) else { + continue; + }; + let chars: Vec = line.chars().collect(); + // A logical row can occupy multiple visual rows after wrapping. Its + // retained text has no wrap newlines, so selecting across two of those + // rows copies the whole logical row rather than guessing at a second, + // divergent wrapping algorithm. + let same_line_wrap = start.line == end.line && start.visual_row != end.visual_row; + let from = if index == start.line && !same_line_wrap { + usize::from(start.column).min(chars.len()) + } else { + 0 + }; + let through = if index == end.line && !same_line_wrap { + usize::from(end.column).saturating_add(1).min(chars.len()) + } else { + chars.len() + }; + if from < through { + text.extend(chars[from..through].iter()); + } + if index < end.line { + text.push('\n'); + } + } + state.selection_text = (!text.trim().is_empty()).then_some(text); +} + +fn selectable_transcript_line(state: &ViewState, index: usize) -> Option { + let line = if index < state.transcript.len() { + &state.transcript[index] + } else { + state.current_parsed.get(index - state.transcript.len())? + }; + Some( + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect(), + ) +} + fn draw_transcript(frame: &mut Frame, state: &mut ViewState, area: Rect, now: Instant) { let h = area.height as usize; let w = area.width; state.area_height = h; state.reasoning_hitboxes.clear(); state.tool_hitboxes.clear(); + state.visible_transcript_rows.clear(); // Welcome card before the first turn: the brand logo on the left, the // model + cwd + key hints on the right, all in one bordered box. It lives @@ -1044,6 +1201,8 @@ fn draw_transcript(frame: &mut Frame, state: &mut ViewState, area: Rect, now: In // Each tuple is (retained block, physical row, wrapped height, label width). let mut reasoning_rows: Vec<(usize, usize, usize, usize)> = Vec::new(); let mut tool_rows: Vec<(usize, usize, usize, usize)> = Vec::new(); + let mut physical_sources = Vec::new(); + let mut source_row_counts: HashMap = HashMap::new(); if w > 0 { let mut physical_row = 0usize; for (offset, line) in lines.iter().enumerate() { @@ -1052,6 +1211,11 @@ fn draw_transcript(frame: &mut Frame, state: &mut ViewState, area: Rect, now: In .line_count(w) .max(1); let global_index = line_sources[offset]; + let source_row = source_row_counts.entry(global_index).or_default(); + physical_sources.extend( + (*source_row..source_row.saturating_add(row_count)).map(|row| (global_index, row)), + ); + *source_row = source_row.saturating_add(row_count); if global_index < tr_len { if let Some(block_index) = state .reasoning_blocks @@ -1095,6 +1259,20 @@ fn draw_transcript(frame: &mut Frame, state: &mut ViewState, area: Rect, now: In } else { 0 }; + state.visible_transcript_rows.extend( + physical_sources + .iter() + .skip(scroll_y as usize) + .take(h) + .enumerate() + .map( + |(offset, (source_index, source_row))| VisibleTranscriptRow { + screen_row: area.y.saturating_add(offset as u16), + source_index: *source_index, + source_row: *source_row, + }, + ), + ); // Reconstruct each logical line's physical wrapped rows using the same // `Paragraph` + `Wrap` configuration as the real render. This makes the // click target land on the label even when earlier transcript lines wrap @@ -2648,6 +2826,79 @@ mod tests { ); } + #[test] + fn transcript_selection_survives_and_copies_across_viewports() { + let mut state = ViewState::new("m".into()); + for index in 0..20 { + state + .transcript + .push(Line::raw(format!("history row {index:02}"))); + } + + state.follow = false; + state.scroll_top = 0; + let _ = rendered_sized(&mut state, 40, 12); + let anchor = state + .transcript_point_at(0, 1) + .expect("old history is addressable after scrolling up"); + + state.scroll_top = 15; + let _ = rendered_sized(&mut state, 40, 12); + let head = state + .transcript_point_at(13, 1) + .expect("newer history is addressable in another viewport"); + assert!(head.line > anchor.line); + + state.transcript_selection = Some(TranscriptSelection { anchor, head }); + let _ = rendered_sized(&mut state, 40, 12); + let copied = state + .selection_text + .as_deref() + .expect("the logical range is harvested") + .to_string(); + assert!(copied.starts_with("history row 01"), "{copied}"); + assert!(copied.ends_with("history row 15"), "{copied}"); + assert!( + copied.contains("history row 08"), + "off-screen rows between both views are included: {copied}" + ); + + state.scroll_top = 0; + let _ = rendered_sized(&mut state, 40, 12); + assert_eq!( + state.selection_text.as_deref(), + Some(copied.as_str()), + "moving the viewport does not change the selected history" + ); + } + + #[test] + fn wrapped_transcript_rows_are_distinct_selection_points() { + let text = "one two three four five six seven eight nine ten eleven twelve"; + let mut state = ViewState::new("m".into()); + state.transcript.push(Line::raw(text)); + let _ = rendered_sized(&mut state, 20, 12); + let points: Vec<_> = state + .visible_transcript_rows + .iter() + .filter(|row| row.source_index == 0) + .map(|row| { + state + .transcript_point_at(1, row.screen_row) + .expect("visible wrapped row maps to history") + }) + .collect(); + assert!(points.len() > 1, "fixture must wrap"); + assert_ne!(points[0], points[1]); + + state.transcript_selection = Some(TranscriptSelection { + anchor: points[0], + head: points[1], + }); + let _ = rendered_sized(&mut state, 20, 12); + assert_eq!(state.selection_text.as_deref(), Some(text)); + } + /// A press that never moved selects nothing, so a plain click can keep its /// existing meaning (expand/collapse a block) without copying. #[test]