use ratatui::buffer::Buffer;
use ratatui::crossterm::{Command, clipboard::CopyToClipboard};
use ratatui::layout::Rect;
use std::io::Write;
#[cfg(test)]
use std::path::PathBuf;
#[cfg(test)]
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(450);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DragAutoScrollDirection {
Up,
Down,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct DragAutoScroll {
pub(crate) direction: DragAutoScrollDirection,
pub(crate) column: u16,
pub(crate) row: u16,
pub(crate) last_step: Instant,
}
#[derive(Debug, Default)]
pub struct MouseSelectionState {
pub(crate) is_selecting: bool,
start: (u16, u16),
end: (u16, u16),
pub(crate) has_selection: bool,
copied: bool,
copy_requested: bool,
last_click: Option<ClickRecord>,
}
#[derive(Clone, Copy, Debug)]
struct ClickRecord {
column: u16,
row: u16,
at: Instant,
}
impl MouseSelectionState {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn start_selection(&mut self, col: u16, row: u16) {
self.is_selecting = true;
self.has_selection = false;
self.copied = false;
self.start = (col, row);
self.end = (col, row);
}
pub(crate) fn set_selection(&mut self, start: (u16, u16), end: (u16, u16)) {
self.is_selecting = false;
self.has_selection = start != end;
self.copied = false;
self.start = start;
self.end = end;
}
pub(crate) fn update_selection(&mut self, col: u16, row: u16) {
if self.is_selecting {
self.end = (col, row);
self.has_selection = true;
}
}
pub(crate) fn finish_selection(&mut self, col: u16, row: u16) {
if self.is_selecting {
self.end = (col, row);
self.is_selecting = false;
self.has_selection = self.start != self.end;
}
}
pub(crate) fn adjust_for_scroll(&mut self, row_delta: i32) {
if !self.has_selection && !self.is_selecting {
return;
}
if row_delta == 0 {
return;
}
let new_start_row = self.start.1 as i32 + row_delta;
let new_end_row = self.end.1 as i32 + row_delta;
let clamped_start = new_start_row.clamp(0, i32::from(u16::MAX));
let clamped_end = new_end_row.clamp(0, i32::from(u16::MAX));
if (new_start_row < 0 && new_end_row < 0)
|| (new_start_row > i32::from(u16::MAX) && new_end_row > i32::from(u16::MAX))
{
self.is_selecting = false;
self.has_selection = false;
self.copied = false;
self.copy_requested = false;
return;
}
self.start.1 = clamped_start as u16;
self.end.1 = clamped_end as u16;
}
pub(crate) fn clear(&mut self) {
self.is_selecting = false;
self.has_selection = false;
self.copied = false;
self.copy_requested = false;
self.last_click = None;
}
pub(crate) fn clear_click_history(&mut self) {
self.last_click = None;
}
pub(crate) fn register_click(&mut self, col: u16, row: u16, at: Instant) -> bool {
let is_double_click = self.last_click.is_some_and(|last| {
last.column == col && last.row == row && at.saturating_duration_since(last.at) <= DOUBLE_CLICK_INTERVAL
});
self.last_click = Some(ClickRecord { column: col, row, at });
is_double_click
}
fn normalized(&self) -> ((u16, u16), (u16, u16)) {
let (s, e) = (self.start, self.end);
if s.1 < e.1 || (s.1 == e.1 && s.0 <= e.0) {
(s, e)
} else {
(e, s)
}
}
pub(crate) fn extract_text(&self, buf: &Buffer, area: Rect) -> String {
if !self.has_selection && !self.is_selecting {
return String::new();
}
let area = area.intersection(buf.area);
if area.width == 0 || area.height == 0 {
return String::new();
}
let ((start_col, start_row), (end_col, end_row)) = self.normalized();
let mut result = String::new();
for row in start_row..=end_row {
if row < area.y || row >= area.bottom() {
continue;
}
let line_start = if row == start_row {
start_col.max(area.x)
} else {
area.x
};
let line_end = if row == end_row {
end_col.min(area.right())
} else {
area.right()
};
for col in line_start..line_end {
if col < area.x || col >= area.right() {
continue;
}
let cell = &buf[(col, row)];
let symbol = cell.symbol();
if !symbol.is_empty() {
result.push_str(symbol);
}
}
if row < end_row {
let trimmed = result.trim_end().len();
result.truncate(trimmed);
result.push('\n');
}
}
let trimmed = result.trim_end();
trimmed.to_string()
}
pub(crate) fn apply_highlight(&self, buf: &mut Buffer, area: Rect) {
if !self.has_selection && !self.is_selecting {
return;
}
let area = area.intersection(buf.area);
if area.width == 0 || area.height == 0 {
return;
}
let ((start_col, start_row), (end_col, end_row)) = self.normalized();
for row in start_row..=end_row {
if row < area.y || row >= area.bottom() {
continue;
}
let line_start = if row == start_row {
start_col.max(area.x)
} else {
area.x
};
let line_end = if row == end_row {
end_col.min(area.right())
} else {
area.right()
};
for col in line_start..line_end {
if col < area.x || col >= area.right() {
continue;
}
let cell = &mut buf[(col, row)];
let fg = cell.fg;
let bg = cell.bg;
cell.set_fg(bg);
cell.set_bg(fg);
}
}
}
pub(crate) fn needs_copy(&self) -> bool {
self.has_selection && !self.is_selecting && !self.copied
}
pub(crate) fn has_copy_request(&self) -> bool {
self.copy_requested
}
pub(crate) fn request_copy(&mut self) {
if self.has_selection {
self.copy_requested = true;
}
}
pub(crate) fn mark_copied(&mut self) {
self.copied = true;
}
pub fn copy_to_clipboard(text: &str) -> bool {
if text.is_empty() {
trace_clipboard("nothing to copy: empty text");
return false;
}
if Self::copy_via_native(text) {
return true;
}
let copied = copy_via_osc52(text, inside_tmux());
trace_clipboard(&format!(
"native candidates exhausted; osc52 fallback result={copied} text_bytes={}",
text.len()
));
copied
}
fn copy_via_native(text: &str) -> bool {
use std::process::Command;
#[cfg(test)]
if let Some(program) = clipboard_command_override() {
return spawn_clipboard_command(Command::new(program), text);
}
let candidates: &[&str] = if cfg!(target_os = "macos") {
&["pbcopy"]
} else if cfg!(target_os = "linux") {
&["xclip", "xsel", "wl-copy"]
} else if cfg!(target_os = "windows") {
&["clip.exe"]
} else {
&[]
};
for program in candidates {
let mut cmd = Command::new(program);
match *program {
"xclip" => {
cmd.arg("-selection").arg("clipboard");
}
"xsel" => {
cmd.arg("--clipboard").arg("--input");
}
_ => {}
}
if spawn_clipboard_command(cmd, text) {
trace_clipboard(&format!("native '{program}' succeeded ({} bytes)", text.len()));
return true;
}
}
trace_clipboard("all native clipboard candidates failed or missing");
false
}
}
fn trace_clipboard(message: &str) {
tracing::debug!(target: "vtcode_ui::clipboard", "{message}");
}
fn spawn_clipboard_command(mut cmd: std::process::Command, text: &str) -> bool {
use std::process::Stdio;
const WAIT_BUDGET: Duration = Duration::from_millis(300);
const POLL_INTERVAL: Duration = Duration::from_millis(10);
const WRITE_REPORT_GRACE: Duration = Duration::from_millis(25);
let program = cmd.get_program().to_string_lossy().into_owned();
let Ok(mut child) = cmd.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else {
trace_clipboard(&format!("'{program}' unavailable: spawn failed"));
return false;
};
let mut stdin = child.stdin.take();
let text_owned = text.to_owned();
let (write_tx, write_rx) = std::sync::mpsc::channel();
let _ = std::thread::spawn(move || {
let result = match stdin.as_mut() {
Some(pipe) => pipe.write_all(text_owned.as_bytes()).err(),
None => None,
};
drop(stdin.take());
let _ = write_tx.send(result);
});
let mut write_error = None;
let deadline = Instant::now() + WAIT_BUDGET;
loop {
if let Ok(report) = write_rx.try_recv() {
write_error = report;
}
match child.try_wait() {
Ok(Some(status)) => {
let flush_deadline = Instant::now() + WRITE_REPORT_GRACE;
while write_error.is_none() {
match write_rx.try_recv() {
Ok(report) => write_error = report,
Err(std::sync::mpsc::TryRecvError::Empty) => {
if Instant::now() >= flush_deadline {
break;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
}
}
let ok = status.success() && write_error.is_none();
if !ok {
if let Some(err) = write_error {
trace_clipboard(&format!("'{program}' stdin write failed: {err}; status={status}"));
} else {
trace_clipboard(&format!("'{program}' failed: status={status}"));
}
}
return ok;
}
Ok(None) => {
if Instant::now() >= deadline {
let ok = write_error.is_none();
trace_clipboard(&format!(
"'{program}' still running after {WAIT_BUDGET:?}; input {}",
if ok { "accepted" } else { "failed" }
));
let _ = std::thread::spawn(move || {
let mut child = child;
let _ = child.wait();
});
return ok;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(err) => {
trace_clipboard(&format!("'{program}' wait failed: {err}"));
let _ = std::thread::spawn(move || {
let mut child = child;
let _ = child.wait();
});
return false;
}
}
}
}
fn inside_tmux() -> bool {
std::env::var_os("TMUX").is_some_and(|value| !value.is_empty())
}
fn wrap_tmux_passthrough(sequence: &str) -> String {
format!("\x1bPtmux;\x1b{}\x1b\\", sequence.replace('\x1b', "\x1b\x1b"))
}
fn build_osc52_payload(text: &str, inside_tmux: bool) -> Option<String> {
let mut sequence = String::new();
CopyToClipboard::to_clipboard_from(text.as_bytes())
.write_ansi(&mut sequence)
.ok()?;
Some(if inside_tmux {
wrap_tmux_passthrough(&sequence)
} else {
sequence
})
}
fn copy_via_osc52(text: &str, inside_tmux: bool) -> bool {
#[cfg(test)]
if let Some(forced) = osc52_write_override() {
return forced;
}
let Some(payload) = build_osc52_payload(text, inside_tmux) else {
return false;
};
let mut stderr = std::io::stderr();
let written = stderr.write_all(payload.as_bytes()).is_ok() && stderr.flush().is_ok();
trace_clipboard(&format!("osc52 written={written} payload_bytes={} tmux_passthrough={inside_tmux}", payload.len()));
written
}
#[cfg(test)]
static CLIPBOARD_COMMAND_OVERRIDE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
#[cfg(test)]
static OSC52_WRITE_OVERRIDE: OnceLock<Mutex<Option<bool>>> = OnceLock::new();
#[cfg(test)]
pub(crate) fn set_osc52_write_override(result: Option<bool>) {
let lock = OSC52_WRITE_OVERRIDE.get_or_init(|| Mutex::new(None));
if let Ok(mut guard) = lock.lock() {
*guard = result;
}
}
#[cfg(test)]
fn osc52_write_override() -> Option<bool> {
let lock = OSC52_WRITE_OVERRIDE.get_or_init(|| Mutex::new(None));
lock.lock().ok().and_then(|guard| *guard)
}
#[cfg(test)]
pub(crate) fn set_clipboard_command_override(path: Option<PathBuf>) {
let lock = CLIPBOARD_COMMAND_OVERRIDE.get_or_init(|| Mutex::new(None));
if let Ok(mut guard) = lock.lock() {
*guard = path;
}
}
#[cfg(test)]
pub(crate) fn clipboard_command_override() -> Option<PathBuf> {
let lock = CLIPBOARD_COMMAND_OVERRIDE.get_or_init(|| Mutex::new(None));
match lock.lock() {
Ok(guard) => guard.clone(),
Err(_) => None,
}
}
pub(crate) fn word_selection_range(text: &str, column: u16) -> Option<(u16, u16)> {
if text.is_empty() {
return None;
}
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return None;
}
let line_width = UnicodeWidthStr::width(text);
if usize::from(column) >= line_width {
return None;
}
let mut consumed = 0usize;
let mut char_index = 0usize;
for ch in &chars {
let width = UnicodeWidthChar::width(*ch).unwrap_or(0);
if consumed.saturating_add(width) > usize::from(column) {
break;
}
consumed = consumed.saturating_add(width);
char_index += 1;
}
if char_index >= chars.len() || chars[char_index].is_whitespace() {
return None;
}
let mut start = char_index;
while start > 0 && !chars[start - 1].is_whitespace() {
start -= 1;
}
let mut end = char_index + 1;
while end < chars.len() && !chars[end].is_whitespace() {
end += 1;
}
Some((display_width_for_char_count(&chars, start), display_width_for_char_count(&chars, end)))
}
fn display_width_for_char_count(chars: &[char], char_count: usize) -> u16 {
chars
.iter()
.take(char_count)
.map(|ch| UnicodeWidthChar::width(*ch).unwrap_or(0) as u16)
.fold(0_u16, u16::saturating_add)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
use std::time::{Duration, Instant};
#[test]
fn extract_text_clamps_area_to_buffer_bounds() {
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
buf[(0, 0)].set_symbol("A");
buf[(1, 0)].set_symbol("B");
buf[(0, 1)].set_symbol("C");
buf[(1, 1)].set_symbol("D");
let mut selection = MouseSelectionState::new();
selection.start_selection(0, 0);
selection.finish_selection(5, 5);
let text = selection.extract_text(&buf, Rect::new(0, 0, 10, 10));
assert_eq!(text, "AB\nCD");
}
#[test]
fn apply_highlight_clamps_area_to_buffer_bounds() {
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
buf[(0, 0)].set_fg(Color::Red);
buf[(0, 0)].set_bg(Color::Blue);
let mut selection = MouseSelectionState::new();
selection.start_selection(0, 0);
selection.finish_selection(5, 5);
selection.apply_highlight(&mut buf, Rect::new(0, 0, 10, 10));
assert_eq!(buf[(0, 0)].fg, Color::Blue);
assert_eq!(buf[(0, 0)].bg, Color::Red);
}
#[test]
fn word_selection_range_selects_clicked_word() {
assert_eq!(word_selection_range("hello world", 1), Some((0, 5)));
assert_eq!(word_selection_range("hello world", 7), Some((6, 11)));
}
#[test]
fn word_selection_range_returns_none_for_whitespace() {
assert_eq!(word_selection_range("hello world", 5), None);
}
#[test]
fn adjust_for_scroll_shifts_rows() {
let mut sel = MouseSelectionState::new();
sel.set_selection((2, 5), (10, 8));
sel.adjust_for_scroll(3);
assert_eq!(sel.start, (2, 8));
assert_eq!(sel.end, (10, 11));
assert!(sel.has_selection);
}
#[test]
fn adjust_for_scroll_negative() {
let mut sel = MouseSelectionState::new();
sel.set_selection((0, 10), (5, 15));
sel.adjust_for_scroll(-4);
assert_eq!(sel.start, (0, 6));
assert_eq!(sel.end, (5, 11));
}
#[test]
fn adjust_for_scroll_clears_when_offscreen() {
let mut sel = MouseSelectionState::new();
sel.set_selection((0, 2), (5, 4));
sel.adjust_for_scroll(-10);
assert!(!sel.has_selection);
assert!(!sel.is_selecting);
}
#[test]
fn adjust_for_scroll_noop_without_selection() {
let mut sel = MouseSelectionState::new();
sel.adjust_for_scroll(5);
assert!(!sel.has_selection);
}
#[test]
fn register_click_detects_double_clicks_at_same_position() {
let mut selection = MouseSelectionState::new();
let now = Instant::now();
assert!(!selection.register_click(3, 7, now));
assert!(selection.register_click(3, 7, now + Duration::from_millis(250)));
assert!(!selection.register_click(4, 7, now + Duration::from_millis(250)));
}
#[test]
fn spawn_clipboard_command_requires_success_exit_status() {
let failing = {
let mut cmd = std::process::Command::new("sh");
cmd.arg("-c").arg("exit 1");
cmd
};
assert!(!spawn_clipboard_command(failing, "hello"));
let succeeding = {
let mut cmd = std::process::Command::new("sh");
cmd.arg("-c").arg("cat > /dev/null");
cmd
};
assert!(spawn_clipboard_command(succeeding, "hello"));
}
#[test]
fn spawn_clipboard_command_treats_hanging_helper_as_success() {
let hanging = {
let mut cmd = std::process::Command::new("sh");
cmd.arg("-c").arg("cat > /dev/null & sleep 10");
cmd
};
let start = Instant::now();
assert!(spawn_clipboard_command(hanging, "hello"));
assert!(
start.elapsed() < Duration::from_secs(2),
"bounded wait must return promptly, took {:?}",
start.elapsed()
);
}
#[test]
fn spawn_clipboard_command_large_write_to_non_reading_helper_returns_promptly() {
let non_reading = {
let mut cmd = std::process::Command::new("sh");
cmd.arg("-c").arg("sleep 10");
cmd
};
let big: String = "x".repeat(128 * 1024);
let start = Instant::now();
let result = spawn_clipboard_command(non_reading, &big);
assert!(start.elapsed() < Duration::from_secs(2), "must not block on the writer, took {:?}", start.elapsed());
assert!(result);
}
#[test]
fn build_osc52_payload_emits_base64_clipboard_sequence() {
let payload = build_osc52_payload("hi", false).expect("osc52 payload");
assert!(payload.starts_with("\x1b]52;c;"), "unexpected payload: {payload:?}");
assert!(payload.ends_with("\x1b\\"));
assert!(payload.contains("aGk="));
}
#[test]
fn build_osc52_payload_wraps_in_tmux_passthrough() {
let plain = build_osc52_payload("hi", false).expect("plain payload");
let wrapped = build_osc52_payload("hi", true).expect("wrapped payload");
assert_eq!(wrapped, format!("\x1bPtmux;\x1b{}\x1b\\", plain.replace('\x1b', "\x1b\x1b")));
assert!(wrapped.starts_with("\x1bPtmux;\x1b\x1b\x1b]52;c;"));
assert!(wrapped.ends_with("\x1b\x1b\\\x1b\\"));
}
#[test]
fn wrap_tmux_passthrough_doubles_every_escape() {
assert_eq!(wrap_tmux_passthrough("\x1ba\x1bb"), "\x1bPtmux;\x1b\x1b\x1ba\x1b\x1bb\x1b\\");
}
}