1use ratatui::buffer::Buffer;
2use ratatui::crossterm::{Command, clipboard::CopyToClipboard};
3use ratatui::layout::Rect;
4use std::io::Write;
5#[cfg(test)]
6use std::path::PathBuf;
7#[cfg(test)]
8use std::sync::{Mutex, OnceLock};
9use std::time::{Duration, Instant};
10use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
11
12const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(450);
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub(crate) enum DragAutoScrollDirection {
17 Up,
18 Down,
19}
20
21#[derive(Debug, Clone, Copy)]
23pub(crate) struct DragAutoScroll {
24 pub(crate) direction: DragAutoScrollDirection,
25 pub(crate) column: u16,
28 pub(crate) row: u16,
29 pub(crate) last_step: Instant,
30}
31
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33enum SelectionSurface {
34 #[default]
35 Transcript,
36 Overlay,
37}
38
39#[derive(Debug, Default)]
41pub struct MouseSelectionState {
42 pub(crate) is_selecting: bool,
44 surface: SelectionSurface,
46 start: (u16, u16),
48 end: (u16, u16),
50 pub(crate) has_selection: bool,
52 copied: bool,
54 copy_requested: bool,
56 last_click: Option<ClickRecord>,
58}
59
60#[derive(Clone, Copy, Debug)]
61struct ClickRecord {
62 column: u16,
63 row: u16,
64 at: Instant,
65}
66
67impl MouseSelectionState {
68 pub(crate) fn new() -> Self {
69 Self::default()
70 }
71
72 pub(crate) fn start_selection(&mut self, col: u16, row: u16) {
74 self.surface = SelectionSurface::Transcript;
75 self.is_selecting = true;
76 self.has_selection = false;
77 self.copied = false;
78 self.start = (col, row);
79 self.end = (col, row);
80 }
81
82 pub(crate) fn start_overlay_selection(&mut self, col: u16, row: u16) {
84 self.start_selection(col, row);
85 self.surface = SelectionSurface::Overlay;
86 }
87
88 pub(crate) fn is_transcript_selection(&self) -> bool {
89 self.surface == SelectionSurface::Transcript
90 }
91
92 pub(crate) fn set_selection(&mut self, start: (u16, u16), end: (u16, u16)) {
94 self.surface = SelectionSurface::Transcript;
95 self.is_selecting = false;
96 self.has_selection = start != end;
97 self.copied = false;
98 self.start = start;
99 self.end = end;
100 }
101
102 pub(crate) fn update_selection(&mut self, col: u16, row: u16) {
104 if self.is_selecting {
105 self.end = (col, row);
106 self.has_selection = true;
107 }
108 }
109
110 pub(crate) fn finish_selection(&mut self, col: u16, row: u16) {
112 if self.is_selecting {
113 self.end = (col, row);
114 self.is_selecting = false;
115 self.has_selection = self.start != self.end;
117 }
118 }
119
120 pub(crate) fn adjust_for_scroll(&mut self, row_delta: i32) {
128 if !self.is_transcript_selection() || (!self.has_selection && !self.is_selecting) {
129 return;
130 }
131 if row_delta == 0 {
132 return;
133 }
134
135 let new_start_row = self.start.1 as i32 + row_delta;
136 let new_end_row = self.end.1 as i32 + row_delta;
137
138 let clamped_start = new_start_row.clamp(0, i32::from(u16::MAX));
144 let clamped_end = new_end_row.clamp(0, i32::from(u16::MAX));
145
146 if (new_start_row < 0 && new_end_row < 0)
149 || (new_start_row > i32::from(u16::MAX) && new_end_row > i32::from(u16::MAX))
150 {
151 self.is_selecting = false;
152 self.has_selection = false;
153 self.copied = false;
154 self.copy_requested = false;
155 return;
156 }
157
158 self.start.1 = clamped_start as u16;
159 self.end.1 = clamped_end as u16;
160 }
161
162 pub(crate) fn clear(&mut self) {
164 self.surface = SelectionSurface::Transcript;
165 self.is_selecting = false;
166 self.has_selection = false;
167 self.copied = false;
168 self.copy_requested = false;
169 self.last_click = None;
170 }
171
172 pub(crate) fn clear_click_history(&mut self) {
174 self.last_click = None;
175 }
176
177 pub(crate) fn register_click(&mut self, col: u16, row: u16, at: Instant) -> bool {
180 let is_double_click = self.last_click.is_some_and(|last| {
181 last.column == col && last.row == row && at.saturating_duration_since(last.at) <= DOUBLE_CLICK_INTERVAL
182 });
183
184 self.last_click = Some(ClickRecord { column: col, row, at });
185 is_double_click
186 }
187
188 fn normalized(&self) -> ((u16, u16), (u16, u16)) {
190 let (s, e) = (self.start, self.end);
191 if s.1 < e.1 || (s.1 == e.1 && s.0 <= e.0) {
192 (s, e)
193 } else {
194 (e, s)
195 }
196 }
197
198 pub(crate) fn extract_text(&self, buf: &Buffer, area: Rect) -> String {
200 if !self.has_selection && !self.is_selecting {
201 return String::new();
202 }
203
204 let area = area.intersection(buf.area);
206 if area.width == 0 || area.height == 0 {
207 return String::new();
208 }
209
210 let ((start_col, start_row), (end_col, end_row)) = self.normalized();
211 let mut result = String::new();
212
213 for row in start_row..=end_row {
214 if row < area.y || row >= area.bottom() {
215 continue;
216 }
217 let line_start = if row == start_row {
218 start_col.max(area.x)
219 } else {
220 area.x
221 };
222 let line_end = if row == end_row {
223 end_col.min(area.right())
224 } else {
225 area.right()
226 };
227
228 for col in line_start..line_end {
229 if col < area.x || col >= area.right() {
230 continue;
231 }
232 let cell = &buf[(col, row)];
233 let symbol = cell.symbol();
234 if !symbol.is_empty() {
235 result.push_str(symbol);
236 }
237 }
238
239 if row < end_row {
241 let trimmed = result.trim_end().len();
243 result.truncate(trimmed);
244 result.push('\n');
245 }
246 }
247
248 let trimmed = result.trim_end();
250 trimmed.to_string()
251 }
252
253 pub(crate) fn apply_highlight(&self, buf: &mut Buffer, area: Rect) {
255 if !self.has_selection && !self.is_selecting {
256 return;
257 }
258
259 let area = area.intersection(buf.area);
261 if area.width == 0 || area.height == 0 {
262 return;
263 }
264
265 let ((start_col, start_row), (end_col, end_row)) = self.normalized();
266
267 for row in start_row..=end_row {
268 if row < area.y || row >= area.bottom() {
269 continue;
270 }
271 let line_start = if row == start_row {
272 start_col.max(area.x)
273 } else {
274 area.x
275 };
276 let line_end = if row == end_row {
277 end_col.min(area.right())
278 } else {
279 area.right()
280 };
281
282 for col in line_start..line_end {
283 if col < area.x || col >= area.right() {
284 continue;
285 }
286 let cell = &mut buf[(col, row)];
287 let fg = cell.fg;
289 let bg = cell.bg;
290 cell.set_fg(bg);
291 cell.set_bg(fg);
292 }
293 }
294 }
295
296 pub(crate) fn needs_copy(&self) -> bool {
298 self.has_selection && !self.is_selecting && !self.copied
299 }
300
301 pub(crate) fn has_copy_request(&self) -> bool {
303 self.copy_requested
304 }
305
306 pub(crate) fn request_copy(&mut self) {
308 if self.has_selection {
309 self.copy_requested = true;
310 }
311 }
312
313 pub(crate) fn mark_copied(&mut self) {
315 self.copied = true;
316 }
317
318 pub fn copy_to_clipboard(text: &str) -> bool {
329 if text.is_empty() {
330 trace_clipboard("nothing to copy: empty text");
331 return false;
332 }
333
334 if Self::copy_via_native(text) {
335 return true;
336 }
337
338 let copied = copy_via_osc52(text, inside_tmux());
339 trace_clipboard(&format!(
340 "native candidates exhausted; osc52 fallback result={copied} text_bytes={}",
341 text.len()
342 ));
343 copied
344 }
345
346 fn copy_via_native(text: &str) -> bool {
349 use std::process::Command;
350
351 #[cfg(test)]
352 if let Some(program) = clipboard_command_override() {
353 return spawn_clipboard_command(Command::new(program), text);
354 }
355
356 let candidates: &[&str] = if cfg!(target_os = "macos") {
357 &["pbcopy"]
358 } else if cfg!(target_os = "linux") {
359 &["xclip", "xsel", "wl-copy"]
360 } else if cfg!(target_os = "windows") {
361 &["clip.exe"]
362 } else {
363 &[]
364 };
365
366 for program in candidates {
367 let mut cmd = Command::new(program);
368 match *program {
369 "xclip" => {
370 cmd.arg("-selection").arg("clipboard");
371 }
372 "xsel" => {
373 cmd.arg("--clipboard").arg("--input");
374 }
375 _ => {}
376 }
377 if spawn_clipboard_command(cmd, text) {
378 trace_clipboard(&format!("native '{program}' succeeded ({} bytes)", text.len()));
379 return true;
380 }
381 }
382 trace_clipboard("all native clipboard candidates failed or missing");
383 false
384 }
385}
386
387fn trace_clipboard(message: &str) {
389 tracing::debug!(target: "vtcode_ui::clipboard", "{message}");
390}
391
392fn reap_in_background(mut child: std::process::Child) {
394 let _ = std::thread::spawn(move || {
395 let _ = child.wait();
396 });
397}
398
399fn spawn_clipboard_command(mut cmd: std::process::Command, text: &str) -> bool {
400 use std::process::Stdio;
401
402 const WAIT_BUDGET: Duration = Duration::from_millis(300);
409 const POLL_INTERVAL: Duration = Duration::from_millis(10);
410 const WRITE_REPORT_GRACE: Duration = Duration::from_millis(100);
413
414 let program = cmd.get_program().to_string_lossy().into_owned();
415 let Ok(mut child) = cmd.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else {
416 trace_clipboard(&format!("'{program}' unavailable: spawn failed"));
417 return false;
418 };
419 let mut stdin = child.stdin.take();
424 let text_owned = text.to_owned();
425 let (write_tx, write_rx) = std::sync::mpsc::channel();
426 let _ = std::thread::spawn(move || {
427 let result = match stdin.as_mut() {
428 Some(pipe) => pipe.write_all(text_owned.as_bytes()).err(),
429 None => None,
430 };
431 drop(stdin.take());
432 let _ = write_tx.send(result);
433 });
434 let mut write_error = None;
437 let mut write_complete = false;
438 let deadline = Instant::now() + WAIT_BUDGET;
439 loop {
440 if let Ok(report) = write_rx.try_recv() {
441 write_error = report;
442 write_complete = true;
443 }
444 match child.try_wait() {
445 Ok(Some(status)) => {
446 let flush_deadline = Instant::now() + WRITE_REPORT_GRACE;
450 while !write_complete {
451 match write_rx.try_recv() {
452 Ok(report) => {
453 write_error = report;
454 write_complete = true;
455 }
456 Err(std::sync::mpsc::TryRecvError::Empty) => {
457 if Instant::now() >= flush_deadline {
458 break;
459 }
460 std::thread::sleep(POLL_INTERVAL);
461 }
462 Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
463 }
464 }
465 let ok = status.success() && write_error.is_none();
466 if !ok {
467 if let Some(err) = write_error {
468 trace_clipboard(&format!("'{program}' stdin write failed: {err}; status={status}"));
469 } else {
470 trace_clipboard(&format!("'{program}' failed: status={status}"));
471 }
472 }
473 return ok;
474 }
475 Ok(None) => {
476 if Instant::now() >= deadline {
477 let ok = write_error.is_none();
481 trace_clipboard(&format!(
482 "'{program}' still running after {WAIT_BUDGET:?}; input {}",
483 if ok { "accepted" } else { "failed" }
484 ));
485 reap_in_background(child);
487 return ok;
488 }
489 std::thread::sleep(POLL_INTERVAL);
490 }
491 Err(err) => {
492 trace_clipboard(&format!("'{program}' wait failed: {err}"));
493 reap_in_background(child);
494 return false;
495 }
496 }
497 }
498}
499
500fn inside_tmux() -> bool {
503 std::env::var_os("TMUX").is_some_and(|value| !value.is_empty())
504}
505
506fn wrap_tmux_passthrough(sequence: &str) -> String {
509 format!("\x1bPtmux;\x1b{}\x1b\\", sequence.replace('\x1b', "\x1b\x1b"))
510}
511
512fn build_osc52_payload(text: &str, inside_tmux: bool) -> Option<String> {
514 let mut sequence = String::new();
515 CopyToClipboard::to_clipboard_from(text.as_bytes())
516 .write_ansi(&mut sequence)
517 .ok()?;
518 Some(if inside_tmux {
519 wrap_tmux_passthrough(&sequence)
520 } else {
521 sequence
522 })
523}
524
525fn copy_via_osc52(text: &str, inside_tmux: bool) -> bool {
528 #[cfg(test)]
529 if let Some(forced) = osc52_write_override() {
530 return forced;
531 }
532
533 let Some(payload) = build_osc52_payload(text, inside_tmux) else {
534 return false;
535 };
536
537 let mut stderr = std::io::stderr();
538 let written = stderr.write_all(payload.as_bytes()).is_ok() && stderr.flush().is_ok();
539 trace_clipboard(&format!("osc52 written={written} payload_bytes={} tmux_passthrough={inside_tmux}", payload.len()));
540 written
541}
542
543#[cfg(test)]
544static CLIPBOARD_COMMAND_OVERRIDE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
545
546#[cfg(test)]
547static OSC52_WRITE_OVERRIDE: OnceLock<Mutex<Option<bool>>> = OnceLock::new();
548
549#[cfg(test)]
550pub(crate) fn set_osc52_write_override(result: Option<bool>) {
551 let lock = OSC52_WRITE_OVERRIDE.get_or_init(|| Mutex::new(None));
552 if let Ok(mut guard) = lock.lock() {
553 *guard = result;
554 }
555}
556
557#[cfg(test)]
558fn osc52_write_override() -> Option<bool> {
559 let lock = OSC52_WRITE_OVERRIDE.get_or_init(|| Mutex::new(None));
560 lock.lock().ok().and_then(|guard| *guard)
561}
562
563#[cfg(test)]
564pub(crate) fn set_clipboard_command_override(path: Option<PathBuf>) {
565 let lock = CLIPBOARD_COMMAND_OVERRIDE.get_or_init(|| Mutex::new(None));
566 if let Ok(mut guard) = lock.lock() {
567 *guard = path;
568 }
569}
570
571#[cfg(test)]
572pub(crate) fn clipboard_command_override() -> Option<PathBuf> {
573 let lock = CLIPBOARD_COMMAND_OVERRIDE.get_or_init(|| Mutex::new(None));
574 match lock.lock() {
575 Ok(guard) => guard.clone(),
576 Err(_) => None,
577 }
578}
579
580pub(crate) fn word_selection_range(text: &str, column: u16) -> Option<(u16, u16)> {
582 if text.is_empty() {
583 return None;
584 }
585
586 let chars: Vec<char> = text.chars().collect();
587 if chars.is_empty() {
588 return None;
589 }
590
591 let line_width = UnicodeWidthStr::width(text);
592 if usize::from(column) >= line_width {
593 return None;
594 }
595
596 let mut consumed = 0usize;
597 let mut char_index = 0usize;
598 for ch in &chars {
599 let width = UnicodeWidthChar::width(*ch).unwrap_or(0);
600 if consumed.saturating_add(width) > usize::from(column) {
601 break;
602 }
603 consumed = consumed.saturating_add(width);
604 char_index += 1;
605 }
606
607 if char_index >= chars.len() || chars[char_index].is_whitespace() {
608 return None;
609 }
610
611 let mut start = char_index;
612 while start > 0 && !chars[start - 1].is_whitespace() {
613 start -= 1;
614 }
615
616 let mut end = char_index + 1;
617 while end < chars.len() && !chars[end].is_whitespace() {
618 end += 1;
619 }
620
621 Some((display_width_for_char_count(&chars, start), display_width_for_char_count(&chars, end)))
622}
623
624fn display_width_for_char_count(chars: &[char], char_count: usize) -> u16 {
625 chars
626 .iter()
627 .take(char_count)
628 .map(|ch| UnicodeWidthChar::width(*ch).unwrap_or(0) as u16)
629 .fold(0_u16, u16::saturating_add)
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use ratatui::style::Color;
636 use std::time::{Duration, Instant};
637
638 #[test]
639 fn extract_text_clamps_area_to_buffer_bounds() {
640 let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
641 buf[(0, 0)].set_symbol("A");
642 buf[(1, 0)].set_symbol("B");
643 buf[(0, 1)].set_symbol("C");
644 buf[(1, 1)].set_symbol("D");
645
646 let mut selection = MouseSelectionState::new();
647 selection.start_selection(0, 0);
648 selection.finish_selection(5, 5);
649
650 let text = selection.extract_text(&buf, Rect::new(0, 0, 10, 10));
651 assert_eq!(text, "AB\nCD");
652 }
653
654 #[test]
655 fn apply_highlight_clamps_area_to_buffer_bounds() {
656 let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
657 buf[(0, 0)].set_fg(Color::Red);
658 buf[(0, 0)].set_bg(Color::Blue);
659
660 let mut selection = MouseSelectionState::new();
661 selection.start_selection(0, 0);
662 selection.finish_selection(5, 5);
663
664 selection.apply_highlight(&mut buf, Rect::new(0, 0, 10, 10));
665
666 assert_eq!(buf[(0, 0)].fg, Color::Blue);
667 assert_eq!(buf[(0, 0)].bg, Color::Red);
668 }
669
670 #[test]
671 fn word_selection_range_selects_clicked_word() {
672 assert_eq!(word_selection_range("hello world", 1), Some((0, 5)));
673 assert_eq!(word_selection_range("hello world", 7), Some((6, 11)));
674 }
675
676 #[test]
677 fn word_selection_range_returns_none_for_whitespace() {
678 assert_eq!(word_selection_range("hello world", 5), None);
679 }
680
681 #[test]
682 fn adjust_for_scroll_shifts_rows() {
683 let mut sel = MouseSelectionState::new();
684 sel.set_selection((2, 5), (10, 8));
685
686 sel.adjust_for_scroll(3);
687 assert_eq!(sel.start, (2, 8));
688 assert_eq!(sel.end, (10, 11));
689 assert!(sel.has_selection);
690 }
691
692 #[test]
693 fn adjust_for_scroll_negative() {
694 let mut sel = MouseSelectionState::new();
695 sel.set_selection((0, 10), (5, 15));
696
697 sel.adjust_for_scroll(-4);
698 assert_eq!(sel.start, (0, 6));
699 assert_eq!(sel.end, (5, 11));
700 }
701
702 #[test]
703 fn adjust_for_scroll_clears_when_offscreen() {
704 let mut sel = MouseSelectionState::new();
705 sel.set_selection((0, 2), (5, 4));
706
707 sel.adjust_for_scroll(-10);
708 assert!(!sel.has_selection);
709 assert!(!sel.is_selecting);
710 }
711
712 #[test]
713 fn adjust_for_scroll_noop_without_selection() {
714 let mut sel = MouseSelectionState::new();
715 sel.adjust_for_scroll(5);
716 assert!(!sel.has_selection);
717 }
718
719 #[test]
720 fn register_click_detects_double_clicks_at_same_position() {
721 let mut selection = MouseSelectionState::new();
722 let now = Instant::now();
723
724 assert!(!selection.register_click(3, 7, now));
725 assert!(selection.register_click(3, 7, now + Duration::from_millis(250)));
726 assert!(!selection.register_click(4, 7, now + Duration::from_millis(250)));
727 }
728
729 #[test]
730 fn spawn_clipboard_command_requires_success_exit_status() {
731 let failing = {
732 let mut cmd = std::process::Command::new("sh");
733 cmd.arg("-c").arg("exit 1");
734 cmd
735 };
736 assert!(!spawn_clipboard_command(failing, "hello"));
737
738 let succeeding = {
739 let mut cmd = std::process::Command::new("sh");
740 cmd.arg("-c").arg("cat > /dev/null");
741 cmd
742 };
743 assert!(spawn_clipboard_command(succeeding, "hello"));
744 }
745
746 #[test]
747 fn spawn_clipboard_command_treats_hanging_helper_as_success() {
748 let hanging = {
752 let mut cmd = std::process::Command::new("sh");
753 cmd.arg("-c").arg("cat > /dev/null & sleep 10");
754 cmd
755 };
756 let start = Instant::now();
757 assert!(spawn_clipboard_command(hanging, "hello"));
758 assert!(
759 start.elapsed() < Duration::from_secs(2),
760 "bounded wait must return promptly, took {:?}",
761 start.elapsed()
762 );
763 }
764
765 #[test]
766 fn spawn_clipboard_command_large_write_to_non_reading_helper_returns_promptly() {
767 let non_reading = {
771 let mut cmd = std::process::Command::new("sh");
772 cmd.arg("-c").arg("sleep 10");
773 cmd
774 };
775 let big: String = "x".repeat(128 * 1024);
776 let start = Instant::now();
777 let result = spawn_clipboard_command(non_reading, &big);
778 assert!(start.elapsed() < Duration::from_secs(2), "must not block on the writer, took {:?}", start.elapsed());
779 assert!(result);
782 }
783
784 #[test]
785 fn build_osc52_payload_emits_base64_clipboard_sequence() {
786 let payload = build_osc52_payload("hi", false).expect("osc52 payload");
787 assert!(payload.starts_with("\x1b]52;c;"), "unexpected payload: {payload:?}");
788 assert!(payload.ends_with("\x1b\\"));
789 assert!(payload.contains("aGk="));
790 }
791
792 #[test]
793 fn build_osc52_payload_wraps_in_tmux_passthrough() {
794 let plain = build_osc52_payload("hi", false).expect("plain payload");
795 let wrapped = build_osc52_payload("hi", true).expect("wrapped payload");
796
797 assert_eq!(wrapped, format!("\x1bPtmux;\x1b{}\x1b\\", plain.replace('\x1b', "\x1b\x1b")));
799 assert!(wrapped.starts_with("\x1bPtmux;\x1b\x1b\x1b]52;c;"));
800 assert!(wrapped.ends_with("\x1b\x1b\\\x1b\\"));
801 }
802
803 #[test]
804 fn wrap_tmux_passthrough_doubles_every_escape() {
805 assert_eq!(wrap_tmux_passthrough("\x1ba\x1bb"), "\x1bPtmux;\x1b\x1b\x1ba\x1b\x1bb\x1b\\");
806 }
807}