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
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})) },
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue