diff --git a/Cargo.lock b/Cargo.lock index f4744b0..f4e8519 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 5e3e1cf..64dbf8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/src/agent/mod.rs b/src/agent/mod.rs index da1816b..695569f 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -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)); diff --git a/src/agent/tools/control.rs b/src/agent/tools/control.rs index 3dff813..6090f43 100644 --- a/src/agent/tools/control.rs +++ b/src/agent/tools/control.rs @@ -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()) })) }, ] } diff --git a/src/agent/tools/mod.rs b/src/agent/tools/mod.rs index 41d0e41..55fe311 100644 --- a/src/agent/tools/mod.rs +++ b/src/agent/tools/mod.rs @@ -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("") diff --git a/src/mind/mod.rs b/src/mind/mod.rs index 8426ef1..2241ea2 100644 --- a/src/mind/mod.rs +++ b/src/mind/mod.rs @@ -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; } diff --git a/src/mind/subconscious.rs b/src/mind/subconscious.rs index a4cc7d9..a35d586 100644 --- a/src/mind/subconscious.rs +++ b/src/mind/subconscious.rs @@ -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 diff --git a/src/user/chat.rs b/src/user/chat.rs index d91576c..800bf2a 100644 --- a/src/user/chat.rs +++ b/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, } 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 = 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 { + 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> = 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); diff --git a/src/user/mod.rs b/src/user/mod.rs index 94a507e..9ec1de6 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -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, 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> 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>) -> 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, }