Skip to content

Commit 01fbd4c

Browse files
committed
feat(tui): add transcript text selection
1 parent f59e203 commit 01fbd4c

6 files changed

Lines changed: 522 additions & 38 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "kit"
3-
version = "0.1.96"
3+
version = "0.1.97"
44
edition = "2024"
55
rust-version = "1.94.0"
66
publish = false

src/tui/app.rs

Lines changed: 257 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,36 @@ pub struct CodeHit {
148148
pub range: Range<usize>,
149149
}
150150

151+
/// A display row's tags: owning tool call, fenced-code content, the logical
152+
/// source line it was wrapped from, and source whitespace removed before this
153+
/// row. The latter lets copy distinguish word wraps from hard token wraps.
151154
pub(super) type CachedTranscriptRow = (
152155
Line<'static>,
153-
(Option<String>, Option<CodeHit>),
156+
(Option<String>, Option<CodeHit>, Option<usize>),
154157
Vec<LinkHit>,
158+
String,
155159
);
156160

161+
/// A drag selection over the transcript, in absolute display-line coordinates
162+
/// so it stays anchored to content while the transcript scrolls. Both cells
163+
/// are inclusive; `anchor` is where the drag began.
164+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165+
pub struct Selection {
166+
pub anchor: (usize, usize),
167+
pub head: (usize, usize),
168+
}
169+
170+
impl Selection {
171+
/// The selection's cells in reading order, both ends inclusive.
172+
pub fn ordered(&self) -> ((usize, usize), (usize, usize)) {
173+
if self.anchor <= self.head {
174+
(self.anchor, self.head)
175+
} else {
176+
(self.head, self.anchor)
177+
}
178+
}
179+
}
180+
157181
/// What the event loop should do after a key press.
158182
#[derive(Clone, Debug, PartialEq, Eq)]
159183
pub struct ModelChoice {
@@ -512,6 +536,10 @@ pub struct App {
512536
pub transcript_top: usize,
513537
pub transcript_left: usize,
514538
pub transcript_width: usize,
539+
pub selection: Option<Selection>,
540+
/// Pending left press; the flag suppresses a release-click when this press
541+
/// dismissed an older selection, while still allowing it to start a drag.
542+
press: Option<(usize, usize, bool)>,
515543
pub toast: Option<(String, Instant)>,
516544
/// When the last key arrived, for telling a paste from typing.
517545
pub last_key: Option<Instant>,
@@ -766,6 +794,8 @@ impl App {
766794
transcript_top: 0,
767795
transcript_left: 0,
768796
transcript_width: 0,
797+
selection: None,
798+
press: None,
769799
toast: None,
770800
last_key: None,
771801
}
@@ -1627,6 +1657,8 @@ impl App {
16271657
self.row_calls.clear();
16281658
self.row_links.clear();
16291659
self.row_code.clear();
1660+
self.selection = None;
1661+
self.press = None;
16301662
}
16311663

16321664
#[cfg(test)]
@@ -1668,13 +1700,21 @@ impl App {
16681700
}
16691701

16701702
pub fn scroll_by(&mut self, lines: isize) {
1703+
self.press = None;
16711704
let top = self.total_lines.saturating_sub(self.viewport);
16721705
let current = self.scroll.min(top);
16731706
self.scroll = current.saturating_add_signed(lines).min(top);
16741707
self.follow = self.scroll >= top;
16751708
}
16761709

1710+
fn scroll_to_top(&mut self) {
1711+
self.press = None;
1712+
self.follow = false;
1713+
self.scroll = 0;
1714+
}
1715+
16771716
pub fn scroll_to_bottom(&mut self) {
1717+
self.press = None;
16781718
self.follow = true;
16791719
self.scroll = usize::MAX;
16801720
}
@@ -1949,6 +1989,10 @@ impl App {
19491989
}
19501990
KeyCode::Char('d') if control && self.editor.is_empty() => return Action::Quit,
19511991
KeyCode::Char('y') if control => {
1992+
if let Some(text) = self.selection_text() {
1993+
self.toast("copied selection");
1994+
return Action::Copy(text);
1995+
}
19521996
let Some(text) = self.latest_agent_text() else {
19531997
self.toast("no agent response to copy");
19541998
return Action::None;
@@ -2099,7 +2143,7 @@ impl App {
20992143
self.editor.history_next();
21002144
}
21012145
}
2102-
KeyCode::Home if control => self.scroll = 0,
2146+
KeyCode::Home if control => self.scroll_to_top(),
21032147
KeyCode::End if control => self.scroll_to_bottom(),
21042148
KeyCode::Home => self.editor.move_line_start(),
21052149
KeyCode::End => self.editor.move_line_end(),
@@ -2141,13 +2185,70 @@ impl App {
21412185
return Action::Redraw;
21422186
}
21432187
MouseEventKind::Down(MouseButton::Left) => {
2144-
return self.click(mouse.column as usize, mouse.row as usize);
2188+
let dismissed = self.selection.take().is_some();
2189+
self.press = Some((mouse.column as usize, mouse.row as usize, dismissed));
2190+
if dismissed {
2191+
return Action::Redraw;
2192+
}
2193+
}
2194+
MouseEventKind::Drag(MouseButton::Left) => {
2195+
let Some((column, row, _)) = self.press else {
2196+
return Action::None;
2197+
};
2198+
let Some(anchor) = self.transcript_position(column, row) else {
2199+
return Action::None;
2200+
};
2201+
let head =
2202+
self.transcript_position_clamped(mouse.column as usize, mouse.row as usize);
2203+
self.selection = Some(Selection { anchor, head });
2204+
return Action::Redraw;
2205+
}
2206+
MouseEventKind::Up(MouseButton::Left) => {
2207+
let press = self.press.take();
2208+
// A drag that produced a selection is not a click.
2209+
if self.selection.is_some() {
2210+
return Action::None;
2211+
}
2212+
if let Some((column, row, false)) = press {
2213+
return self.click(column, row);
2214+
}
21452215
}
21462216
_ => {}
21472217
}
21482218
Action::None
21492219
}
21502220

2221+
pub(super) fn clear_transcript_interaction(&mut self) {
2222+
self.selection = None;
2223+
self.press = None;
2224+
}
2225+
2226+
/// Maps a screen cell to (absolute transcript line, column), if it is
2227+
/// inside the transcript area.
2228+
fn transcript_position(&self, column: usize, row: usize) -> Option<(usize, usize)> {
2229+
if self.scroll == usize::MAX || self.viewport == 0 {
2230+
return None;
2231+
}
2232+
let offset = row.checked_sub(self.transcript_top)?;
2233+
let inside = offset < self.viewport
2234+
&& column >= self.transcript_left
2235+
&& column < self.transcript_left + self.transcript_width;
2236+
inside.then(|| (self.scroll + offset, column - self.transcript_left))
2237+
}
2238+
2239+
/// Like [`Self::transcript_position`], but clamps a cell outside the
2240+
/// transcript to its nearest edge so a drag can leave the area.
2241+
fn transcript_position_clamped(&self, column: usize, row: usize) -> (usize, usize) {
2242+
let last_row = self.transcript_top + self.viewport.saturating_sub(1);
2243+
let row = row.clamp(self.transcript_top, last_row);
2244+
let last_column = self.transcript_left + self.transcript_width.saturating_sub(1);
2245+
let column = column.clamp(self.transcript_left, last_column);
2246+
(
2247+
self.scroll + (row - self.transcript_top),
2248+
column - self.transcript_left,
2249+
)
2250+
}
2251+
21512252
/// Copies code, opens links, or folds tool output at the clicked row.
21522253
fn click(&mut self, column: usize, row: usize) -> Action {
21532254
if self.scroll == usize::MAX {
@@ -2199,6 +2300,95 @@ impl App {
21992300
.find(|link| column >= link.start && column < link.end)
22002301
.map(|link| link.url.clone())
22012302
}
2303+
2304+
/// The cached row behind an absolute transcript line. `None` covers the
2305+
/// separator rows between blocks and lines outside the cache.
2306+
fn transcript_row(&self, line: usize) -> Option<(usize, &CachedTranscriptRow)> {
2307+
let total = self.transcript_prefixes.last().copied()?;
2308+
if line >= total || self.blocks.is_empty() {
2309+
return None;
2310+
}
2311+
let block = self
2312+
.transcript_prefixes
2313+
.partition_point(|prefix| *prefix <= line)
2314+
.saturating_sub(1)
2315+
.min(self.blocks.len() - 1);
2316+
let span_start = self.transcript_prefixes[block];
2317+
let content_start = span_start + usize::from(span_start > 0);
2318+
let row = line.checked_sub(content_start)?;
2319+
self.transcript_cache
2320+
.get(block)?
2321+
.as_ref()?
2322+
.rows
2323+
.get(row)
2324+
.map(|cached| (block, cached))
2325+
}
2326+
2327+
/// The selected text, reconstructed from the rendered rows: rows wrapped
2328+
/// from one logical line rejoin, trailing padding is dropped, and fenced
2329+
/// code loses the two-column display indent it is drawn with.
2330+
pub fn selection_text(&self) -> Option<String> {
2331+
let selection = self.selection?;
2332+
let (start, end) = selection.ordered();
2333+
let mut lines: Vec<String> = Vec::new();
2334+
let mut last_logical: Option<(usize, usize)> = None;
2335+
for line in start.0..=end.0 {
2336+
let Some((block, row)) = self.transcript_row(line) else {
2337+
lines.push(String::new());
2338+
last_logical = None;
2339+
continue;
2340+
};
2341+
let text: String = row
2342+
.0
2343+
.spans
2344+
.iter()
2345+
.map(|span| span.content.as_ref())
2346+
.collect();
2347+
let from = if line == start.0 { start.1 } else { 0 };
2348+
let to = if line == end.0 { end.1 + 1 } else { usize::MAX };
2349+
let mut fragment = column_slice(&text, from, to).trim_end().to_string();
2350+
if row.1.1.is_some() && from == 0 && fragment.starts_with(" ") {
2351+
fragment.drain(..2);
2352+
}
2353+
let logical = row.1.2.map(|index| (block, index));
2354+
match (logical, last_logical) {
2355+
(Some(current), Some(previous)) if current == previous => {
2356+
let joined = lines.last_mut().expect("a wrapped row follows its first");
2357+
let fragment = fragment.trim_start();
2358+
if !fragment.is_empty() {
2359+
joined.push_str(&row.3);
2360+
joined.push_str(fragment);
2361+
}
2362+
}
2363+
_ => lines.push(fragment),
2364+
}
2365+
last_logical = logical;
2366+
}
2367+
let text = lines.join("\n");
2368+
let text = text.trim_matches('\n');
2369+
(!text.trim().is_empty()).then(|| text.to_string())
2370+
}
2371+
}
2372+
2373+
/// The substring of `text` covering display columns `[from, to)`.
2374+
fn column_slice(text: &str, from: usize, to: usize) -> &str {
2375+
use unicode_width::UnicodeWidthChar;
2376+
2377+
let mut column = 0;
2378+
let mut start = None;
2379+
let mut end = text.len();
2380+
for (index, character) in text.char_indices() {
2381+
let width = character.width().unwrap_or(0);
2382+
if width > 0 && column >= to {
2383+
end = index;
2384+
break;
2385+
}
2386+
if start.is_none() && column + width > from {
2387+
start = Some(index);
2388+
}
2389+
column += width;
2390+
}
2391+
start.and_then(|start| text.get(start..end)).unwrap_or("")
22022392
}
22032393

22042394
#[cfg(target_os = "macos")]
@@ -2246,7 +2436,9 @@ mod tests {
22462436

22472437
use agent_client_protocol::schema::v2::{StopReason, ToolCallStatus, ToolKind};
22482438
use agentkit_core::{DataRef, Item, ItemKind, MediaPart, MetadataMap, Modality, Part};
2249-
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
2439+
use crossterm::event::{
2440+
KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
2441+
};
22502442

22512443
use super::{
22522444
Action, App, AttachmentKind, Block, MAX_IMAGE_BASE64_BYTES, MAX_IMAGE_SOURCE_BYTES,
@@ -2267,6 +2459,67 @@ mod tests {
22672459
}
22682460
}
22692461

2462+
#[test]
2463+
fn column_slice_includes_a_selected_wide_character_and_its_combining_marks() {
2464+
assert_eq!(super::column_slice("界a", 1, 2), "界");
2465+
assert_eq!(super::column_slice("e\u{301}x", 0, 1), "e\u{301}");
2466+
}
2467+
2468+
#[test]
2469+
fn scrolling_or_dismissing_a_selection_cancels_a_pending_mouse_press() {
2470+
let mut app = app();
2471+
app.total_lines = 20;
2472+
app.viewport = 5;
2473+
app.follow = false;
2474+
let down = MouseEvent {
2475+
kind: MouseEventKind::Down(MouseButton::Left),
2476+
column: 2,
2477+
row: 2,
2478+
modifiers: KeyModifiers::NONE,
2479+
};
2480+
2481+
app.handle_mouse(down);
2482+
assert!(app.press.is_some());
2483+
app.scroll_by(1);
2484+
assert!(app.press.is_none());
2485+
2486+
app.handle_mouse(down);
2487+
app.scroll_to_bottom();
2488+
assert!(app.press.is_none());
2489+
2490+
app.handle_mouse(down);
2491+
app.scroll_to_top();
2492+
assert!(app.press.is_none());
2493+
2494+
app.transcript_width = 10;
2495+
app.selection = Some(super::Selection {
2496+
anchor: (0, 0),
2497+
head: (0, 1),
2498+
});
2499+
assert!(matches!(app.handle_mouse(down), Action::Redraw));
2500+
assert!(app.selection.is_none());
2501+
assert!(app.press.is_some());
2502+
2503+
assert!(matches!(
2504+
app.handle_mouse(MouseEvent {
2505+
kind: MouseEventKind::Drag(MouseButton::Left),
2506+
column: 3,
2507+
..down
2508+
}),
2509+
Action::Redraw
2510+
));
2511+
assert!(app.selection.is_some());
2512+
assert!(matches!(
2513+
app.handle_mouse(MouseEvent {
2514+
kind: MouseEventKind::Up(MouseButton::Left),
2515+
column: 3,
2516+
..down
2517+
}),
2518+
Action::None
2519+
));
2520+
assert!(app.press.is_none());
2521+
}
2522+
22702523
fn app() -> App {
22712524
App::new(
22722525
PathBuf::from("/tmp"),

0 commit comments

Comments
 (0)