Skip to content

Commit 81b511f

Browse files
committed
TUI: add mouse scroll and text selection support
Enable crossterm MouseCapture to handle scroll events (3 lines per tick) and click-drag text selection with reversed-color highlighting. Selected text is auto-copied to clipboard on mouse release using platform commands (pbcopy/xclip/xsel). Selection clears on new streaming content and clicks outside the chat panel.
1 parent 2b44506 commit 81b511f

3 files changed

Lines changed: 253 additions & 19 deletions

File tree

tycode-cli/src/tui/app.rs

Lines changed: 166 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
use anyhow::Result;
22
use crossterm::{
3-
event::{EnableBracketedPaste, DisableBracketedPaste, Event as CrosstermEvent, EventStream, KeyboardEnhancementFlags, PushKeyboardEnhancementFlags, PopKeyboardEnhancementFlags},
3+
event::{
4+
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
5+
Event as CrosstermEvent, EventStream, KeyboardEnhancementFlags,
6+
MouseButton, MouseEvent, MouseEventKind, PopKeyboardEnhancementFlags,
7+
PushKeyboardEnhancementFlags,
8+
},
49
execute,
510
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
611
};
712
use futures::StreamExt;
8-
use ratatui::{backend::CrosstermBackend, Terminal};
13+
use ratatui::{backend::CrosstermBackend, layout::Position, Terminal};
914
use std::io;
1015
use std::path::PathBuf;
1116
use std::time::Duration;
@@ -92,7 +97,7 @@ impl TuiApp {
9297
// Setup terminal
9398
enable_raw_mode()?;
9499
let mut stdout = io::stdout();
95-
execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
100+
execute!(stdout, EnterAlternateScreen, EnableBracketedPaste, EnableMouseCapture)?;
96101
// Try to enable keyboard enhancement (for Shift+Enter support).
97102
// This is only supported by some terminals (Kitty, WezTerm, foot, etc.)
98103
// so we ignore failures.
@@ -116,7 +121,7 @@ impl TuiApp {
116121
let original_hook = std::panic::take_hook();
117122
std::panic::set_hook(Box::new(move |panic_info| {
118123
let _ = disable_raw_mode();
119-
let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags, DisableBracketedPaste, LeaveAlternateScreen);
124+
let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags, DisableMouseCapture, DisableBracketedPaste, LeaveAlternateScreen);
120125
original_hook(panic_info);
121126
}));
122127

