@@ -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.
151154pub ( 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 ) ]
159183pub 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