merge: single-pod text selection
This commit is contained in:
commit
3fa52f2c01
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3958,6 +3958,7 @@ dependencies = [
|
|||
name = "tui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"client",
|
||||
"crossterm 0.28.1",
|
||||
"llm-worker",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<CrosstermBackend<io::Stdout>>;
|
||||
|
||||
/// 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<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 {
|
||||
if let Some(p) = override_path {
|
||||
return p;
|
||||
|
|
@ -294,10 +333,9 @@ pub(crate) async fn run_spawn(
|
|||
|
||||
fn enter_fullscreen() -> Result<FullscreenTerminal, Box<dyn std::error::Error>> {
|
||||
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<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 {
|
||||
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<Method> {
|
|||
#[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();
|
||||
|
|
|
|||
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::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<Line<'static>>,
|
||||
pub rows: Vec<HistoryRow>,
|
||||
pub turn_starts: Vec<usize>,
|
||||
}
|
||||
|
||||
pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
|
||||
// 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 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<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);
|
||||
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<Line<'static>> = 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<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
|
||||
// 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);
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user