@@ -151,23 +156,31 @@ impl TuiApp {
151156

152157
// Poll crossterm events (async)
153158
Some(Ok(crossterm_event)) = crossterm_reader.next() => {
154-
if let CrosstermEvent::Key(key) = crossterm_event {
155-
match handle_key_event(key, &mut textarea, &mut self.state) {
156-
TuiAction::SendMessage(text) => {
157-
self.send_user_message(&text)?;
159+
match crossterm_event {
160+
CrosstermEvent::Key(key) => {
161+
match handle_key_event(key, &mut textarea, &mut self.state) {
162+
TuiAction::SendMessage(text) => {
163+
self.send_user_message(&text)?;
164+
}
165+
TuiAction::Cancel => {
166+
self.chat_actor.cancel()?;
167+
}
168+
TuiAction::Quit => {
169+
self.state.should_quit = true;
170+
}
171+
TuiAction::None => {}
158172
}
159-
TuiAction::Cancel => {
160-
self.chat_actor.cancel()?;
161-
}
162-
TuiAction::Quit => {
163-
self.state.should_quit = true;
164-
}
165-
TuiAction::None => {}
166173
}
167-
} else if let CrosstermEvent::Paste(text) = crossterm_event {
168-
textarea.insert_str(&text);
169-
} else if let CrosstermEvent::Resize(_, _) = crossterm_event {
170-
// Terminal will re-render on next loop iteration
174+
CrosstermEvent::Mouse(mouse) => {
175+
self.handle_mouse_event(mouse);
176+
}
177+
CrosstermEvent::Paste(text) => {
178+
textarea.insert_str(&text);
179+
}
180+
CrosstermEvent::Resize(_, _) => {
181+
// Terminal will re-render on next loop iteration
182+
}
183+
_ => {}
171184
}
172185
}
173186

@@ -242,11 +255,144 @@ impl TuiApp {
242255
Ok(())
243256
}
244257

258+
fn handle_mouse_event(&mut self, mouse: MouseEvent) {
259+
let col = mouse.column;
260+
let row = mouse.row;
261+
let in_chat = self
262+
.state
263+
.chat_area
264+
.contains(Position { x: col, y: row });
265+
266+
match mouse.kind {
267+
MouseEventKind::ScrollUp if in_chat => {
268+
self.state.scroll_up(3);
269+
}
270+
MouseEventKind::ScrollDown if in_chat => {
271+
self.state.scroll_down(3);
272+
}
273+
MouseEventKind::Down(MouseButton::Left) => {
274+
if in_chat {
275+
self.state.selection.start = (col, row);
276+
self.state.selection.end = (col, row);
277+
self.state.selection.is_dragging = true;
278+
self.state.selection.has_selection = false;
279+
} else {
280+
self.state.selection.clear();
281+
}
282+
}
283+
MouseEventKind::Drag(MouseButton::Left) if self.state.selection.is_dragging => {
284+
// Clamp to chat area bounds
285+
let clamped_col = col.clamp(self.state.chat_area.x, self.state.chat_area.right().saturating_sub(1));
286+
let clamped_row = row.clamp(self.state.chat_area.y, self.state.chat_area.bottom().saturating_sub(1));
287+
self.state.selection.end = (clamped_col, clamped_row);
288+
self.state.selection.has_selection = true;
289+
}
290+
MouseEventKind::Up(MouseButton::Left) => {
291+
if self.state.selection.has_selection {
292+
self.state.selection.is_dragging = false;
293+
let text = self.extract_selected_text();
294+
if !text.is_empty() {
295+
Self::copy_to_clipboard(&text);
296+
}
297+
} else {
298+
self.state.selection.clear();
299+
}
300+
}
301+
_ => {}
302+
}
303+
}
304+
305+
fn extract_selected_text(&self) -> String {
306+
let ((sx, sy), (ex, ey)) = self.state.selection.normalized();
307+
let area = self.state.chat_area;
308+
309+
// Convert terminal-absolute coords to chat-area-relative
310+
let rel_sy = sy.saturating_sub(area.y) as usize;
311+
let rel_ey = ey.saturating_sub(area.y) as usize;
312+
let rel_sx = sx.saturating_sub(area.x) as usize;
313+
let rel_ex = ex.saturating_sub(area.x) as usize;
314+
315+
let mut lines = Vec::new();
316+
for row_idx in rel_sy..=rel_ey {
317+
if row_idx >= self.state.screen_text.len() {
318+
break;
319+
}
320+
let line = &self.state.screen_text[row_idx];
321+
let start_col = if row_idx == rel_sy { rel_sx } else { 0 };
322+
let end_col = if row_idx == rel_ey {
323+
(rel_ex + 1).min(line.len())
324+
} else {
325+
line.len()
326+
};
327+
328+
if start_col < line.len() {
329+
let selected: String = line
330+
.chars()
331+
.skip(start_col)
332+
.take(end_col.saturating_sub(start_col))
333+
.collect();
334+
lines.push(selected.trim_end().to_string());
335+
} else {
336+
lines.push(String::new());
337+
}
338+
}
339+
340+
// Remove trailing empty lines
341+
while lines.last().map_or(false, |l| l.is_empty()) {
342+
lines.pop();
343+
}
344+
345+
lines.join("\n")
346+
}
347+
348+
fn copy_to_clipboard(text: &str) {
349+
#[cfg(target_os = "macos")]
350+
{
351+
use std::process::{Command, Stdio};
352+
if let Ok(mut child) = Command::new("pbcopy")
353+
.stdin(Stdio::piped())
354+
.spawn()
355+
{
356+
if let Some(stdin) = child.stdin.as_mut() {
357+
use std::io::Write;
358+
let _ = stdin.write_all(text.as_bytes());
359+
}
360+
let _ = child.wait();
361+
}
362+
}
363+
#[cfg(target_os = "linux")]
364+
{
365+
use std::process::{Command, Stdio};
366+
use std::io::Write;
367+
// Try xclip first, fall back to xsel
368+
let result = Command::new("xclip")
369+
.args(["-selection", "clipboard"])
370+
.stdin(Stdio::piped())
371+
.spawn();
372+
if let Ok(mut child) = result {
373+
if let Some(stdin) = child.stdin.as_mut() {
374+
let _ = stdin.write_all(text.as_bytes());
375+
}
376+
let _ = child.wait();
377+
} else if let Ok(mut child) = Command::new("xsel")
378+
.args(["--clipboard", "--input"])
379+
.stdin(Stdio::piped())
380+
.spawn()
381+
{
382+
if let Some(stdin) = child.stdin.as_mut() {
383+
let _ = stdin.write_all(text.as_bytes());
384+
}
385+
let _ = child.wait();
386+
}
387+
}
388+
}
389+
245390
fn restore_terminal(&mut self) -> Result<()> {
246391
disable_raw_mode()?;
247392
execute!(
248393
self.terminal.backend_mut(),
249394
PopKeyboardEnhancementFlags,
395+
DisableMouseCapture,
250396
DisableBracketedPaste,
251397
LeaveAlternateScreen
252398
)?;
@@ -261,6 +407,7 @@ impl Drop for TuiApp {
261407
let _ = execute!(
262408
self.terminal.backend_mut(),
263409
PopKeyboardEnhancementFlags,
410+
DisableMouseCapture,
264411
DisableBracketedPaste,
265412
LeaveAlternateScreen
266413
);

tycode-cli/src/tui/state.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,43 @@
1+
use ratatui::layout::Rect;
12
use tycode_core::ai::types::TokenUsage;
23
use tycode_core::modules::task_list::TaskList;
34

45
use crate::state::State;
56

7+
/// Tracks mouse-based text selection in the chat panel.
8+
#[derive(Clone, Debug, Default)]
9+
pub struct SelectionState {
10+
/// Starting position of the selection (terminal-absolute coordinates).
11+
pub start: (u16, u16),
12+
/// Ending position of the selection (terminal-absolute coordinates).
13+
pub end: (u16, u16),
14+
/// Whether the user is currently dragging (mouse button held).
15+
pub is_dragging: bool,
16+
/// Whether there is a valid selection (drag moved at least 1 cell).
17+
pub has_selection: bool,
18+
}
19+
20+
impl SelectionState {
21+
/// Returns start and end in reading order (top-left to bottom-right).
22+
pub fn normalized(&self) -> ((u16, u16), (u16, u16)) {
23+
let (sx, sy) = self.start;
24+
let (ex, ey) = self.end;
25+
if sy < ey || (sy == ey && sx <= ex) {
26+
((sx, sy), (ex, ey))
27+
} else {
28+
((ex, ey), (sx, sy))
29+
}
30+
}
31+
32+
/// Clear the selection state.
33+
pub fn clear(&mut self) {
34+
self.start = (0, 0);
35+
self.end = (0, 0);
36+
self.is_dragging = false;
37+
self.has_selection = false;
38+
}
39+
}
40+
641
/// A single entry in the chat history panel.
742
#[derive(Clone, Debug)]
843
#[allow(dead_code)]
@@ -102,6 +137,15 @@ pub struct TuiState {
102137

103138
/// Banner info for initial display.
104139
pub banner_data: Option<BannerData>,
140+
141+
/// The chat panel area rect (stored from layout for mouse hit-testing).
142+
pub chat_area: Rect,
143+
144+
/// Mouse-based text selection state.
145+
pub selection: SelectionState,
146+
147+
/// Buffer snapshot of the chat panel text (one String per row).
148+
pub screen_text: Vec<String>,
105149
}
106150

107151
impl TuiState {
@@ -132,6 +176,9 @@ impl TuiState {
132176
should_quit: false,
133177
awaiting_response: false,
134178
banner_data,
179+
chat_area: Rect::default(),
180+
selection: SelectionState::default(),
181+
screen_text: Vec::new(),
135182
}
136183
}
137184

@@ -140,6 +187,7 @@ impl TuiState {
140187
self.chat_history.push(entry);
141188
if self.auto_scroll {
142189
self.scroll_offset = 0;
190+
self.selection.clear();
143191
}
144192
}
145193

tycode-cli/src/tui/ui.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use ratatui::{
22
layout::{Constraint, Direction, Layout, Margin},
3+
style::Modifier,
34
Frame,
45
};
56
use tui_textarea::TextArea;
@@ -32,4 +33,42 @@ pub fn draw_ui(frame: &mut Frame, state: &mut TuiState, textarea: &TextArea) {
3233
input_area::render(frame, chunks[1], textarea);
3334
// chunks[2] is the empty gap line - just leave it blank
3435
status_bar::render(frame, chunks[3], state);
36+
37+
// Store chat area rect for mouse hit-testing
38+
state.chat_area = chunks[0];
39+
40+
// Snapshot the chat panel buffer text for text extraction
41+
let area = chunks[0];
42+
let buf = frame.buffer_mut();
43+
let mut screen_text = Vec::with_capacity(area.height as usize);
44+
for row in area.y..area.bottom() {
45+
let mut line = String::with_capacity(area.width as usize);
46+
for col in area.x..area.right() {
47+
let cell = &buf[(col, row)];
48+
line.push_str(cell.symbol());
49+
}
50+
screen_text.push(line);
51+
}
52+
state.screen_text = screen_text;
53+
54+
// Apply selection highlight (reversed video)
55+
if state.selection.has_selection {
56+
let ((sx, sy), (ex, ey)) = state.selection.normalized();
57+
for row in sy..=ey {
58+
if row < area.y || row >= area.bottom() {
59+
continue;
60+
}
61+
let col_start = if row == sy { sx.max(area.x) } else { area.x };
62+
let col_end = if row == ey {
63+
(ex + 1).min(area.right())
64+
} else {
65+
area.right()
66+
};
67+
for col in col_start..col_end {
68+
let cell = &mut buf[(col, row)];
69+
let style = cell.style().add_modifier(Modifier::REVERSED);
70+
cell.set_style(style);
71+
}
72+
}
73+
}
3574
}

0 commit comments

Comments
 (0)