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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"crossterm_winapi",
|
"crossterm_winapi",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
crossterm = { version = "0.29", features = ["event-stream", "bracketed-paste", "osc52"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
figment = { version = "0.10", features = ["env"] }
|
figment = { version = "0.10", features = ["env"] }
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
|
|
@ -30,7 +31,6 @@ serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
json5 = "1.3"
|
json5 = "1.3"
|
||||||
|
|
||||||
crossterm = { version = "0.29", features = ["event-stream"] }
|
|
||||||
ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] }
|
ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] }
|
||||||
tui-markdown = { git = "https://github.com/koverstreet/tui-markdown", subdirectory = "tui-markdown" }
|
tui-markdown = { git = "https://github.com/koverstreet/tui-markdown", subdirectory = "tui-markdown" }
|
||||||
tui-textarea = { version = "0.10.2", package = "tui-textarea-2" }
|
tui-textarea = { version = "0.10.2", package = "tui-textarea-2" }
|
||||||
|
|
|
||||||
|
|
@ -469,6 +469,7 @@ impl Agent {
|
||||||
) {
|
) {
|
||||||
let mut nodes = Vec::new();
|
let mut nodes = Vec::new();
|
||||||
for (call, output) in &results {
|
for (call, output) in &results {
|
||||||
|
if call.name == "yield_to_user" { continue; }
|
||||||
ds.had_tool_calls = true;
|
ds.had_tool_calls = true;
|
||||||
if output.starts_with("Error:") { ds.tool_errors += 1; }
|
if output.starts_with("Error:") { ds.tool_errors += 1; }
|
||||||
nodes.push(Self::make_tool_result_node(call, output));
|
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",
|
Tool { name: "yield_to_user",
|
||||||
description: "Wait for user input before continuing. The only way to enter a waiting state.",
|
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"}}}"#,
|
parameters_json: r#"{"type":"object","properties":{}}"#,
|
||||||
handler: Arc::new(|agent, v| Box::pin(async move {
|
handler: Arc::new(|agent, _| Box::pin(async move {
|
||||||
let msg = v.get("message").and_then(|v| v.as_str()).unwrap_or("Waiting for input.");
|
|
||||||
if let Some(agent) = agent {
|
if let Some(agent) = agent {
|
||||||
let mut a = agent.state.lock().await;
|
agent.state.lock().await.pending_yield = true;
|
||||||
a.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()
|
entry.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"yield_to_user" => args["message"]
|
"yield_to_user" => String::new(),
|
||||||
.as_str()
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string(),
|
|
||||||
"switch_model" => args["model"]
|
"switch_model" => args["model"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
|
|
|
||||||
|
|
@ -494,6 +494,7 @@ impl Mind {
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut cmds = Vec::new();
|
let mut cmds = Vec::new();
|
||||||
|
let mut dmn_expired = false;
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
biased;
|
biased;
|
||||||
|
|
@ -526,17 +527,15 @@ impl Mind {
|
||||||
}
|
}
|
||||||
|
|
||||||
cmds.push(MindCommand::Compact);
|
cmds.push(MindCommand::Compact);
|
||||||
|
/*
|
||||||
|
* Broken since the AST context window conversion:
|
||||||
if !self.config.no_agents {
|
if !self.config.no_agents {
|
||||||
cmds.push(MindCommand::Score);
|
cmds.push(MindCommand::Score);
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = tokio::time::sleep(timeout), if !has_input => {
|
_ = tokio::time::sleep(timeout), if !has_input => dmn_expired = true,
|
||||||
let tick = self.shared.lock().unwrap().dmn_tick();
|
|
||||||
if let Some((prompt, target)) = tick {
|
|
||||||
self.start_turn(&prompt, target).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.config.no_agents {
|
if !self.config.no_agents {
|
||||||
|
|
@ -562,6 +561,14 @@ impl Mind {
|
||||||
if let Some(text) = pending {
|
if let Some(text) = pending {
|
||||||
self.start_turn(&text, StreamTarget::Conversation).await;
|
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;
|
self.run_commands(cmds).await;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,8 @@ impl State {
|
||||||
/// How long to wait before the next DMN prompt in this state.
|
/// How long to wait before the next DMN prompt in this state.
|
||||||
pub fn interval(&self) -> Duration {
|
pub fn interval(&self) -> Duration {
|
||||||
match self {
|
match self {
|
||||||
State::Engaged => Duration::from_secs(5),
|
|
||||||
State::Working => Duration::from_secs(3),
|
State::Working => Duration::from_secs(3),
|
||||||
|
State::Engaged => Duration::from_secs(5),
|
||||||
State::Foraging => Duration::from_secs(30),
|
State::Foraging => Duration::from_secs(30),
|
||||||
State::Resting { .. } => Duration::from_secs(300),
|
State::Resting { .. } => Duration::from_secs(300),
|
||||||
State::Paused | State::Off => Duration::from_secs(86400), // effectively never
|
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},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Paragraph, Wrap},
|
widgets::{Block, Borders, Paragraph, Wrap},
|
||||||
Frame,
|
Frame,
|
||||||
crossterm::event::{KeyCode, KeyModifiers, MouseEvent, MouseEventKind, MouseButton},
|
|
||||||
};
|
};
|
||||||
|
use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseEvent, MouseEventKind, MouseButton};
|
||||||
|
|
||||||
|
|
||||||
use super::{App, ScreenView, screen_legend};
|
use super::{App, ScreenView, screen_legend};
|
||||||
use crate::agent::context::{AstNode, NodeBody, Role, Ast};
|
use crate::agent::context::{AstNode, NodeBody, Role, Ast};
|
||||||
|
|
@ -158,6 +159,56 @@ enum ActivePane {
|
||||||
Tools,
|
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 {
|
fn strip_ansi(text: &str) -> String {
|
||||||
let mut out = String::with_capacity(text.len());
|
let mut out = String::with_capacity(text.len());
|
||||||
let mut chars = text.chars().peekable();
|
let mut chars = text.chars().peekable();
|
||||||
|
|
@ -226,6 +277,7 @@ struct PaneState {
|
||||||
pinned: bool,
|
pinned: bool,
|
||||||
last_total_lines: u16,
|
last_total_lines: u16,
|
||||||
last_height: u16,
|
last_height: u16,
|
||||||
|
selection: Option<Selection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PaneState {
|
impl PaneState {
|
||||||
|
|
@ -237,6 +289,7 @@ impl PaneState {
|
||||||
md_buffer: String::new(), use_markdown,
|
md_buffer: String::new(), use_markdown,
|
||||||
pending_marker: Marker::None, scroll: 0, pinned: false,
|
pending_marker: Marker::None, scroll: 0, pinned: false,
|
||||||
last_total_lines: 0, last_height: 20,
|
last_total_lines: 0, last_height: 20,
|
||||||
|
selection: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -352,6 +405,56 @@ impl PaneState {
|
||||||
}
|
}
|
||||||
(lines, markers)
|
(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 {
|
pub(crate) struct InteractScreen {
|
||||||
|
|
@ -610,14 +713,83 @@ impl InteractScreen {
|
||||||
for (i, area) in self.pane_areas.iter().enumerate() {
|
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 {
|
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 };
|
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;
|
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.
|
/// Draw the main (F1) screen — four-pane layout with status bar.
|
||||||
fn draw_main(&mut self, frame: &mut Frame, size: Rect, app: &App) {
|
fn draw_main(&mut self, frame: &mut Frame, size: Rect, app: &App) {
|
||||||
// Main layout: content area + active tools overlay + status bar
|
// Main layout: content area + active tools overlay + status bar
|
||||||
|
|
@ -825,6 +997,11 @@ impl ScreenView for InteractScreen {
|
||||||
self.textarea = new_textarea(vec![String::new()]);
|
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::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::Down if key.modifiers.contains(KeyModifiers::CONTROL) => self.scroll_active_down(3),
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
|
|
@ -862,6 +1039,9 @@ impl ScreenView for InteractScreen {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Mouse(mouse) => { self.handle_mouse(*mouse); }
|
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
|
// Find visible line range
|
||||||
let (first, sub_scroll, last) = visible_range(&heights, pane.scroll, inner.height);
|
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
|
// 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 })
|
.wrap(Wrap { trim: false })
|
||||||
.scroll((sub_scroll, 0));
|
.scroll((sub_scroll, 0));
|
||||||
frame.render_widget(text_para, text_area);
|
frame.render_widget(text_para, text_area);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ use crate::user::{self as tui};
|
||||||
// --- TUI infrastructure (moved from tui/mod.rs) ---
|
// --- TUI infrastructure (moved from tui/mod.rs) ---
|
||||||
|
|
||||||
use ratatui::crossterm::{
|
use ratatui::crossterm::{
|
||||||
event::{EnableMouseCapture, DisableMouseCapture},
|
event::{EnableMouseCapture, DisableMouseCapture, EnableBracketedPaste, DisableBracketedPaste},
|
||||||
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
ExecutableCommand,
|
ExecutableCommand,
|
||||||
};
|
};
|
||||||
|
|
@ -98,6 +98,7 @@ struct ChannelStatus {
|
||||||
struct App {
|
struct App {
|
||||||
status: StatusInfo,
|
status: StatusInfo,
|
||||||
activity: String,
|
activity: String,
|
||||||
|
activity_started: Option<std::time::Instant>,
|
||||||
running_processes: u32,
|
running_processes: u32,
|
||||||
reasoning_effort: String,
|
reasoning_effort: String,
|
||||||
temperature: f32,
|
temperature: f32,
|
||||||
|
|
@ -125,6 +126,7 @@ impl App {
|
||||||
turn_tools: 0, context_budget: String::new(),
|
turn_tools: 0, context_budget: String::new(),
|
||||||
},
|
},
|
||||||
activity: String::new(),
|
activity: String::new(),
|
||||||
|
activity_started: None,
|
||||||
running_processes: 0,
|
running_processes: 0,
|
||||||
reasoning_effort: "none".to_string(),
|
reasoning_effort: "none".to_string(),
|
||||||
temperature: 0.6,
|
temperature: 0.6,
|
||||||
|
|
@ -164,12 +166,14 @@ fn init_terminal() -> io::Result<ratatui::Terminal<CrosstermBackend<io::Stdout>>
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
stdout.execute(EnterAlternateScreen)?;
|
stdout.execute(EnterAlternateScreen)?;
|
||||||
stdout.execute(EnableMouseCapture)?;
|
stdout.execute(EnableMouseCapture)?;
|
||||||
|
stdout.execute(EnableBracketedPaste)?;
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
ratatui::Terminal::new(backend)
|
ratatui::Terminal::new(backend)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_terminal(terminal: &mut ratatui::Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
|
fn restore_terminal(terminal: &mut ratatui::Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
|
||||||
terminal::disable_raw_mode()?;
|
terminal::disable_raw_mode()?;
|
||||||
|
terminal.backend_mut().execute(DisableBracketedPaste)?;
|
||||||
terminal.backend_mut().execute(DisableMouseCapture)?;
|
terminal.backend_mut().execute(DisableMouseCapture)?;
|
||||||
terminal.backend_mut().execute(LeaveAlternateScreen)?;
|
terminal.backend_mut().execute(LeaveAlternateScreen)?;
|
||||||
terminal.show_cursor()
|
terminal.show_cursor()
|
||||||
|
|
@ -319,7 +323,7 @@ async fn run(
|
||||||
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
|
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
match crossterm::event::read() {
|
match ratatui::crossterm::event::read() {
|
||||||
Ok(event) => { if event_tx.send(event).is_err() { break; } }
|
Ok(event) => { if event_tx.send(event).is_err() { break; } }
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue