feat: add single-pod text selection
This commit is contained in:
parent
368249d677
commit
09f5e9d51a
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3958,6 +3958,7 @@ dependencies = [
|
||||||
name = "tui"
|
name = "tui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"client",
|
"client",
|
||||||
"crossterm 0.28.1",
|
"crossterm 0.28.1",
|
||||||
"llm-worker",
|
"llm-worker",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ e2e-test = []
|
||||||
client = { workspace = true }
|
client = { workspace = true }
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
ratatui = { version = "0.30.0", features = ["scrolling-regions"] }
|
ratatui = { version = "0.30.0", features = ["scrolling-regions"] }
|
||||||
|
base64 = "0.22.1"
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "process"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "process"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ use crate::composer_history::{
|
||||||
use crate::input::InputBuffer;
|
use crate::input::InputBuffer;
|
||||||
use crate::scroll::Scroll;
|
use crate::scroll::Scroll;
|
||||||
use crate::task::TaskStore;
|
use crate::task::TaskStore;
|
||||||
|
use crate::text_selection::TextSelectionState;
|
||||||
use crate::view_mode::Mode;
|
use crate::view_mode::Mode;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -293,6 +294,10 @@ pub struct App {
|
||||||
/// `[Session TaskStore snapshot]` system messages — no protocol
|
/// `[Session TaskStore snapshot]` system messages — no protocol
|
||||||
/// surface added on the Pod side.
|
/// surface added on the Pod side.
|
||||||
pub task_store: TaskStore,
|
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.
|
/// Whether the right-side task pane is currently open.
|
||||||
pub task_pane_open: bool,
|
pub task_pane_open: bool,
|
||||||
/// Top entry index of the task pane's visible window. Clamped on
|
/// Top entry index of the task pane's visible window. Clamped on
|
||||||
|
|
@ -351,6 +356,7 @@ impl App {
|
||||||
rewind_refresh_fence: false,
|
rewind_refresh_fence: false,
|
||||||
greeting: None,
|
greeting: None,
|
||||||
task_store: TaskStore::new(),
|
task_store: TaskStore::new(),
|
||||||
|
text_selection: TextSelectionState::default(),
|
||||||
task_pane_open: false,
|
task_pane_open: false,
|
||||||
task_pane_scroll: 0,
|
task_pane_scroll: 0,
|
||||||
queued_inputs: VecDeque::new(),
|
queued_inputs: VecDeque::new(),
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ pub mod setup_model;
|
||||||
mod single_pod;
|
mod single_pod;
|
||||||
mod spawn;
|
mod spawn;
|
||||||
mod task;
|
mod task;
|
||||||
|
mod text_selection;
|
||||||
mod tool;
|
mod tool;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod view_mode;
|
mod view_mode;
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crossterm::event::{
|
use crossterm::event::{
|
||||||
self, DisableMouseCapture, Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, MouseEvent,
|
self, DisableMouseCapture, Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, MouseButton,
|
||||||
MouseEventKind,
|
MouseEvent, MouseEventKind,
|
||||||
};
|
};
|
||||||
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||||
use crossterm::{Command, execute};
|
use crossterm::{Command, execute};
|
||||||
|
|
@ -23,6 +23,7 @@ use ratatui::backend::CrosstermBackend;
|
||||||
use session_store::SegmentId;
|
use session_store::SegmentId;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
use client::{PodClient, PodRuntimeCommand};
|
use client::{PodClient, PodRuntimeCommand};
|
||||||
|
|
||||||
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||||
|
|
@ -33,20 +34,19 @@ use crate::{multi_pod, picker, spawn, ui};
|
||||||
|
|
||||||
type FullscreenTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
type FullscreenTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
||||||
|
|
||||||
/// Enable the narrowest standard xterm mouse mode that still reports wheel
|
/// Enable SGR coordinates plus button-event tracking for Yoi-owned drag text
|
||||||
/// events. Crossterm's `EnableMouseCapture` also enables button-event
|
/// selection in the single-Pod transcript. This intentionally opts out of
|
||||||
/// tracking (`?1002h`), which requests drag-motion reports and interferes
|
/// terminal-native selection while the alternate screen is active, but still
|
||||||
/// with terminal text selection more aggressively. Normal tracking (`?1000h`)
|
/// avoids all-motion tracking (`?1003h`).
|
||||||
/// reports button presses, releases, and wheel notches, but does not request
|
|
||||||
/// drag-motion reports; the TUI ignores the non-wheel events.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[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 {
|
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
|
// 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)]
|
#[cfg(windows)]
|
||||||
|
|
@ -60,6 +60,45 @@ impl Command for EnableWheelMouseCapture {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn copy_to_terminal_clipboard<W: io::Write>(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<W: io::Write>(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>) -> PathBuf {
|
fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
||||||
if let Some(p) = override_path {
|
if let Some(p) = override_path {
|
||||||
return p;
|
return p;
|
||||||
|
|
@ -294,10 +333,9 @@ pub(crate) async fn run_spawn(
|
||||||
|
|
||||||
fn enter_fullscreen() -> Result<FullscreenTerminal, Box<dyn std::error::Error>> {
|
fn enter_fullscreen() -> Result<FullscreenTerminal, Box<dyn std::error::Error>> {
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
// Enable only normal mouse tracking for wheel events. Avoid crossterm's
|
// Enable button-event tracking so the transcript can own drag selection;
|
||||||
// full mouse capture because it requests drag-motion events and breaks
|
// avoid all-motion capture because hover-motion reports are unnecessary.
|
||||||
// terminal-native text selection.
|
execute!(stdout, EnterAlternateScreen, EnableSinglePodMouseCapture)?;
|
||||||
execute!(stdout, EnterAlternateScreen, EnableWheelMouseCapture)?;
|
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
Ok(Terminal::new(backend)?)
|
Ok(Terminal::new(backend)?)
|
||||||
}
|
}
|
||||||
|
|
@ -310,7 +348,7 @@ fn enter_fullscreen_existing(
|
||||||
execute!(
|
execute!(
|
||||||
terminal.backend_mut(),
|
terminal.backend_mut(),
|
||||||
EnterAlternateScreen,
|
EnterAlternateScreen,
|
||||||
EnableWheelMouseCapture
|
EnableSinglePodMouseCapture
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -761,8 +799,25 @@ const PANE_SCROLL_LINES: usize = 5;
|
||||||
|
|
||||||
fn handle_mouse(app: &mut App, mouse: MouseEvent) {
|
fn handle_mouse(app: &mut App, mouse: MouseEvent) {
|
||||||
match mouse.kind {
|
match mouse.kind {
|
||||||
MouseEventKind::ScrollUp => app.scroll.scroll_up(WHEEL_LINES),
|
MouseEventKind::ScrollUp => {
|
||||||
MouseEventKind::ScrollDown => app.scroll.scroll_down(WHEEL_LINES),
|
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<Method> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
// Close the popup if it's still showing (covers the
|
// Close the popup if it's still showing (covers the
|
||||||
|
|
@ -1128,18 +1202,116 @@ fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::text_selection::{HistoryViewport, SelectionRow};
|
||||||
use protocol::{Event, RewindTarget, RewindTargetId, Segment};
|
use protocol::{Event, RewindTarget, RewindTargetId, Segment};
|
||||||
|
|
||||||
#[test]
|
#[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();
|
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("?1000h"));
|
||||||
assert!(!ansi.contains("?1002h"));
|
assert!(ansi.contains("?1002h"));
|
||||||
|
assert!(ansi.contains("?1006h"));
|
||||||
assert!(!ansi.contains("?1003h"));
|
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]
|
#[tokio::test]
|
||||||
async fn terminal_event_is_selected_before_ready_pod_event() {
|
async fn terminal_event_is_selected_before_ready_pod_event() {
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
|
|
||||||
367
crates/tui/src/text_selection.rs
Normal file
367
crates/tui/src/text_selection.rs
Normal file
|
|
@ -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<SelectionPoint> {
|
||||||
|
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<SelectionPoint> {
|
||||||
|
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<ActiveSelection>,
|
||||||
|
viewport: Option<HistoryViewport>,
|
||||||
|
rows: Vec<SelectionRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextSelectionState {
|
||||||
|
pub fn set_history_snapshot(&mut self, viewport: HistoryViewport, rows: Vec<SelectionRow>) {
|
||||||
|
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<ActiveSelection> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@ use crate::app::{ActionbarNoticeLevel, App, CompletionState, alert_source_label,
|
||||||
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
|
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
|
||||||
use crate::command::CommandCandidate;
|
use crate::command::CommandCandidate;
|
||||||
use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
|
use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
|
||||||
|
use crate::text_selection::{HistoryViewport, SelectionRow};
|
||||||
use crate::view_mode::Mode;
|
use crate::view_mode::Mode;
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, app: &mut App) {
|
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)
|
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
|
/// Pre-rendered history lines plus the line indices at which each turn
|
||||||
/// begins (used for Ctrl-[/] jumps).
|
/// begins (used for Ctrl-[/] jumps).
|
||||||
pub struct HistoryLayout {
|
pub struct HistoryLayout {
|
||||||
pub lines: Vec<Line<'static>>,
|
pub rows: Vec<HistoryRow>,
|
||||||
pub turn_starts: Vec<usize>,
|
pub turn_starts: Vec<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
|
pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
|
||||||
// Step 1: collect logical lines from each block (unwrapped).
|
// Step 1: collect logical lines from each block (unwrapped).
|
||||||
let mut logical: Vec<Line<'static>> = Vec::new();
|
let mut logical: Vec<(Line<'static>, bool)> = Vec::new();
|
||||||
let mut logical_turn_starts: Vec<usize> = Vec::new();
|
let mut logical_turn_starts: Vec<usize> = Vec::new();
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
|
let mut previous_selectable = false;
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < app.blocks.len() {
|
while i < app.blocks.len() {
|
||||||
|
let block = &app.blocks[i];
|
||||||
|
let current_selectable = block_is_selectable_text(block);
|
||||||
if !first {
|
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;
|
first = false;
|
||||||
let block = &app.blocks[i];
|
|
||||||
if matches!(block, Block::TurnHeader { .. }) {
|
if matches!(block, Block::TurnHeader { .. }) {
|
||||||
logical_turn_starts.push(logical.len());
|
logical_turn_starts.push(logical.len());
|
||||||
}
|
}
|
||||||
if matches!(block, Block::ToolCall(_)) {
|
if matches!(block, Block::ToolCall(_)) {
|
||||||
let out = crate::tool::render_tool(&app.cache, &app.blocks, i, width, app.mode);
|
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);
|
i += out.consumed.max(1);
|
||||||
|
previous_selectable = false;
|
||||||
continue;
|
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;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: pre-wrap every logical line to char-based terminal rows so
|
// Step 2: pre-wrap every logical line to char-based terminal rows so
|
||||||
// scroll math is exact. Track the logical → wrapped mapping so
|
// scroll math is exact. Track the logical → wrapped mapping so
|
||||||
// turn-start indices get translated into wrapped-row coordinates.
|
// turn-start indices get translated into wrapped-row coordinates.
|
||||||
let mut lines: Vec<Line<'static>> = Vec::with_capacity(logical.len());
|
let mut rows: Vec<HistoryRow> = Vec::with_capacity(logical.len());
|
||||||
let mut logical_to_wrapped: Vec<usize> = Vec::with_capacity(logical.len() + 1);
|
let mut logical_to_wrapped: Vec<usize> = Vec::with_capacity(logical.len() + 1);
|
||||||
for line in logical {
|
for (line, selectable) in logical {
|
||||||
logical_to_wrapped.push(lines.len());
|
logical_to_wrapped.push(rows.len());
|
||||||
wrap_line_into(line, width, &mut lines);
|
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
|
let turn_starts = logical_turn_starts
|
||||||
.into_iter()
|
.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();
|
.collect();
|
||||||
|
|
||||||
HistoryLayout { lines, turn_starts }
|
HistoryLayout { rows, turn_starts }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Horizontal gutter around the log area. Applied via a
|
/// 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.total_lines = 0;
|
||||||
app.scroll.tail_top_offset = 0;
|
app.scroll.tail_top_offset = 0;
|
||||||
app.scroll.turn_starts.clear();
|
app.scroll.turn_starts.clear();
|
||||||
|
app.text_selection.clear_history_snapshot();
|
||||||
return;
|
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 outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING));
|
||||||
let inner = outer_block.inner(history_area);
|
let inner = outer_block.inner(history_area);
|
||||||
if inner.width == 0 || inner.height == 0 {
|
if inner.width == 0 || inner.height == 0 {
|
||||||
|
app.text_selection.clear_history_snapshot();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(picker) = app.rewind_picker.as_mut() {
|
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);
|
draw_rewind_picker(frame, history_area, inner, outer_block, picker);
|
||||||
return;
|
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.
|
// 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.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.tail_top_offset = tail_top;
|
||||||
app.scroll.turn_starts = turn_starts;
|
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);
|
app.scroll.top_offset = app.scroll.top_offset.min(tail_top);
|
||||||
}
|
}
|
||||||
|
|
||||||
let end = (app.scroll.top_offset + inner.height as usize).min(lines.len());
|
app.text_selection.set_history_snapshot(
|
||||||
let visible: Vec<Line<'static>> = lines[app.scroll.top_offset..end].to_vec();
|
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<Line<'static>> = 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
|
// Pre-wrapped input → render without ratatui's word-wrap (which
|
||||||
// would otherwise re-wrap mid-row at word boundaries and desync the
|
// 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<Line<'static>>)
|
||||||
push_row(&mut current, &mut row_width, out);
|
push_row(&mut current, &mut row_width, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn wrap_history_row_into(
|
||||||
|
line: Line<'static>,
|
||||||
|
selectable: bool,
|
||||||
|
width: u16,
|
||||||
|
out: &mut Vec<HistoryRow>,
|
||||||
|
) {
|
||||||
|
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::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Span<'static>>| {
|
||||||
|
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<Line<'static>>, block: &Block, width: u16, mode: Mode) {
|
fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16, mode: Mode) {
|
||||||
match block {
|
match block {
|
||||||
Block::Greeting(g) => match mode {
|
Block::Greeting(g) => match mode {
|
||||||
|
|
@ -1668,4 +1811,41 @@ mod tests {
|
||||||
Some("retrying LLM request".into())
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user