feat: add single-pod text selection

This commit is contained in:
Keisuke Hirata 2026-06-15 16:01:13 +09:00
parent 368249d677
commit 09f5e9d51a
No known key found for this signature in database
7 changed files with 770 additions and 42 deletions

1
Cargo.lock generated
View File

@ -3958,6 +3958,7 @@ dependencies = [
name = "tui"
version = "0.1.0"
dependencies = [
"base64",
"client",
"crossterm 0.28.1",
"llm-worker",

View File

@ -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 }

View File

@ -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(),

View File

@ -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;

View File

@ -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();

View 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");
}
}

View File

@ -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)
);
}
}