Mouse selection, copy/paste, yield_to_user fixes
- Mouse text selection with highlight rendering in panes - OSC 52 clipboard copy on selection, middle-click paste via tmux buffer - Bracketed paste support (Event::Paste) - yield_to_user: no tool result appended, ends turn immediately - yield_to_user: no parameters, just a control signal - Drop arboard dependency, use crossterm OSC 52 + tmux for clipboard Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
parent
7dd9daa2b9
commit
a596e007b2
9 changed files with 246 additions and 22 deletions
220
src/user/chat.rs
220
src/user/chat.rs
|
|
@ -9,8 +9,9 @@ use ratatui::{
|
|||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
Frame,
|
||||
crossterm::event::{KeyCode, KeyModifiers, MouseEvent, MouseEventKind, MouseButton},
|
||||
};
|
||||
use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseEvent, MouseEventKind, MouseButton};
|
||||
|
||||
|
||||
use super::{App, ScreenView, screen_legend};
|
||||
use crate::agent::context::{AstNode, NodeBody, Role, Ast};
|
||||
|
|
@ -158,6 +159,56 @@ enum ActivePane {
|
|||
Tools,
|
||||
}
|
||||
|
||||
/// Text selection within a pane. Anchor is where the click started,
|
||||
/// cursor is where the mouse currently is. They may be in either order.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
struct Selection {
|
||||
anchor_line: usize,
|
||||
anchor_col: usize,
|
||||
cursor_line: usize,
|
||||
cursor_col: usize,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
fn new(line: usize, col: usize) -> Self {
|
||||
Self { anchor_line: line, anchor_col: col, cursor_line: line, cursor_col: col }
|
||||
}
|
||||
|
||||
fn extend(&mut self, line: usize, col: usize) {
|
||||
self.cursor_line = line;
|
||||
self.cursor_col = col;
|
||||
}
|
||||
|
||||
/// Normalized range: (start_line, start_col, end_line, end_col)
|
||||
fn range(&self) -> (usize, usize, usize, usize) {
|
||||
if (self.anchor_line, self.anchor_col) <= (self.cursor_line, self.cursor_col) {
|
||||
(self.anchor_line, self.anchor_col, self.cursor_line, self.cursor_col)
|
||||
} else {
|
||||
(self.cursor_line, self.cursor_col, self.anchor_line, self.anchor_col)
|
||||
}
|
||||
}
|
||||
|
||||
fn text(&self, lines: &[Line<'static>]) -> String {
|
||||
let (start_line, start_col, end_line, end_col) = self.range();
|
||||
let mut result = String::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if i < start_line || i > end_line { continue; }
|
||||
let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
let sc = if i == start_line { start_col } else { 0 };
|
||||
let ec = if i == end_line { end_col } else { line_text.len() };
|
||||
if sc < line_text.len() {
|
||||
if let Some(selected) = line_text.get(sc..ec.min(line_text.len())) {
|
||||
if !result.is_empty() {
|
||||
result.push('\n');
|
||||
}
|
||||
result.push_str(selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_ansi(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut chars = text.chars().peekable();
|
||||
|
|
@ -226,6 +277,7 @@ struct PaneState {
|
|||
pinned: bool,
|
||||
last_total_lines: u16,
|
||||
last_height: u16,
|
||||
selection: Option<Selection>,
|
||||
}
|
||||
|
||||
impl PaneState {
|
||||
|
|
@ -237,6 +289,7 @@ impl PaneState {
|
|||
md_buffer: String::new(), use_markdown,
|
||||
pending_marker: Marker::None, scroll: 0, pinned: false,
|
||||
last_total_lines: 0, last_height: 20,
|
||||
selection: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -352,6 +405,56 @@ impl PaneState {
|
|||
}
|
||||
(lines, markers)
|
||||
}
|
||||
|
||||
/// Convert mouse coordinates (relative to pane) to line/column position.
|
||||
fn mouse_to_position(&self, mouse_x: u16, mouse_y: u16, pane_height: u16) -> Option<(usize, usize)> {
|
||||
let (lines, _) = self.all_lines_with_markers();
|
||||
if lines.is_empty() || self.cached_width == 0 { return None; }
|
||||
|
||||
// Build heights array (reuse cached where possible)
|
||||
let n_committed = self.line_heights.len();
|
||||
let mut heights: Vec<u16> = self.line_heights.clone();
|
||||
for line in lines.iter().skip(n_committed) {
|
||||
let h = Paragraph::new(line.clone())
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(self.cached_width) as u16;
|
||||
heights.push(h.max(1));
|
||||
}
|
||||
|
||||
// Find the first visible line given current scroll
|
||||
let (first, sub_scroll, _) = visible_range(&heights, self.scroll, pane_height);
|
||||
|
||||
// Walk from the first visible line, offset by sub_scroll
|
||||
let mut row = -(sub_scroll as i32);
|
||||
for line_idx in first..lines.len() {
|
||||
let h = heights.get(line_idx).copied().unwrap_or(1) as i32;
|
||||
if (mouse_y as i32) < row + h {
|
||||
let line_text: String = lines[line_idx].spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
let col = (mouse_x as usize).min(line_text.len());
|
||||
return Some((line_idx, col));
|
||||
}
|
||||
row += h;
|
||||
}
|
||||
Some((lines.len().saturating_sub(1), 0))
|
||||
}
|
||||
|
||||
/// Set the selection start position.
|
||||
fn start_selection(&mut self, line: usize, col: usize) {
|
||||
self.selection = Some(Selection::new(line, col));
|
||||
}
|
||||
|
||||
/// Update the selection end position.
|
||||
fn extend_selection(&mut self, line: usize, col: usize) {
|
||||
if let Some(ref mut sel) = self.selection {
|
||||
sel.extend(line, col);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the selected text, or None if nothing is selected.
|
||||
fn get_selection(&self) -> Option<String> {
|
||||
let (lines, _) = self.all_lines_with_markers();
|
||||
self.selection.as_ref().map(|sel| sel.text(&lines))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct InteractScreen {
|
||||
|
|
@ -610,14 +713,83 @@ impl InteractScreen {
|
|||
for (i, area) in self.pane_areas.iter().enumerate() {
|
||||
if x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height {
|
||||
self.active_pane = match i { 0 => ActivePane::Autonomous, 1 => ActivePane::Conversation, _ => ActivePane::Tools };
|
||||
let rel_x = x.saturating_sub(area.x);
|
||||
let rel_y = y.saturating_sub(area.y);
|
||||
self.selection_event(i, rel_x, rel_y, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
let (x, y) = (mouse.column, mouse.row);
|
||||
let i = match self.active_pane { ActivePane::Autonomous => 0, ActivePane::Conversation => 1, ActivePane::Tools => 2 };
|
||||
let area = self.pane_areas[i];
|
||||
if x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height {
|
||||
let rel_x = x.saturating_sub(area.x);
|
||||
let rel_y = y.saturating_sub(area.y);
|
||||
self.selection_event(i, rel_x, rel_y, false);
|
||||
}
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
self.copy_selection_to_clipboard();
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Middle) => {
|
||||
self.paste_from_selection();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the current selection to the clipboard via OSC 52.
|
||||
fn copy_selection_to_clipboard(&self) {
|
||||
let text = match self.active_pane {
|
||||
ActivePane::Autonomous => self.autonomous.get_selection(),
|
||||
ActivePane::Conversation => self.conversation.get_selection(),
|
||||
ActivePane::Tools => self.tools.get_selection(),
|
||||
};
|
||||
if let Some(ref selected_text) = text {
|
||||
if selected_text.is_empty() { return; }
|
||||
// OSC 52 clipboard copy
|
||||
use std::io::Write;
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(selected_text);
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
let _ = write!(stdout, "\x1b]52;c;{}\x07", encoded);
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste from tmux buffer via middle-click.
|
||||
fn paste_from_selection(&mut self) {
|
||||
let result = std::process::Command::new("tmux")
|
||||
.args(["save-buffer", "-"]).output();
|
||||
if let Ok(output) = result {
|
||||
if output.status.success() {
|
||||
let text = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
if !text.is_empty() {
|
||||
self.textarea.insert_str(&text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pane_mut(&mut self, idx: usize) -> &mut PaneState {
|
||||
match idx { 0 => &mut self.autonomous, 1 => &mut self.conversation, _ => &mut self.tools }
|
||||
}
|
||||
|
||||
fn selection_event(&mut self, pane_idx: usize, rel_x: u16, rel_y: u16, start: bool) {
|
||||
let height = self.pane_areas[pane_idx].height;
|
||||
let pane = self.pane_mut(pane_idx);
|
||||
if let Some((line, col)) = pane.mouse_to_position(rel_x, rel_y, height) {
|
||||
if start {
|
||||
pane.start_selection(line, col);
|
||||
} else {
|
||||
pane.extend_selection(line, col);
|
||||
}
|
||||
}
|
||||
self.copy_selection_to_clipboard();
|
||||
}
|
||||
|
||||
/// Draw the main (F1) screen — four-pane layout with status bar.
|
||||
fn draw_main(&mut self, frame: &mut Frame, size: Rect, app: &App) {
|
||||
// Main layout: content area + active tools overlay + status bar
|
||||
|
|
@ -825,6 +997,11 @@ impl ScreenView for InteractScreen {
|
|||
self.textarea = new_textarea(vec![String::new()]);
|
||||
}
|
||||
}
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::SHIFT) => {
|
||||
// Ctrl+Shift+C: copy selection
|
||||
self.copy_selection_to_clipboard();
|
||||
}
|
||||
// Paste: terminal handles Ctrl+Shift+V natively via bracketed paste
|
||||
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => self.scroll_active_up(3),
|
||||
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => self.scroll_active_down(3),
|
||||
KeyCode::Up => {
|
||||
|
|
@ -862,6 +1039,9 @@ impl ScreenView for InteractScreen {
|
|||
}
|
||||
}
|
||||
Event::Mouse(mouse) => { self.handle_mouse(*mouse); }
|
||||
Event::Paste(text) => {
|
||||
self.textarea.insert_str(text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1011,8 +1191,44 @@ fn draw_conversation_pane(
|
|||
// Find visible line range
|
||||
let (first, sub_scroll, last) = visible_range(&heights, pane.scroll, inner.height);
|
||||
|
||||
// Apply selection highlighting to visible lines
|
||||
let mut visible_lines: Vec<Line<'static>> = Vec::new();
|
||||
if let Some(ref sel) = pane.selection {
|
||||
let (sl, sc, el, ec) = sel.range();
|
||||
for i in first..last {
|
||||
let line = &lines[i];
|
||||
let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
|
||||
// Check if this line is within the selection
|
||||
if i >= sl && i <= el {
|
||||
let start_col = if i == sl { sc } else { 0 };
|
||||
let end_col = if i == el { ec } else { line_text.len() };
|
||||
if start_col < end_col {
|
||||
let before = if start_col > 0 { &line_text[..start_col] } else { "" };
|
||||
let selected = &line_text[start_col..end_col];
|
||||
let after = if end_col < line_text.len() { &line_text[end_col..] } else { "" };
|
||||
let mut new_spans = Vec::new();
|
||||
if !before.is_empty() {
|
||||
new_spans.push(Span::raw(before.to_string()));
|
||||
}
|
||||
new_spans.push(Span::styled(selected.to_string(), Style::default().bg(Color::DarkGray).fg(Color::White)));
|
||||
if !after.is_empty() {
|
||||
new_spans.push(Span::raw(after.to_string()));
|
||||
}
|
||||
visible_lines.push(Line::from(new_spans).style(line.style).alignment(line.alignment.unwrap_or(ratatui::layout::Alignment::Left)));
|
||||
} else {
|
||||
visible_lines.push(line.clone());
|
||||
}
|
||||
} else {
|
||||
visible_lines.push(line.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
visible_lines = lines[first..last].to_vec();
|
||||
}
|
||||
|
||||
// Render only the visible slice — no full-content grapheme walk
|
||||
let text_para = Paragraph::new(lines[first..last].to_vec())
|
||||
let text_para = Paragraph::new(visible_lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((sub_scroll, 0));
|
||||
frame.render_widget(text_para, text_area);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue