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:
ProofOfConcept 2026-04-09 18:08:07 -04:00 committed by Kent Overstreet
parent 7dd9daa2b9
commit a596e007b2
9 changed files with 246 additions and 22 deletions

1
Cargo.lock generated
View file

@ -671,6 +671,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"base64 0.22.1",
"bitflags 2.11.0",
"crossterm_winapi",
"derive_more",

View file

@ -20,6 +20,7 @@ edition.workspace = true
[dependencies]
anyhow = "1"
crossterm = { version = "0.29", features = ["event-stream", "bracketed-paste", "osc52"] }
clap = { version = "4", features = ["derive"] }
figment = { version = "0.10", features = ["env"] }
dirs = "6"
@ -30,7 +31,6 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
json5 = "1.3"
crossterm = { version = "0.29", features = ["event-stream"] }
ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] }
tui-markdown = { git = "https://github.com/koverstreet/tui-markdown", subdirectory = "tui-markdown" }
tui-textarea = { version = "0.10.2", package = "tui-textarea-2" }

View file

@ -469,6 +469,7 @@ impl Agent {
) {
let mut nodes = Vec::new();
for (call, output) in &results {
if call.name == "yield_to_user" { continue; }
ds.had_tool_calls = true;
if output.starts_with("Error:") { ds.tool_errors += 1; }
nodes.push(Self::make_tool_result_node(call, output));

View file

@ -33,14 +33,12 @@ pub(super) fn tools() -> [super::Tool; 3] {
})) },
Tool { name: "yield_to_user",
description: "Wait for user input before continuing. The only way to enter a waiting state.",
parameters_json: r#"{"type":"object","properties":{"message":{"type":"string","description":"Optional status message"}}}"#,
handler: Arc::new(|agent, v| Box::pin(async move {
let msg = v.get("message").and_then(|v| v.as_str()).unwrap_or("Waiting for input.");
parameters_json: r#"{"type":"object","properties":{}}"#,
handler: Arc::new(|agent, _| Box::pin(async move {
if let Some(agent) = agent {
let mut a = agent.state.lock().await;
a.pending_yield = true;
agent.state.lock().await.pending_yield = true;
}
Ok(format!("Yielding. {}", msg))
Ok(String::new())
})) },
]
}

View file

@ -246,10 +246,7 @@ pub fn summarize_args(tool_name: &str, args: &serde_json::Value) -> String {
entry.to_string()
}
}
"yield_to_user" => args["message"]
.as_str()
.unwrap_or("")
.to_string(),
"yield_to_user" => String::new(),
"switch_model" => args["model"]
.as_str()
.unwrap_or("")

View file

@ -494,6 +494,7 @@ impl Mind {
};
let mut cmds = Vec::new();
let mut dmn_expired = false;
tokio::select! {
biased;
@ -526,17 +527,15 @@ impl Mind {
}
cmds.push(MindCommand::Compact);
/*
* Broken since the AST context window conversion:
if !self.config.no_agents {
cmds.push(MindCommand::Score);
}
*/
}
_ = tokio::time::sleep(timeout), if !has_input => {
let tick = self.shared.lock().unwrap().dmn_tick();
if let Some((prompt, target)) = tick {
self.start_turn(&prompt, target).await;
}
}
_ = tokio::time::sleep(timeout), if !has_input => dmn_expired = true,
}
if !self.config.no_agents {
@ -562,6 +561,14 @@ impl Mind {
if let Some(text) = pending {
self.start_turn(&text, StreamTarget::Conversation).await;
}
/*
else if dmn_expired {
let tick = self.shared.lock().unwrap().dmn_tick();
if let Some((prompt, target)) = tick {
self.start_turn(&prompt, target).await;
}
}
*/
self.run_commands(cmds).await;
}

View file

@ -68,8 +68,8 @@ impl State {
/// How long to wait before the next DMN prompt in this state.
pub fn interval(&self) -> Duration {
match self {
State::Engaged => Duration::from_secs(5),
State::Working => Duration::from_secs(3),
State::Engaged => Duration::from_secs(5),
State::Foraging => Duration::from_secs(30),
State::Resting { .. } => Duration::from_secs(300),
State::Paused | State::Off => Duration::from_secs(86400), // effectively never

View file

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

View file

@ -19,7 +19,7 @@ use crate::user::{self as tui};
// --- TUI infrastructure (moved from tui/mod.rs) ---
use ratatui::crossterm::{
event::{EnableMouseCapture, DisableMouseCapture},
event::{EnableMouseCapture, DisableMouseCapture, EnableBracketedPaste, DisableBracketedPaste},
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
@ -98,6 +98,7 @@ struct ChannelStatus {
struct App {
status: StatusInfo,
activity: String,
activity_started: Option<std::time::Instant>,
running_processes: u32,
reasoning_effort: String,
temperature: f32,
@ -125,6 +126,7 @@ impl App {
turn_tools: 0, context_budget: String::new(),
},
activity: String::new(),
activity_started: None,
running_processes: 0,
reasoning_effort: "none".to_string(),
temperature: 0.6,
@ -164,12 +166,14 @@ fn init_terminal() -> io::Result<ratatui::Terminal<CrosstermBackend<io::Stdout>>
let mut stdout = io::stdout();
stdout.execute(EnterAlternateScreen)?;
stdout.execute(EnableMouseCapture)?;
stdout.execute(EnableBracketedPaste)?;
let backend = CrosstermBackend::new(stdout);
ratatui::Terminal::new(backend)
}
fn restore_terminal(terminal: &mut ratatui::Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
terminal::disable_raw_mode()?;
terminal.backend_mut().execute(DisableBracketedPaste)?;
terminal.backend_mut().execute(DisableMouseCapture)?;
terminal.backend_mut().execute(LeaveAlternateScreen)?;
terminal.show_cursor()
@ -319,7 +323,7 @@ async fn run(
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
std::thread::spawn(move || {
loop {
match crossterm::event::read() {
match ratatui::crossterm::event::read() {
Ok(event) => { if event_tx.send(event).is_err() { break; } }
Err(_) => break,
}