返回 CodeWhale
mouse_ui.rs
根目录 / crates / tui / src / tui / mouse_ui.rs
1 use std::time::{Duration, Instant};
2
3 /// Timing trace of the last left-click in the composer. crossterm never
4 /// decodes click counts, so double/triple-click detection keeps the prior
5 /// click's time and position; a fast click within the slop window increments
6 /// the count, anything else resets it.
7 #[derive(Debug, Clone, Copy)]
8 pub(crate) struct ComposerClickTrace {
9 at: Instant,
10 column: u16,
11 row: u16,
12 count: u8,
13 }
14
15 const COMPOSER_DOUBLE_CLICK_MS: u128 = 400;
16 const COMPOSER_CLICK_SLOP_CELLS: u16 = 1;
17
18 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
19 pub(crate) enum ComposerClickGesture {
20 Caret,
21 Word,
22 Line,
23 }
24
25 fn classify_composer_click(
26 trace: &mut Option<ComposerClickTrace>,
27 column: u16,
28 row: u16,
29 ) -> ComposerClickGesture {
30 let at = Instant::now();
31 let next = match trace.as_ref() {
32 Some(prev)
33 if at.duration_since(prev.at).as_millis() <= COMPOSER_DOUBLE_CLICK_MS
34 && prev.row.abs_diff(row) <= COMPOSER_CLICK_SLOP_CELLS
35 && prev.column.abs_diff(column) <= COMPOSER_CLICK_SLOP_CELLS =>
36 {
37 ComposerClickTrace {
38 at,
39 column,
40 row,
41 count: prev.count.saturating_add(1),
42 }
43 }
44 _ => ComposerClickTrace {
45 at,
46 column,
47 row,
48 count: 1,
49 },
50 };
51 let count = next.count;
52 *trace = Some(next);
53 match count {
54 2 => ComposerClickGesture::Word,
55 n if n >= 3 => ComposerClickGesture::Line,
56 _ => ComposerClickGesture::Caret,
57 }
58 }
59
60 /// Byte bounds of the word (or CJK run) containing `pos`.
61 fn composer_word_bounds(text: &str, pos: usize) -> (usize, usize) {
62 let chars: Vec<(usize, char)> = text.char_indices().collect();
63 if chars.is_empty() {
64 return (0, 0);
65 }
66 let is_word = |ch: char| ch.is_alphanumeric() || (ch as u32) >= 0x80;
67 let idx = chars.partition_point(|(byte, _)| *byte < pos);
68 let idx = idx.min(chars.len().saturating_sub(1));
69 if !is_word(chars[idx].1) {
70 return (chars[idx].0, chars[idx].0 + chars[idx].1.len_utf8());
71 }
72 let mut start = idx;
73 while start > 0 && is_word(chars[start - 1].1) {
74 start -= 1;
75 }
76 let mut end = idx + 1;
77 while end < chars.len() && is_word(chars[end].1) {
78 end += 1;
79 }
80 let start_byte = chars[start].0;
81 let end_byte = if end < chars.len() {
82 chars[end].0
83 } else {
84 text.len()
85 };
86 (start_byte, end_byte)
87 }
88
89 /// Byte bounds of the logical line containing `pos` (excluding the newline).
90 fn composer_line_bounds(text: &str, pos: usize) -> (usize, usize) {
91 let pos = pos.min(text.len());
92 let start = text[..pos].rfind('\n').map_or(0, |i| i + 1);
93 let end = text[pos..]
94 .find('\n')
95 .map_or(text.len(), |offset| pos + offset);
96 (start, end)
97 }
98
99 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
100 use ratatui::layout::Rect;
101 use unicode_segmentation::UnicodeSegmentation;
102
103 use crate::tui::app::{App, SidebarRowAction, StatusToastLevel};
104 use crate::tui::command_palette::{
105 CommandPaletteView, build_entries as build_command_palette_entries,
106 };
107 use crate::tui::context_menu::{ContextMenuEntry, ContextMenuView};
108 use crate::tui::history::HistoryCell;
109 use crate::tui::scrolling::{ScrollDirection, TranscriptScroll};
110 use crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};
111 use crate::tui::tideline::InteractionAction;
112 use crate::tui::ui_text::{
113 history_cell_to_clipboard_text, history_cell_to_text, line_to_plain, slice_visible_columns,
114 text_display_width, text_visible_width, truncate_line_to_width,
115 };
116 use crate::tui::views::{ContextMenuAction, HelpView, ModalKind, ViewEvent};
117 use codewhale_localization::MessageId;
118 use codewhale_models::{ContentBlock, Message};
119
120 // These functions will need to be imported from ui.rs or we can just import crate::tui::ui::*.
121 use crate::tui::ui::{
122 copy_cell_to_clipboard, detail_target_label, open_context_inspector,
123 open_details_pager_for_cell, open_pager_for_selection,
124 };
125
126 const COMPOSER_MOUSE_SCROLL_LINES: usize = 3;
127
128 pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) -> bool {
129 if !app.is_loading {
130 return false;
131 }
132
133 match mouse.kind {
134 // v0.9.1: keep a cheap hover hit-test alive while streaming. Motion
135 // events are no longer dropped wholesale — the frame limiter bounds
136 // redraw cost. Only expensive transcript reflow stays deferred.
137 MouseEventKind::Moved => false,
138 MouseEventKind::Drag(_) => {
139 // Divider drags must stay live during active turns — dropping
140 // these events wedges the resize state mid-drag (#3063).
141 !app.viewport.transcript_selection.dragging
142 && !app.viewport.transcript_scrollbar_dragging
143 && !app.work_surface.is_resizing()
144 }
145 _ => false,
146 }
147 }
148
149 fn toggle_tool_run_expand(app: &mut App, mouse: MouseEvent) -> bool {
150 if !app.tool_collapse_active() {
151 return false;
152 }
153 let Some(rendered_idx) = transcript_cell_index_from_mouse(app, mouse) else {
154 return false;
155 };
156 let original_idx = app.original_cell_index_for_rendered(rendered_idx);
157 if app.tool_run_start_for_history_index(original_idx) != Some(original_idx) {
158 return false;
159 }
160 app.toggle_tool_run_expansion_at(original_idx)
161 }
162
163 /// Map a mouse (column, row) within the composer area to a char index
164 /// in the composer input string. Uses the canonical prompt-adjusted text rect
165 /// for coordinate mapping, and accounts for vertical padding and scroll offset.
166 fn mouse_pos_to_char_index(app: &App, col: u16, row: u16, text_area: Rect) -> Option<usize> {
167 let rel_col = col.saturating_sub(text_area.x) as usize;
168 let rel_row = row.saturating_sub(text_area.y) as usize;
169
170 if app.input.is_empty() {
171 return Some(0);
172 }
173
174 let width = text_area.width.max(1) as usize;
175 let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(&app.input, width);
176
177 // Subtract the vertical top-padding (centering of short inputs).
178 let text_row = rel_row.saturating_sub(app.viewport.last_composer_top_padding);
179
180 // Add the scroll offset (lines scrolled out of view).
181 let absolute_row = text_row + app.viewport.last_composer_scroll_offset;
182
183 if absolute_row >= wrapped.len() {
184 return Some(app.input.chars().count());
185 }
186
187 let (line_start, line_text) = &wrapped[absolute_row];
188
189 let mut char_offset = 0usize;
190 let mut col_used = 0usize;
191 for g in line_text.graphemes(true) {
192 // Painted cells: ratatui strips control characters, so a tab takes
193 // no column here, matching the wrap and caret math.
194 let gw = crate::tui::widgets::visible_grapheme_width(g);
195 if col_used + gw > rel_col {
196 break;
197 }
198 col_used += gw;
199 char_offset += g.chars().count();
200 }
201 Some(line_start + char_offset)
202 }
203
204 fn composer_wrapped_cursor_row_col(
205 input: &str,
206 cursor: usize,
207 wrapped: &[(usize, String)],
208 ) -> (usize, usize) {
209 let total = input.chars().count();
210 let cursor = cursor.min(total);
211
212 for (idx, (line_start, line_text)) in wrapped.iter().enumerate() {
213 let next_start = wrapped
214 .get(idx + 1)
215 .map(|(start, _)| *start)
216 .unwrap_or_else(|| total.saturating_add(1));
217
218 if cursor >= *line_start && cursor < next_start {
219 let line_len = line_text.chars().count();
220 return (idx, cursor.saturating_sub(*line_start).min(line_len));
221 }
222 }
223
224 let row = wrapped.len().saturating_sub(1);
225 let col = wrapped
226 .get(row)
227 .map(|(_, line_text)| line_text.chars().count())
228 .unwrap_or(0);
229 (row, col)
230 }
231
232 /// Move the composer caret by wrapped rows. Returns whether the caret actually
233 /// moved: a draft that is empty, unwrapped, or already at the boundary in this
234 /// direction reports `false` so the wheel can reach the transcript instead of
235 /// dying in the composer (#5223).
236 fn move_composer_cursor_by_wrapped_rows(app: &mut App, text_area: Rect, rows: isize) -> bool {
237 if app.input.is_empty() || rows == 0 {
238 return false;
239 }
240
241 let width = text_area.width.max(1) as usize;
242 let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(&app.input, width);
243 if wrapped.len() <= 1 {
244 return false;
245 }
246
247 let (current_row, current_col) =
248 composer_wrapped_cursor_row_col(&app.input, app.cursor_position, &wrapped);
249 let max_row = wrapped.len().saturating_sub(1);
250 let target_row = if rows.is_negative() {
251 current_row.saturating_sub(rows.unsigned_abs())
252 } else {
253 current_row.saturating_add(rows as usize).min(max_row)
254 };
255
256 if target_row == current_row {
257 return false;
258 }
259
260 let (target_start, target_text) = &wrapped[target_row];
261 let target_len = target_text.chars().count();
262 let total = app.input.chars().count();
263 app.clear_selection();
264 app.cursor_position = target_start
265 .saturating_add(current_col.min(target_len))
266 .min(total);
267 app.needs_redraw = true;
268 true
269 }
270
271 /// Click the WorkflowPanel header to toggle expand/collapse, or the trailing
272 /// cancel affordance while a run is active (#4121).
273 fn handle_workflow_panel_mouse(app: &mut App, mouse: MouseEvent) -> bool {
274 if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
275 return false;
276 }
277 let Some(area) = app.viewport.last_workflow_panel_area else {
278 return false;
279 };
280 if !mouse_hits_rect(mouse, Some(area)) {
281 return false;
282 }
283 if app.workflow_panel.is_none() {
284 return false;
285 }
286
287 if let Some(panel) = app.workflow_panel.as_mut() {
288 panel.keyboard_focus = true;
289 }
290
291 let on_header_row = mouse.row == area.y;
292 let in_cancel_zone =
293 on_header_row && mouse_hits_rect(mouse, app.viewport.last_workflow_cancel_area);
294 let running = app
295 .workflow_panel
296 .as_ref()
297 .is_some_and(|panel| panel.lifecycle.is_running());
298
299 if in_cancel_zone && running {
300 let run_id = app
301 .workflow_panel
302 .as_ref()
303 .map(|panel| panel.run_id.clone())
304 .expect("running panel has an id");
305 app.input = format!("/workflow cancel {run_id}");
306 app.cursor_position = app.input.chars().count();
307 app.status_message = Some(app.tr(MessageId::SidebarDestructiveArmed).into_owned());
308 if let Some(panel) = app.workflow_panel.as_mut() {
309 panel.keyboard_focus = false;
310 }
311 app.needs_redraw = true;
312 return true;
313 }
314
315 // Any other click on the panel toggles expand/collapse.
316 app.toggle_workflow_panel();
317 true
318 }
319
320 fn handle_plugin_cta_mouse(app: &mut App, mouse: MouseEvent) -> Option<Vec<ViewEvent>> {
321 if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
322 return None;
323 }
324 if !mouse_hits_rect(mouse, app.viewport.last_plugin_cta_area) {
325 return None;
326 }
327 if mouse_hits_rect(mouse, app.viewport.last_plugin_cta_dismiss_area) {
328 let _ = app.dismiss_plugin_cta();
329 return Some(Vec::new());
330 }
331 // Review button, or the rest of the CTA line, runs the existing review
332 // command. Never auto-installs: the slash command is the human path.
333 if let Some(command) = app.accept_plugin_cta_command() {
334 return Some(apply_sidebar_row_action(
335 app,
336 crate::tui::app::SidebarRowAction::Command(command),
337 ));
338 }
339 Some(Vec::new())
340 }
341
342 /// Slash-autocomplete rows painted inside the composer. Click selects
343 /// (second click on the same row applies, matching the command palette);
344 /// wheel moves the highlight. Returns true when the event was consumed so
345 /// the composer caret / draft-scroll path does not also handle it.
346 fn handle_slash_autocomplete_mouse(app: &mut App, mouse: MouseEvent) -> bool {
347 let hitboxes = app.viewport.last_slash_menu_hitboxes.borrow();
348 if hitboxes.is_empty() {
349 return false;
350 }
351 let over_row = hitboxes
352 .iter()
353 .find_map(|(idx, rect)| mouse_hits_rect(mouse, Some(*rect)).then_some(*idx));
354 let over_menu = over_row.is_some()
355 || hitboxes.iter().any(|(_, rect)| {
356 mouse.row >= rect.y
357 && mouse.row < rect.y.saturating_add(rect.height)
358 && mouse.column >= rect.x
359 && mouse.column < rect.x.saturating_add(rect.width)
360 });
361 // Wheel over any painted slash row moves selection (mouse == keys).
362 // Clicks only fire when the pointer is on a row rect.
363 match mouse.kind {
364 MouseEventKind::ScrollUp if over_menu => {
365 drop(hitboxes);
366 let entries = crate::tui::slash_menu::visible_slash_menu_entries(app, 128);
367 if entries.is_empty() {
368 return false;
369 }
370 crate::tui::composer_ui::select_previous_slash_menu_entry(app, entries.len());
371 app.needs_redraw = true;
372 true
373 }
374 MouseEventKind::ScrollDown if over_menu => {
375 drop(hitboxes);
376 let entries = crate::tui::slash_menu::visible_slash_menu_entries(app, 128);
377 if entries.is_empty() {
378 return false;
379 }
380 crate::tui::composer_ui::select_next_slash_menu_entry(app, entries.len());
381 app.needs_redraw = true;
382 true
383 }
384 MouseEventKind::Down(MouseButton::Left) => {
385 let Some(idx) = over_row else {
386 return false;
387 };
388 drop(hitboxes);
389 let entries = crate::tui::slash_menu::visible_slash_menu_entries(app, 128);
390 if entries.is_empty() || idx >= entries.len() {
391 return false;
392 }
393 // Same as command palette: click the highlighted row to apply;
394 // click another row to move the highlight (mouse == keys).
395 if app.slash_menu_selected == idx {
396 let _ = crate::tui::slash_menu::apply_slash_menu_selection(app, &entries, true);
397 } else {
398 app.slash_menu_selected = idx;
399 app.slash_menu_hidden = false;
400 }
401 app.needs_redraw = true;
402 true
403 }
404 _ => false,
405 }
406 }
407
408 /// Handle mouse events within the composer area.
409 /// Returns true if the event was consumed.
410 pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool {
411 if !app.view_stack.is_empty() {
412 return false;
413 }
414 // A transcript selection or scrollbar drag that ends over the composer
415 // belongs to the surface that started it: the transcript handler must
416 // still see the release to clear its drag state and publish the text.
417 if matches!(
418 mouse.kind,
419 MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
420 ) && (app.viewport.transcript_selection.dragging
421 || app.viewport.transcript_scrollbar_dragging)
422 {
423 return false;
424 }
425 // Use outer area for hit-testing (includes border).
426 let Some(area) = app.viewport.last_composer_area else {
427 return false;
428 };
429 if mouse.column < area.x
430 || mouse.column >= area.x + area.width
431 || mouse.row < area.y
432 || mouse.row >= area.y + area.height
433 {
434 return false;
435 }
436 // Slash autocomplete owns its painted rows before caret placement or
437 // draft scroll — otherwise a click on `/model` would only move the caret.
438 if handle_slash_autocomplete_mouse(app, mouse) {
439 return true;
440 }
441 // Resolve the border- and submit-aware input plane through the same
442 // persistent prompt geometry used by rendering, cursor placement, and
443 // viewport bookkeeping. The frame records it after reserving `[↵]`.
444 let input_plane = app.viewport.last_composer_content.unwrap_or(area);
445 let text_area =
446 crate::tui::widgets::composer_content_geometry(input_plane, app.is_history_search_active())
447 .text_area;
448
449 match mouse.kind {
450 // Only claim the wheel while the caret still has somewhere to go. At
451 // the top or bottom of the draft — or with no wrapped draft at all —
452 // fall through so the transcript scrolls instead of the event being
453 // silently swallowed by the composer rect (#5223).
454 MouseEventKind::ScrollUp => move_composer_cursor_by_wrapped_rows(
455 app,
456 text_area,
457 -(COMPOSER_MOUSE_SCROLL_LINES as isize),
458 ),
459 MouseEventKind::ScrollDown => move_composer_cursor_by_wrapped_rows(
460 app,
461 text_area,
462 COMPOSER_MOUSE_SCROLL_LINES as isize,
463 ),
464 MouseEventKind::Down(MouseButton::Left) => {
465 clear_transcript_selection(app);
466 crate::tui::work_surface::release_focus(app);
467 if let Some(submit) = crate::tui::widgets::active_composer_submit_rect(app, area)
468 && mouse_hits_rect(mouse, Some(submit))
469 {
470 // Same chord the keyboard Enter path uses. Empty / paste-burst
471 // clicks are consumed so they cannot also move the caret.
472 let action =
473 app.decide_composer_submit(crate::tui::app::ComposerSubmitChord::Enter);
474 if !matches!(action, crate::tui::app::ComposerSubmitAction::Noop)
475 && (app.composer_enter_would_submit()
476 || matches!(action, crate::tui::app::ComposerSubmitAction::SendQueuedNow))
477 {
478 app.pending_composer_submit = Some(crate::tui::app::ComposerSubmitChord::Enter);
479 }
480 app.needs_redraw = true;
481 return true;
482 }
483 if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, text_area) {
484 match classify_composer_click(
485 &mut app.viewport.composer_click_trace,
486 mouse.column,
487 mouse.row,
488 ) {
489 ComposerClickGesture::Word => {
490 let (start, end) = composer_word_bounds(&app.input, pos);
491 app.selection_anchor = Some(start);
492 app.cursor_position = end;
493 }
494 ComposerClickGesture::Line => {
495 let (start, end) = composer_line_bounds(&app.input, pos);
496 app.selection_anchor = Some(start);
497 app.cursor_position = end;
498 }
499 ComposerClickGesture::Caret => {
500 app.cursor_position = pos;
501 app.selection_anchor = None;
502 }
503 }
504 app.needs_redraw = true;
505 }
506 true
507 }
508 MouseEventKind::Drag(MouseButton::Left) => {
509 if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, text_area) {
510 if app.selection_anchor.is_none() {
511 app.selection_anchor = Some(app.cursor_position);
512 }
513 app.cursor_position = pos;
514 app.needs_redraw = true;
515 }
516 true
517 }
518 MouseEventKind::Up(MouseButton::Left) => {
519 if app.selection_anchor == Some(app.cursor_position) {
520 app.selection_anchor = None;
521 }
522 true
523 }
524 MouseEventKind::Down(MouseButton::Middle) if app.clipboard.uses_primary_selection() => {
525 if let Some(text) = app.clipboard.read_primary_text() {
526 // Flush already-typed bytes at their original caret first.
527 app.insert_paste_text("");
528 let Some(position) =
529 mouse_pos_to_char_index(app, mouse.column, mouse.row, text_area)
530 else {
531 return true;
532 };
533 // PRIMARY often contains this very selection. Insert at the
534 // pointer, preserving the selected original rather than cutting it.
535 app.selection_anchor = None;
536 app.cursor_position = position;
537 crate::tui::work_surface::release_focus(app);
538 app.insert_paste_text(&text);
539 }
540 true
541 }
542 _ => false,
543 }
544 }
545
546 pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEvent> {
547 if app.view_stack.top_kind() == Some(ModalKind::ContextMenu) {
548 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
549 app.view_stack.pop();
550 open_context_menu(app, mouse);
551 return Vec::new();
552 }
553 return app.view_stack.handle_mouse(mouse);
554 }
555
556 // Decision prompts leave transcript evidence visible above them. A question
557 // sheet owns the wheel over its content; approval cards retain their existing
558 // transcript-scroll behavior. Visible side surfaces keep their ownership.
559 // Other modals still own wheel input exclusively (#4371, #6045).
560 if matches!(
561 app.view_stack.top_kind(),
562 Some(ModalKind::Approval | ModalKind::UserInput)
563 ) {
564 let over_prompt = mouse_hits_rect(mouse, app.viewport.last_prompt_area);
565 let over_side_surface = mouse_hits_rect(mouse, app.work_surface.last_area);
566 let direction = match mouse.kind {
567 MouseEventKind::ScrollUp => Some(ScrollDirection::Up),
568 MouseEventKind::ScrollDown => Some(ScrollDirection::Down),
569 _ => None,
570 };
571 if let Some(direction) = direction {
572 if over_prompt && app.view_stack.top_kind() == Some(ModalKind::UserInput) {
573 app.needs_redraw = true;
574 return app.view_stack.handle_mouse(mouse);
575 }
576 if over_prompt || !over_side_surface {
577 scroll_transcript_with_mouse(app, direction);
578 }
579 return Vec::new();
580 }
581 }
582
583 if !app.view_stack.is_empty() {
584 app.needs_redraw = true;
585 return app.view_stack.handle_mouse(mouse);
586 }
587
588 // A drag can finish outside the composer/transcript that started it.
589 // Publish once before other visible surfaces consume the release event.
590 if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left))
591 && app.clipboard.uses_primary_selection()
592 {
593 let text = if app.viewport.transcript_selection.dragging {
594 selection_to_text(app).unwrap_or_default()
595 } else {
596 app.selected_text()
597 };
598 let _ = app.clipboard.write_primary_text(&text);
599 }
600
601 // Topbar facts are typed controls, not decorative text. Route this before
602 // either launch or session content so a segment painted in the one shared
603 // header has identical mouse behavior in both shell states.
604 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
605 let action = app
606 .viewport
607 .interaction_targets
608 .target_at(mouse.column, mouse.row)
609 .and_then(|target| target.mouse_action);
610 if let Some(action) = action
611 && !matches!(
612 action,
613 InteractionAction::ShowDockPanel(_) | InteractionAction::DismissDock
614 )
615 {
616 app.needs_redraw = true;
617 return match action {
618 InteractionAction::InspectContext => {
619 open_context_inspector(app);
620 Vec::new()
621 }
622 InteractionAction::OpenProviderPicker => {
623 vec![ViewEvent::TopbarRoutePickerRequested]
624 }
625 InteractionAction::OpenAutomations => apply_sidebar_row_action(
626 app,
627 SidebarRowAction::Command("/automation".to_string()),
628 ),
629 InteractionAction::OpenModelPicker => {
630 vec![ViewEvent::TopbarModelPickerRequested]
631 }
632 InteractionAction::ShowDockPanel(_) | InteractionAction::DismissDock => {
633 unreachable!("dock targets defer to the strip")
634 }
635 };
636 }
637 }
638
639 // The launch card is content on the ordinary screen, not a surface that
640 // owns the frame. It used to consume every mouse event and return, which
641 // was right when it *was* a separate surface and became a bug the moment
642 // it stopped being one: scrolling, the real composer, the work surface
643 // and every other target were unreachable while it was up, and the
644 // send-glyph branch pointed at a `send_area` the deleted launch composer
645 // used to set. So the card takes its own rows and lets everything else
646 // fall through to the handlers that own it.
647 if app.launch.visible && !app.launch.row_hitboxes.is_empty() {
648 let hit = app
649 .launch
650 .row_hitboxes
651 .iter()
652 .position(|(_, area)| mouse_hits_rect(mouse, Some(*area)));
653 match mouse.kind {
654 MouseEventKind::Moved => {
655 if hit != app.launch.hovered_row {
656 app.launch.hovered_row = hit;
657 app.needs_redraw = true;
658 }
659 }
660 MouseEventKind::Down(MouseButton::Left) => {
661 if let Some(index) = hit {
662 let id = app.launch.row_hitboxes[index].0.clone();
663 match &id {
664 // Resuming replaces the whole session context —
665 // founder live-test: "you just click it and boom
666 // you're there ... you don't realize it's happening".
667 // It asks first. New session and See all stay one
668 // click, because neither discards anything.
669 crate::tui::app::LaunchRowId::Recent(session_id) => {
670 app.launch.menu_selected = Some(index);
671 crate::tui::underwater::open_launch_resume_confirm(app, session_id);
672 }
673 _ => {
674 app.launch.status = None;
675 app.pending_launch_action =
676 Some(crate::tui::underwater::launch_row_click_action(&id));
677 }
678 }
679 app.needs_redraw = true;
680 return Vec::new();
681 }
682 }
683 _ => {}
684 }
685 }
686
687 // Ocean work surface owns its rect, scrolling, focus, and row actions.
688 // Route it before workflow/composer/transcript so wheel events never leak
689 // into an unrelated viewport.
690 let work_surface = crate::tui::work_surface::handle_mouse(app, mouse);
691 if let Some(action) = work_surface.action {
692 return apply_sidebar_row_action(app, action);
693 }
694 if work_surface.consumed {
695 return Vec::new();
696 }
697 // The posture bar's live counts open the dock view they count. The
698 // strip's own tabs were consumed above; anything left carrying a dock
699 // action is a footer chip.
700 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
701 && let Some(InteractionAction::ShowDockPanel(panel)) = app
702 .viewport
703 .interaction_targets
704 .target_at(mouse.column, mouse.row)
705 .and_then(|target| target.mouse_action)
706 {
707 crate::tui::work_surface::select_dock_panel(app, panel);
708 // Clicking the affordance teaches it just as well as the chord does.
709 app.note_footer_hint_used(crate::tui::footer_hints::DOCK_OPEN);
710 return Vec::new();
711 }
712
713 // WorkflowPanel toggle / cancel (#4121) before composer so the strip
714 // above the input remains clickable.
715 if handle_workflow_panel_mouse(app, mouse) {
716 return Vec::new();
717 }
718
719 if let Some(events) = handle_plugin_cta_mouse(app, mouse) {
720 return events;
721 }
722
723 // Composer mouse events take priority over transcript.
724 if handle_composer_mouse(app, mouse) {
725 return Vec::new();
726 }
727
728 match mouse.kind {
729 MouseEventKind::Moved => {
730 // Update last mouse position for tooltip rendering + hover layer.
731 app.last_mouse_pos = Some((mouse.column, mouse.row));
732 let previous_hover = crate::tui::hover_layer::current_hover();
733 crate::tui::hover_layer::set_pointer(mouse.column, mouse.row);
734 crate::tui::hover_layer::resolve_hover();
735 if crate::tui::hover_layer::current_hover() != previous_hover {
736 app.needs_redraw = true;
737 }
738
739 // Check sidebar sections for hover popovers. Only surface a
740 // popover when the hovered row lost information in the compact
741 // sidebar view.
742 let mut found = false;
743 for section in &app.sidebar_hover.sections {
744 if mouse.column >= section.content_area.x
745 && mouse.column
746 < section
747 .content_area
748 .x
749 .saturating_add(section.content_area.width)
750 && mouse.row >= section.content_area.y
751 && mouse.row
752 < section
753 .content_area
754 .y
755 .saturating_add(section.content_area.height)
756 {
757 if let Some(row) = section.rows.iter().find(|row| row.row_y == mouse.row) {
758 let desired = row.is_truncated.then(|| {
759 if let Some(detail) = row.detail.as_deref()
760 && !detail.trim().is_empty()
761 {
762 format!("{}\n{detail}", row.full_text)
763 } else {
764 row.full_text.clone()
765 }
766 });
767 if app.sidebar_hover_tooltip != desired {
768 app.sidebar_hover_tooltip = desired;
769 app.needs_redraw = true;
770 }
771 found = true;
772 break;
773 } else if section.rows.is_empty() {
774 let line_idx = (mouse.row.saturating_sub(section.content_area.y)) as usize;
775 if let Some(full) = section.lines.get(line_idx) {
776 let truncated =
777 text_display_width(full) > section.content_area.width as usize;
778 let desired = truncated.then(|| full.clone());
779 if app.sidebar_hover_tooltip != desired {
780 app.sidebar_hover_tooltip = desired;
781 app.needs_redraw = true;
782 }
783 found = true;
784 break;
785 }
786 }
787 }
788 }
789 if !found && app.sidebar_hover_tooltip.is_some() {
790 app.sidebar_hover_tooltip = None;
791 app.needs_redraw = true;
792 }
793 }
794 MouseEventKind::ScrollUp => {
795 scroll_transcript_with_mouse(app, ScrollDirection::Up);
796 }
797 MouseEventKind::ScrollDown => {
798 scroll_transcript_with_mouse(app, ScrollDirection::Down);
799 }
800 MouseEventKind::Down(MouseButton::Left) => {
801 app.viewport.transcript_scrollbar_dragging = false;
802 app.viewport.selection_autoscroll = None;
803
804 // #3028/#4009: Check sidebar hover state for clickable rows before
805 // falling through to transcript selection. Command rows still use
806 // the command-palette pipeline; agent rows are direct UI actions.
807 if let Some(action) = sidebar_click_action(app, mouse) {
808 return apply_sidebar_row_action(app, action);
809 }
810
811 // Click on the transcript scrollbar gutter starts a scrollbar
812 // drag so the visible thumb remains interactive for users who
813 // prefer mouse-based navigation.
814 if mouse_hits_transcript_scrollbar(app, mouse) {
815 app.viewport.transcript_scrollbar_dragging = true;
816 return Vec::new();
817 }
818
819 if mouse_hits_rect(mouse, app.viewport.jump_to_latest_button_area) {
820 app.scroll_to_bottom();
821 return Vec::new();
822 }
823
824 if toggle_tool_run_expand(app, mouse) {
825 return Vec::new();
826 }
827
828 if let Some(point) = selection_point_from_mouse(app, mouse) {
829 app.viewport.transcript_selection.anchor = Some(point);
830 app.viewport.transcript_selection.head = Some(point);
831 app.viewport.transcript_selection.dragging = true;
832 app.needs_redraw = true;
833
834 if app.is_loading
835 && app.viewport.transcript_scroll.is_at_tail()
836 && let Some(anchor) = TranscriptScroll::anchor_for(
837 app.viewport.transcript_cache.line_meta(),
838 app.viewport.last_transcript_top,
839 )
840 {
841 app.viewport.transcript_scroll = anchor;
842 }
843 } else {
844 clear_transcript_selection(app);
845 }
846 }
847 MouseEventKind::Drag(MouseButton::Left) => {
848 if app.viewport.transcript_scrollbar_dragging {
849 scroll_transcript_to_mouse_row(app, mouse.row);
850 return Vec::new();
851 }
852
853 if app.viewport.transcript_selection.dragging {
854 update_selection_drag(app, mouse);
855 }
856 }
857 MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_scrollbar_dragging => {
858 app.viewport.transcript_scrollbar_dragging = false;
859 app.viewport.selection_autoscroll = None;
860 app.needs_redraw = true;
861 }
862 MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_selection.dragging => {
863 app.viewport.transcript_selection.dragging = false;
864 app.viewport.selection_autoscroll = None;
865 if selection_has_content(app) && !app.clipboard.uses_primary_selection() {
866 copy_active_selection(app);
867 }
868 }
869 MouseEventKind::Down(MouseButton::Right) => {
870 open_context_menu(app, mouse);
871 }
872 _ => {}
873 }
874
875 Vec::new()
876 }
877
878 fn scroll_transcript_with_mouse(app: &mut App, direction: ScrollDirection) {
879 let update = app.viewport.mouse_scroll.on_scroll(direction);
880 app.viewport.pending_scroll_delta = app
881 .viewport
882 .pending_scroll_delta
883 .saturating_add(update.delta_lines);
884 if update.delta_lines != 0 {
885 app.user_scrolled_during_stream = true;
886 app.needs_redraw = true;
887 }
888 }
889
890 /// Resolve a right-click in the sidebar to the hovered row's full copyable
891 /// text: the row's untruncated text plus its hover detail when present.
892 fn sidebar_row_copy_text(app: &App, mouse: MouseEvent) -> Option<String> {
893 for section in &app.sidebar_hover.sections {
894 if !mouse_hits_rect(mouse, Some(section.content_area)) {
895 continue;
896 }
897 if let Some(row) = section.rows.iter().find(|row| row.row_y == mouse.row) {
898 let mut text = row.full_text.clone();
899 if let Some(detail) = row.detail.as_deref()
900 && !detail.trim().is_empty()
901 {
902 text.push('\n');
903 text.push_str(detail);
904 }
905 return Some(text).filter(|text| !text.trim().is_empty());
906 }
907 let line_idx = (mouse.row.saturating_sub(section.content_area.y)) as usize;
908 if let Some(full) = section.lines.get(line_idx) {
909 return Some(full.clone()).filter(|text| !text.trim().is_empty());
910 }
911 }
912 None
913 }
914
915 fn first_line(text: &str) -> &str {
916 text.lines().next().unwrap_or(text)
917 }
918
919 /// Resolve a left-click in the sidebar to a typed row action, if the clicked
920 /// row has a click action assigned (#3028, #4009).
921 fn sidebar_click_action(app: &App, mouse: MouseEvent) -> Option<SidebarRowAction> {
922 for section in &app.sidebar_hover.sections {
923 if mouse.column >= section.content_area.x
924 && mouse.column
925 < section
926 .content_area
927 .x
928 .saturating_add(section.content_area.width)
929 && mouse.row >= section.content_area.y
930 && mouse.row
931 < section
932 .content_area
933 .y
934 .saturating_add(section.content_area.height)
935 && let Some(row) = section.rows.iter().find(|row| row.row_y == mouse.row)
936 {
937 if let (Some(action), Some(start), Some(end)) = (
938 row.stop_action.as_ref(),
939 row.stop_zone_start_col,
940 row.stop_zone_end_col,
941 ) && mouse.column >= start
942 && mouse.column < end
943 {
944 return Some(action.clone());
945 }
946 return row.click_action.clone();
947 }
948 }
949 None
950 }
951
952 pub(crate) fn apply_sidebar_row_action(app: &mut App, action: SidebarRowAction) -> Vec<ViewEvent> {
953 match action {
954 SidebarRowAction::Command(command) => {
955 use crate::tui::views::CommandPaletteAction;
956 vec![ViewEvent::CommandPaletteSelected {
957 action: CommandPaletteAction::ExecuteCommand { command },
958 }]
959 }
960 SidebarRowAction::PrefillCommand(command) => {
961 app.input = command;
962 app.cursor_position = app.input.len();
963 app.status_message = Some(app.tr(MessageId::SidebarDestructiveArmed).into_owned());
964 app.needs_redraw = true;
965 Vec::new()
966 }
967 SidebarRowAction::ShowSubagentsPanel => {
968 use crate::tui::work_surface::RailPanel;
969 // The register header is a two-way door: opening the Agents panel
970 // from anywhere, and returning to Tasks when it is already open,
971 // so the to-do list is never one click away with no way back.
972 let target = match app.work_surface.panel {
973 RailPanel::Agents => RailPanel::Tasks,
974 _ => RailPanel::Agents,
975 };
976 crate::tui::work_surface::select_dock_panel(app, target);
977 app.status_message = Some(
978 match target {
979 RailPanel::Agents => "Showing subagents",
980 _ => "Showing tasks",
981 }
982 .to_string(),
983 );
984 app.needs_redraw = true;
985 Vec::new()
986 }
987 SidebarRowAction::OpenAgentDetail { agent_id } => {
988 if !crate::tui::agent_details::open_agent_details(app, &agent_id) {
989 crate::tui::work_surface::agent_details_closed(app, &agent_id);
990 app.status_message = Some("Agent details are unavailable".to_string());
991 }
992 app.needs_redraw = true;
993 Vec::new()
994 }
995 SidebarRowAction::OpenAgentTranscript { agent_id } => {
996 // The primary agent destination: focus the worker in place. The
997 // focused view explains a missing capture instead of dead-ending.
998 crate::tui::agent_focus::focus_agent(app, &agent_id);
999 app.needs_redraw = true;
1000 Vec::new()
1001 }
1002 SidebarRowAction::CancelAgent { agent_id } => {
1003 vec![ViewEvent::SidebarAgentCancel { agent_id }]
1004 }
1005 SidebarRowAction::InspectWork {
1006 title,
1007 body,
1008 stop_action,
1009 } => {
1010 let width = app
1011 .viewport
1012 .last_transcript_area
1013 .map(|area| area.width)
1014 .unwrap_or(80);
1015 let mut pager =
1016 crate::tui::pager::PagerView::from_text(title, &body, width.saturating_sub(2))
1017 .with_copy_text(body);
1018 let stop_event = stop_action.and_then(|action| match *action {
1019 SidebarRowAction::Command(command) => {
1020 use crate::tui::views::CommandPaletteAction;
1021 Some(ViewEvent::CommandPaletteSelected {
1022 action: CommandPaletteAction::ExecuteCommand { command },
1023 })
1024 }
1025 SidebarRowAction::CancelAgent { agent_id } => {
1026 Some(ViewEvent::SidebarAgentCancel { agent_id })
1027 }
1028 _ => None,
1029 });
1030 if let Some(event) = stop_event {
1031 pager = pager.with_destructive_action(
1032 's',
1033 app.tr(MessageId::SidebarStopControl),
1034 app.tr(MessageId::WorkSurfaceStopConfirmHint),
1035 event,
1036 );
1037 }
1038 app.view_stack.push(pager);
1039 app.needs_redraw = true;
1040 Vec::new()
1041 }
1042 }
1043 }
1044
1045 pub(crate) fn resolve_agent_transcript_text(app: &App, agent_id: &str) -> Option<String> {
1046 use crate::tools::handle::{HandleValue, VarHandle};
1047
1048 let lookup = VarHandle {
1049 kind: "var_handle".to_string(),
1050 session_id: format!("agent:{agent_id}"),
1051 name: "full_transcript".to_string(),
1052 type_name: String::new(),
1053 length: 0,
1054 repr_preview: String::new(),
1055 sha256: String::new(),
1056 };
1057 let payload = match app.runtime_services.handle_store.try_lock() {
1058 Ok(store) => match store.get(&lookup) {
1059 Some(record) => match &record.value {
1060 HandleValue::Json(value) => Some(value.clone()),
1061 HandleValue::Text(_) => None,
1062 },
1063 None => None,
1064 },
1065 Err(_) => return None,
1066 };
1067
1068 // The handle is a deliberately bounded live projection. Prefer the private
1069 // on-disk message stream so Open means the entire chat, including early
1070 // turns that no longer fit in the 1 MiB resident tail. While the worker is
1071 // live, require its artifact count to match the latest handle count; a
1072 // failed/stale append must fall back to the explicit omission banner. With
1073 // no process-local handle (for example after restart), the validated
1074 // artifact remains the durable source of truth.
1075 if let Ok(messages) =
1076 crate::tools::subagent::load_subagent_transcript_artifact(&app.workspace, agent_id)
1077 {
1078 let matches_resident_count = payload.as_ref().is_none_or(|resident| {
1079 resident
1080 .get("message_count")
1081 .and_then(serde_json::Value::as_u64)
1082 .and_then(|count| usize::try_from(count).ok())
1083 == Some(messages.len())
1084 && resident
1085 .get("complete_transcript_artifact")
1086 .and_then(|artifact| artifact.get("complete"))
1087 .and_then(serde_json::Value::as_bool)
1088 .unwrap_or(true)
1089 });
1090 if matches_resident_count {
1091 let text = agent_messages_text(&messages);
1092 if !text.trim().is_empty() {
1093 return Some(text);
1094 }
1095 }
1096 }
1097
1098 let payload = payload?;
1099 let text = agent_transcript_text(&payload);
1100 if text.trim().is_empty() {
1101 return None;
1102 }
1103 Some(text)
1104 }
1105
1106 pub(crate) fn agent_transcript_evidence_available(app: &App, agent_id: &str) -> bool {
1107 resolve_agent_transcript_text(app, agent_id).is_some()
1108 }
1109
1110 /// Turn the agent transcript handle into a readable conversation. The worker
1111 /// may retain tool calls and results, but private model thinking never appears
1112 /// here; the parent transcript has the same default privacy behavior.
1113 fn agent_transcript_text(payload: &serde_json::Value) -> String {
1114 let Some(messages) = payload
1115 .get("messages")
1116 .and_then(serde_json::Value::as_array)
1117 else {
1118 return String::new();
1119 };
1120
1121 let omitted = payload
1122 .get("omitted_messages")
1123 .and_then(serde_json::Value::as_u64)
1124 .unwrap_or_default();
1125 let total = payload
1126 .get("message_count")
1127 .and_then(serde_json::Value::as_u64)
1128 .unwrap_or(messages.len() as u64);
1129 let mut text = String::new();
1130 if omitted > 0 {
1131 text.push_str(&format!(
1132 "Showing the latest {} of {total} worker messages. Earlier messages were omitted from the in-memory transcript.\n\n",
1133 messages.len()
1134 ));
1135 }
1136
1137 let parsed: Vec<Message> = messages
1138 .iter()
1139 .filter_map(|raw| serde_json::from_value::<Message>(raw.clone()).ok())
1140 .collect();
1141 text.push_str(&agent_messages_text(&parsed));
1142 text
1143 }
1144
1145 fn agent_messages_text(messages: &[Message]) -> String {
1146 let mut text = String::new();
1147 for message in messages {
1148 let body = agent_message_text(message);
1149 if body.trim().is_empty() {
1150 continue;
1151 }
1152 text.push_str(&format!("── {} ──\n{body}\n\n", message.role));
1153 }
1154 text
1155 }
1156
1157 fn agent_message_text(message: &Message) -> String {
1158 let mut text = String::new();
1159 for block in &message.content {
1160 match block {
1161 ContentBlock::Text { text: body, .. } => {
1162 if !body.trim().is_empty() {
1163 text.push_str(body);
1164 text.push('\n');
1165 }
1166 }
1167 ContentBlock::ToolUse { name, input, .. }
1168 | ContentBlock::ServerToolUse { name, input, .. } => {
1169 text.push_str(&format!(
1170 "→ {name}\n{}\n",
1171 serde_json::to_string_pretty(input).unwrap_or_else(|_| input.to_string())
1172 ));
1173 }
1174 ContentBlock::ToolResult {
1175 tool_use_id,
1176 content,
1177 is_error,
1178 ..
1179 } => {
1180 let label = if is_error.unwrap_or(false) {
1181 "← tool error"
1182 } else {
1183 "← tool result"
1184 };
1185 text.push_str(&format!("{label} ({tool_use_id})\n{content}\n"));
1186 }
1187 ContentBlock::ImageUrl { image_url } => {
1188 text.push_str(&format!("[image: {}]\n", image_url.url));
1189 }
1190 // Thinking blocks are deliberately not surfaced in the main TUI
1191 // and should not leak through a worker detail view either.
1192 ContentBlock::Thinking { .. } => {}
1193 other => {
1194 text.push_str(&format!(
1195 "{}\n",
1196 serde_json::to_string_pretty(other).unwrap_or_else(|_| "[worker event]".into())
1197 ));
1198 }
1199 }
1200 }
1201 text.trim_end().to_string()
1202 }
1203
1204 pub(crate) fn mouse_hits_transcript_scrollbar(app: &App, mouse: MouseEvent) -> bool {
1205 let Some(area) = app.viewport.last_transcript_area else {
1206 return false;
1207 };
1208 if area.width <= 1 || app.viewport.last_transcript_total <= app.viewport.last_transcript_visible
1209 {
1210 return false;
1211 }
1212
1213 let scrollbar_col = area.x.saturating_add(area.width.saturating_sub(1));
1214 mouse.column == scrollbar_col
1215 && mouse.row >= area.y
1216 && mouse.row < area.y.saturating_add(area.height)
1217 }
1218
1219 pub(crate) fn scroll_transcript_to_mouse_row(app: &mut App, row: u16) -> bool {
1220 let Some(area) = app.viewport.last_transcript_area else {
1221 return false;
1222 };
1223 let total = app.viewport.last_transcript_total;
1224 let visible = app.viewport.last_transcript_visible;
1225 if area.height == 0 || total <= visible {
1226 return false;
1227 }
1228
1229 let max_start = total.saturating_sub(visible);
1230 if max_start == 0 {
1231 app.scroll_to_bottom();
1232 return true;
1233 }
1234
1235 let max_row = usize::from(area.height.saturating_sub(1));
1236 let relative_row = usize::from(row.saturating_sub(area.y)).min(max_row);
1237 let numerator = relative_row
1238 .saturating_mul(max_start)
1239 .saturating_add(max_row / 2);
1240 // Round to the nearest transcript offset so short thumbs still feel
1241 // responsive on compact terminals.
1242 let top = numerator.checked_div(max_row).unwrap_or(0);
1243
1244 app.viewport.transcript_scroll = if top >= max_start {
1245 TranscriptScroll::to_bottom()
1246 } else {
1247 TranscriptScroll::at_line(top)
1248 };
1249 app.viewport.pending_scroll_delta = 0;
1250 app.user_scrolled_during_stream = !app.viewport.transcript_scroll.is_at_tail();
1251 app.needs_redraw = true;
1252 true
1253 }
1254
1255 /// Cadence between auto-scroll ticks while drag-selecting past the
1256 /// transcript edge (#1163). 30 ms ≈ 33 lines/sec, comparable to the feel
1257 /// of a steady scroll-wheel drag.
1258 const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30);
1259
1260 /// Update the transcript selection while the left button is dragging.
1261 /// When the mouse leaves the transcript rect vertically, arm
1262 /// `selection_autoscroll` so the main loop can advance the viewport on a
1263 /// fixed cadence; when the mouse returns inside, disarm it.
1264 pub(crate) fn update_selection_drag(app: &mut App, mouse: MouseEvent) {
1265 if let Some(point) = selection_point_from_mouse(app, mouse) {
1266 app.viewport.transcript_selection.head = Some(point);
1267 app.viewport.selection_autoscroll = None;
1268 app.needs_redraw = true;
1269 return;
1270 }
1271
1272 let Some(area) = app.viewport.last_transcript_area else {
1273 return;
1274 };
1275 if area.height == 0 || area.width == 0 {
1276 return;
1277 }
1278
1279 let direction = if mouse.row < area.y {
1280 -1
1281 } else if mouse.row >= area.y.saturating_add(area.height) {
1282 1
1283 } else {
1284 // Outside horizontally only — leave selection head where it is.
1285 return;
1286 };
1287
1288 let max_col = area.x.saturating_add(area.width.saturating_sub(1));
1289 let column = mouse.column.clamp(area.x, max_col);
1290
1291 // Fire on the next tick immediately by setting `next_tick` to now.
1292 app.viewport.selection_autoscroll = Some(SelectionAutoscroll {
1293 direction,
1294 column,
1295 next_tick: Instant::now(),
1296 });
1297 app.needs_redraw = true;
1298 }
1299
1300 /// Advance the drag-edge auto-scroll one step if its cadence has elapsed.
1301 /// Called once per main-loop iteration.
1302 pub(crate) fn tick_selection_autoscroll(app: &mut App) {
1303 let Some(state) = app.viewport.selection_autoscroll else {
1304 return;
1305 };
1306
1307 if !app.viewport.transcript_selection.dragging {
1308 app.viewport.selection_autoscroll = None;
1309 return;
1310 }
1311
1312 let Some(area) = app.viewport.last_transcript_area else {
1313 return;
1314 };
1315 if area.height == 0 {
1316 return;
1317 }
1318
1319 let now = Instant::now();
1320 if now < state.next_tick {
1321 return;
1322 }
1323
1324 app.viewport.pending_scroll_delta = app
1325 .viewport
1326 .pending_scroll_delta
1327 .saturating_add(state.direction);
1328 app.user_scrolled_during_stream = true;
1329
1330 let edge_row = if state.direction < 0 {
1331 area.y
1332 } else {
1333 area.y.saturating_add(area.height.saturating_sub(1))
1334 };
1335 if let Some(point) = selection_point_from_position(
1336 area,
1337 state.column,
1338 edge_row,
1339 app.viewport.last_transcript_top,
1340 app.viewport.last_transcript_total,
1341 app.viewport.last_transcript_padding_top,
1342 ) {
1343 app.viewport.transcript_selection.head = Some(point);
1344 }
1345
1346 app.viewport.selection_autoscroll = Some(SelectionAutoscroll {
1347 next_tick: now + SELECTION_AUTOSCROLL_INTERVAL,
1348 ..state
1349 });
1350 app.needs_redraw = true;
1351 }
1352
1353 pub(crate) fn mouse_hits_rect(mouse: MouseEvent, area: Option<Rect>) -> bool {
1354 point_hits_rect(mouse.column, mouse.row, area)
1355 }
1356
1357 fn point_hits_rect(column: u16, row: u16, area: Option<Rect>) -> bool {
1358 let Some(area) = area else {
1359 return false;
1360 };
1361
1362 column >= area.x
1363 && column < area.x.saturating_add(area.width)
1364 && row >= area.y
1365 && row < area.y.saturating_add(area.height)
1366 }
1367
1368 pub(crate) fn open_context_menu(app: &mut App, mouse: MouseEvent) {
1369 let entries = build_context_menu_entries(app, mouse);
1370 if entries.is_empty() {
1371 return;
1372 }
1373 let title = app.tr(MessageId::CtxMenuTitle).to_string();
1374 let reduced = app.motion_policy().as_low_motion();
1375 app.view_stack.push(ContextMenuView::new_with_motion(
1376 entries,
1377 mouse.column,
1378 mouse.row,
1379 title,
1380 reduced,
1381 ));
1382 app.needs_redraw = true;
1383 }
1384
1385 pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec<ContextMenuEntry> {
1386 let mut entries = Vec::new();
1387 let mut git_path = None;
1388 let on_sidebar = mouse_hits_rect(mouse, app.work_surface.last_area);
1389
1390 if on_sidebar {
1391 if let Some(command) = sidebar_click_action(app, mouse)
1392 .and_then(|action| action.as_command().map(str::to_string))
1393 {
1394 entries.push(
1395 ContextMenuEntry::new(
1396 "Run",
1397 command.clone(),
1398 ContextMenuAction::ExecuteCommand { command },
1399 )
1400 .with_glyph("▶")
1401 .primary(),
1402 );
1403 }
1404 // Copy the hovered row's full text (sidebar rows can't be
1405 // mouse-selected, so the menu is the only copy path).
1406 if let Some(text) = sidebar_row_copy_text(app, mouse) {
1407 entries.push(
1408 ContextMenuEntry::new(
1409 "Copy",
1410 truncate_line_to_width(first_line(&text), 28),
1411 ContextMenuAction::CopyText { text },
1412 )
1413 .with_glyph("⎘")
1414 .with_hint("y"),
1415 );
1416 }
1417 } else {
1418 // Paste first — the most common action when right-clicking in the
1419 // composer or transcript after copying text from the output area.
1420 entries.push(
1421 ContextMenuEntry::new(
1422 app.tr(MessageId::CtxMenuPaste),
1423 app.tr(MessageId::CtxMenuPasteDesc),
1424 ContextMenuAction::Paste,
1425 )
1426 .with_glyph("📋")
1427 .with_hint("p")
1428 .primary(),
1429 );
1430 }
1431
1432 if selection_has_content(app) {
1433 entries.push(
1434 ContextMenuEntry::new(
1435 app.tr(MessageId::CtxMenuCopySelection),
1436 app.tr(MessageId::CtxMenuCopySelectionDesc),
1437 ContextMenuAction::CopySelection,
1438 )
1439 .with_glyph("⎘")
1440 .with_hint("y")
1441 .section_start(),
1442 );
1443 entries.push(
1444 ContextMenuEntry::new(
1445 app.tr(MessageId::CtxMenuOpenSelection),
1446 app.tr(MessageId::CtxMenuOpenSelectionDesc),
1447 ContextMenuAction::OpenSelection,
1448 )
1449 .with_glyph("↗"),
1450 );
1451 entries.push(
1452 ContextMenuEntry::new(
1453 app.tr(MessageId::CtxMenuClearSelection),
1454 "",
1455 ContextMenuAction::ClearSelection,
1456 )
1457 .with_glyph("×"),
1458 );
1459 }
1460
1461 if !on_sidebar && let Some(filtered_cell_index) = transcript_cell_index_from_mouse(app, mouse) {
1462 let cell_index = app.original_cell_index_for_rendered(filtered_cell_index);
1463 git_path = context_menu_git_path(app, cell_index);
1464
1465 let target = detail_target_label(app, cell_index)
1466 .map(|label| truncate_line_to_width(label.as_str(), 28))
1467 .unwrap_or_else(|| "message".to_string());
1468 entries.push(
1469 ContextMenuEntry::new(
1470 app.tr(MessageId::CtxMenuOpenDetails),
1471 target,
1472 ContextMenuAction::OpenDetails { cell_index },
1473 )
1474 .with_glyph("▣")
1475 .section_start(),
1476 );
1477 entries.push(
1478 ContextMenuEntry::new(
1479 app.tr(MessageId::CtxMenuCopyMessage),
1480 app.tr(MessageId::CtxMenuCopyMessageDesc),
1481 ContextMenuAction::CopyCell { cell_index },
1482 )
1483 .with_glyph("⎘"),
1484 );
1485 entries.push(
1486 ContextMenuEntry::new(
1487 app.tr(MessageId::CtxMenuOpenInEditor),
1488 app.tr(MessageId::CtxMenuOpenInEditorDesc),
1489 ContextMenuAction::OpenFileAtLine { cell_index },
1490 )
1491 .with_glyph("↗")
1492 .with_hint("e"),
1493 );
1494 // Hide/show cell toggle.
1495 if app.collapsed_cells.contains(&cell_index) {
1496 entries.push(
1497 ContextMenuEntry::new(
1498 app.tr(MessageId::CtxMenuShowCell),
1499 app.tr(MessageId::CtxMenuShowCellDesc),
1500 ContextMenuAction::ShowCell { cell_index },
1501 )
1502 .with_glyph("◇"),
1503 );
1504 } else {
1505 entries.push(
1506 ContextMenuEntry::new(
1507 app.tr(MessageId::CtxMenuHideCell),
1508 app.tr(MessageId::CtxMenuHideCellDesc),
1509 ContextMenuAction::HideCell { cell_index },
1510 )
1511 .with_glyph("○"),
1512 );
1513 }
1514 }
1515
1516 // When cells are hidden, offer a way to show them all.
1517 if !app.collapsed_cells.is_empty() {
1518 let count = app.collapsed_cells.len();
1519 let label = app.tr(MessageId::CtxMenuShowHidden).to_string();
1520 entries.push(
1521 ContextMenuEntry::new(
1522 format!("{label} ({count})"),
1523 app.tr(MessageId::CtxMenuShowHiddenDesc),
1524 ContextMenuAction::ShowAllHidden,
1525 )
1526 .with_glyph("◇")
1527 .section_start(),
1528 );
1529 }
1530
1531 entries.push(
1532 ContextMenuEntry::new(
1533 app.tr(MessageId::CtxMenuCmdPalette),
1534 app.tr(MessageId::CtxMenuCmdPaletteDesc),
1535 ContextMenuAction::OpenCommandPalette,
1536 )
1537 .with_glyph("⌘")
1538 .section_start(),
1539 );
1540 entries.push(
1541 ContextMenuEntry::new(
1542 app.tr(MessageId::CtxMenuContextInspector),
1543 app.tr(MessageId::CtxMenuContextInspectorDesc),
1544 ContextMenuAction::OpenContextInspector,
1545 )
1546 .with_glyph("ⓘ"),
1547 );
1548 entries.push(
1549 ContextMenuEntry::new(
1550 app.tr(MessageId::CtxMenuHelp),
1551 app.tr(MessageId::CtxMenuHelpDesc),
1552 ContextMenuAction::OpenHelp,
1553 )
1554 .with_glyph("?"),
1555 );
1556
1557 // Host window control (Windows only): pin/unpin the terminal window into
1558 // an always-on-top mini window. Global action, listed after the app
1559 // chrome entries. The label flips while pinned ("还原窗口" instead of
1560 // "弹出置顶小窗") so the entry always describes what the click will do.
1561 if crate::tui::window_control::available() {
1562 let pinned = crate::tui::window_control::pinned();
1563 entries.push(
1564 ContextMenuEntry::new(
1565 app.tr(if pinned {
1566 MessageId::CtxMenuWindowUnpin
1567 } else {
1568 MessageId::CtxMenuWindowPin
1569 }),
1570 app.tr(MessageId::CtxMenuWindowPinDesc),
1571 ContextMenuAction::ToggleWindowPin,
1572 )
1573 .with_glyph(if pinned { "↩" } else { "📌" }),
1574 );
1575 }
1576
1577 let branch = git_path
1578 .as_deref()
1579 .and_then(|_| crate::tui::workspace_context::branch(&app.workspace));
1580 crate::tui::context_menu::with_git_actions(entries, git_path.as_deref(), branch.as_deref())
1581 }
1582
1583 fn context_menu_git_path(app: &App, cell_index: usize) -> Option<String> {
1584 use crate::tui::history::ToolCell;
1585
1586 match app.cell_at_virtual_index(cell_index)? {
1587 HistoryCell::Tool(ToolCell::PatchSummary(patch)) => Some(patch.path.clone()),
1588 HistoryCell::Tool(ToolCell::ViewImage(image)) => {
1589 Some(image.path.to_string_lossy().into_owned())
1590 }
1591 _ => None,
1592 }
1593 }
1594
1595 pub(crate) fn transcript_cell_index_from_mouse(app: &App, mouse: MouseEvent) -> Option<usize> {
1596 let point = selection_point_from_mouse(app, mouse)?;
1597 app.viewport
1598 .transcript_cache
1599 .line_meta()
1600 .get(point.line_index)
1601 .and_then(|meta| meta.cell_line())
1602 .map(|(cell_index, _)| cell_index)
1603 }
1604
1605 pub(crate) fn handle_context_menu_action(
1606 terminal: &mut ratatui::Terminal<crate::tui::color_compat::ColorCompatBackend<std::io::Stdout>>,
1607 app: &mut App,
1608 action: ContextMenuAction,
1609 ) {
1610 match action {
1611 ContextMenuAction::CopySelection => {
1612 copy_active_selection(app);
1613 }
1614 ContextMenuAction::OpenSelection => {
1615 if !open_pager_for_selection(app) {
1616 app.status_message = Some("No selection to open".to_string());
1617 }
1618 }
1619 ContextMenuAction::ClearSelection => {
1620 clear_transcript_selection(app);
1621 app.status_message = Some("Selection cleared".to_string());
1622 }
1623 ContextMenuAction::CopyCell { cell_index } => {
1624 copy_cell_to_clipboard(app, cell_index);
1625 }
1626 ContextMenuAction::OpenDetails { cell_index } => {
1627 if !open_details_pager_for_cell(app, cell_index) {
1628 app.status_message = Some("No details available for that line".to_string());
1629 }
1630 }
1631 ContextMenuAction::Paste => {
1632 app.paste_from_clipboard();
1633 }
1634 ContextMenuAction::ExecuteCommand { command } => {
1635 app.input = command;
1636 app.status_message = Some("Command staged in composer".to_string());
1637 app.needs_redraw = true;
1638 }
1639 ContextMenuAction::CopyText { text } => {
1640 if app.clipboard.write_text(&text).is_ok() {
1641 app.status_message = Some("Copied".to_string());
1642 } else {
1643 app.status_message = Some("Copy failed".to_string());
1644 }
1645 }
1646 ContextMenuAction::ToggleWindowPin => {
1647 crate::tui::window_control::toggle_pin(app);
1648 }
1649 ContextMenuAction::OpenCommandPalette => {
1650 codewhale_telemetry::session_counters()
1651 .bump(codewhale_telemetry::Counter::CommandPaletteOpen);
1652 app.view_stack.push(CommandPaletteView::new_for_locale(
1653 app.ui_locale,
1654 build_command_palette_entries(
1655 app.ui_locale,
1656 &app.skills_dir,
1657 app.skills_scan_codewhale_only,
1658 &app.workspace,
1659 &app.mcp_config_path,
1660 app.mcp_snapshot.as_ref(),
1661 ),
1662 ));
1663 }
1664 ContextMenuAction::OpenContextInspector => {
1665 open_context_inspector(app);
1666 }
1667 ContextMenuAction::OpenHelp => {
1668 let help =
1669 HelpView::new_for_workspace(app.ui_locale, &app.workspace, &app.cached_skills)
1670 .with_groups_expanded(app.help_expand_groups);
1671 app.view_stack.push(help);
1672 }
1673 ContextMenuAction::OpenFileAtLine { cell_index } => {
1674 let width = app
1675 .viewport
1676 .last_transcript_area
1677 .map(|area| area.width)
1678 .unwrap_or(80);
1679 let text = history_cell_to_text(
1680 app.cell_at_virtual_index(cell_index)
1681 .unwrap_or(&HistoryCell::System {
1682 content: String::new(),
1683 }),
1684 width,
1685 );
1686 match crate::tui::history::first_file_line_reference(&text, &app.workspace) {
1687 // The editor gets the terminal through the same suspend path
1688 // the composer and `/hooks edit` use, one at a time, and we
1689 // wait for it. It used to be spawned detached while the TUI
1690 // still held raw mode, the alt screen and mouse capture (#6235).
1691 Some((path, line)) => {
1692 let outcome = crate::tui::external_editor::spawn_editor_for_path(
1693 terminal,
1694 app.use_alt_screen(),
1695 app.use_mouse_capture,
1696 app.use_bracketed_paste,
1697 &path,
1698 Some(line),
1699 );
1700 app.needs_redraw = true;
1701 app.status_message = Some(match outcome {
1702 Ok(crate::tui::external_editor::EditorOutcome::Cancelled) => {
1703 format!("Editor exited without opening {}", path.display())
1704 }
1705 Ok(_) => format!("Closed editor for {}:{line}", path.display()),
1706 Err(error) => format!("Could not open the editor: {error}"),
1707 });
1708 }
1709 None => {
1710 app.status_message =
1711 Some("No file:line pattern found in selection".to_string());
1712 }
1713 }
1714 }
1715 ContextMenuAction::HideCell { cell_index } => {
1716 app.collapsed_cells.insert(cell_index);
1717 app.status_message = Some("Cell hidden".to_string());
1718 }
1719 ContextMenuAction::ShowCell { cell_index } => {
1720 app.collapsed_cells.remove(&cell_index);
1721 app.status_message = Some("Cell shown".to_string());
1722 }
1723 ContextMenuAction::ShowAllHidden => {
1724 let count = app.collapsed_cells.len();
1725 app.collapsed_cells.clear();
1726 app.status_message = Some(format!("{count} hidden cell(s) restored"));
1727 }
1728 }
1729 app.needs_redraw = true;
1730 }
1731
1732 pub(crate) fn selection_point_from_mouse(
1733 app: &App,
1734 mouse: MouseEvent,
1735 ) -> Option<TranscriptSelectionPoint> {
1736 selection_point_from_position(
1737 app.viewport.last_transcript_area?,
1738 mouse.column,
1739 mouse.row,
1740 app.viewport.last_transcript_top,
1741 app.viewport.last_transcript_total,
1742 app.viewport.last_transcript_padding_top,
1743 )
1744 }
1745
1746 pub(crate) fn selection_point_from_position(
1747 area: Rect,
1748 column: u16,
1749 row: u16,
1750 transcript_top: usize,
1751 transcript_total: usize,
1752 padding_top: usize,
1753 ) -> Option<TranscriptSelectionPoint> {
1754 if column < area.x
1755 || column >= area.x + area.width
1756 || row < area.y
1757 || row >= area.y + area.height
1758 {
1759 return None;
1760 }
1761
1762 if transcript_total == 0 {
1763 return None;
1764 }
1765
1766 let row = row.saturating_sub(area.y) as usize;
1767 if row < padding_top {
1768 return None;
1769 }
1770 let row = row.saturating_sub(padding_top);
1771
1772 let col = column.saturating_sub(area.x) as usize;
1773 let line_index = transcript_top
1774 .saturating_add(row)
1775 .min(transcript_total.saturating_sub(1));
1776
1777 Some(TranscriptSelectionPoint {
1778 line_index,
1779 column: col,
1780 })
1781 }
1782
1783 pub(crate) fn selection_has_content(app: &App) -> bool {
1784 // Composer selection takes priority (same as Cmd+C handler above).
1785 if !app.selected_text().is_empty() {
1786 return true;
1787 }
1788 selection_to_text(app).is_some_and(|text| !text.is_empty())
1789 }
1790
1791 /// Branches taken by the Ctrl+C key handler. The order encodes priority and is
1792 /// the unit-tested contract for #1337 / #1367: a transcript selection always
1793 /// wins (so users learn that Ctrl+C copies when there's something to copy);
1794 /// otherwise an active turn is interrupted; otherwise the quit-arm flow runs.
1795 #[derive(Debug, PartialEq, Eq)]
1796 pub(crate) enum CtrlCDisposition {
1797 CopySelection,
1798 CancelTurn,
1799 ConfirmExit,
1800 ArmExit,
1801 }
1802
1803 pub(crate) fn ctrl_c_disposition(app: &App) -> CtrlCDisposition {
1804 if selection_has_content(app) {
1805 CtrlCDisposition::CopySelection
1806 } else if app.is_loading
1807 || app.is_compacting
1808 || app.manual_compaction_queued
1809 || app.goal_continuation_waiting
1810 {
1811 CtrlCDisposition::CancelTurn
1812 } else if app.quit_is_armed() {
1813 CtrlCDisposition::ConfirmExit
1814 } else {
1815 CtrlCDisposition::ArmExit
1816 }
1817 }
1818
1819 /// Normalize the raw Ctrl+C control byte to canonical `Ctrl+C`.
1820 ///
1821 /// In PTY/raw-mode the terminal driver delivers Ctrl+C as the literal byte
1822 /// `0x03` (the ETX control character). crossterm usually decodes that to
1823 /// `Char('c') + CONTROL`, but some terminal / kitty-keyboard-protocol
1824 /// combinations surface it as `Char('\u{3}')` instead, where it slips past the
1825 /// `Char('c') + CONTROL` arm of the key handler and never reaches the
1826 /// quit-arm flow (#4090). Rewriting every encoding of Ctrl+C to the canonical
1827 /// form here keeps the double-press-to-exit behavior consistent across PTY,
1828 /// raw-mode, and kitty-enhanced terminals.
1829 pub(crate) fn normalize_raw_ctrl_c(key: &mut KeyEvent) {
1830 if matches!(key.code, KeyCode::Char('\u{3}')) {
1831 key.code = KeyCode::Char('c');
1832 key.modifiers.insert(KeyModifiers::CONTROL);
1833 }
1834 }
1835
1836 pub(crate) fn copy_active_selection(app: &mut App) {
1837 // Composer selection takes priority.
1838 let sel = app.selected_text();
1839 if !sel.is_empty() {
1840 if app.clipboard.write_text(&sel).is_ok() {
1841 app.status_message = Some("Selection copied".to_string());
1842 app.clear_selection();
1843 } else {
1844 app.status_message = Some("Copy failed".to_string());
1845 }
1846 return;
1847 }
1848 if !app.viewport.transcript_selection.is_active() {
1849 return;
1850 }
1851 // Markdown source first (#6156): project every intersected cell through
1852 // the canonical clean-copy path. Falls back to rendered text when the
1853 // `[tui] selection_copy_markdown` key is off or no cell metadata
1854 // intersects the range.
1855 let payload = if app.viewport.selection_copy_markdown {
1856 selection_to_markdown(app).map(|(text, cells)| (text, Some(cells)))
1857 } else {
1858 None
1859 };
1860 let payload = payload.or_else(|| {
1861 selection_to_text(app)
1862 .filter(|text| !text.is_empty())
1863 .map(|text| (text, None))
1864 });
1865 if let Some((text, markdown_cells)) = payload {
1866 if app.clipboard.write_text(&text).is_ok() {
1867 match markdown_cells {
1868 Some(cells) => {
1869 let toast = app
1870 .tr(MessageId::SelectionCopiedAsMarkdown)
1871 .replace("{count}", &cells.to_string());
1872 app.push_status_toast(toast, StatusToastLevel::Info, None);
1873 }
1874 None => app.status_message = Some("Selection copied".to_string()),
1875 }
1876 } else {
1877 app.status_message = Some("Copy failed".to_string());
1878 }
1879 } else {
1880 clear_transcript_selection(app);
1881 app.status_message = Some("No selection to copy".to_string());
1882 }
1883 }
1884
1885 /// Whether a drag selection covers every cell it touches end to end (#6228).
1886 ///
1887 /// Two checks: the edge columns must reach the content edges on the boundary
1888 /// lines, and the line range must not cut a cell in half at either end.
1889 /// Middle lines are fully covered by construction, and cells render as
1890 /// contiguous spans, so the two edge cells decide for the whole range.
1891 fn selection_covers_cells_fully(
1892 app: &App,
1893 start: &TranscriptSelectionPoint,
1894 end: &TranscriptSelectionPoint,
1895 start_index: usize,
1896 end_index: usize,
1897 ) -> bool {
1898 let (first_head, _) = match content_column_span(app, start_index) {
1899 Some(span) => span,
1900 None => return false,
1901 };
1902 if start.column > first_head {
1903 return false;
1904 }
1905 let (_, last_tail) = match content_column_span(app, end_index) {
1906 Some(span) => span,
1907 None => return false,
1908 };
1909 if end.column < last_tail {
1910 return false;
1911 }
1912 let line_meta = app.viewport.transcript_cache.line_meta();
1913 let mut edge_cells = (start_index..=end_index).filter_map(|line_index| {
1914 line_meta
1915 .get(line_index)
1916 .and_then(|meta| meta.cell_line())
1917 .map(|(cell_index, _)| cell_index)
1918 });
1919 let Some(first_cell) = edge_cells.next() else {
1920 return false;
1921 };
1922 let last_cell = edge_cells.next_back().unwrap_or(first_cell);
1923 [first_cell, last_cell].into_iter().all(|cell| {
1924 let mut span = line_meta
1925 .iter()
1926 .enumerate()
1927 .filter_map(|(line_index, meta)| {
1928 meta.cell_line()
1929 .filter(|(cell_index, _)| *cell_index == cell)
1930 .map(|_| line_index)
1931 });
1932 match (span.next(), span.next_back()) {
1933 (Some(cell_first), Some(cell_last)) => {
1934 cell_first >= start_index && cell_last <= end_index
1935 }
1936 (Some(only), None) => start_index <= only && only <= end_index,
1937 (None, _) => false,
1938 }
1939 })
1940 }
1941
1942 /// Rendered-column span of selectable content on one transcript cache line.
1943 ///
1944 /// Mirrors the prefix math in [`selection_to_text`]: rail decorations plus
1945 /// copy-only prefixes are visual, so content runs from their combined width
1946 /// to that width plus the content's display width.
1947 fn content_column_span(app: &App, line_index: usize) -> Option<(usize, usize)> {
1948 let cache = &app.viewport.transcript_cache;
1949 let full_width = text_visible_width(&line_to_plain(cache.lines().get(line_index)?));
1950 let rail_width = cache.rail_prefix_width(line_index).min(full_width);
1951 let copy_prefix = cache
1952 .line_meta()
1953 .get(line_index)
1954 .map(|meta| meta.copy_prefix_width())
1955 .unwrap_or(0)
1956 .min(full_width.saturating_sub(rail_width));
1957 let head = rail_width.saturating_add(copy_prefix);
1958 let tail = head.saturating_add(
1959 full_width
1960 .saturating_sub(rail_width)
1961 .saturating_sub(copy_prefix),
1962 );
1963 Some((head, tail))
1964 }
1965
1966 /// Project a transcript drag selection to Markdown source (#6156).
1967 ///
1968 /// Collects every history cell intersecting the selection's rendered line
1969 /// range, in order, and serializes each through
1970 /// `history_cell_to_clipboard_text` — the same canonical projection Ctrl-Y
1971 /// and `/copy` use — joined with a blank line. Returns the payload plus the
1972 /// projected cell count for the toast.
1973 ///
1974 /// Markdown source is only truthful for whole cells, so a selection that
1975 /// cuts a cell in half is not projected here at all — it keeps its exact
1976 /// rendered text through the caller's [`selection_to_text`] fallback (#6228).
1977 ///
1978 /// Returns `None` when the selection is a fragment, when no cell metadata
1979 /// intersects the range, or when every projection is blank.
1980 pub(crate) fn selection_to_markdown(app: &App) -> Option<(String, usize)> {
1981 let (start, end) = app.viewport.transcript_selection.ordered_endpoints()?;
1982 let lines = app.viewport.transcript_cache.lines();
1983 if lines.is_empty() {
1984 return None;
1985 }
1986 let end_index = end.line_index.min(lines.len().saturating_sub(1));
1987 let start_index = start.line_index.min(end_index);
1988 if !selection_covers_cells_fully(app, &start, &end, start_index, end_index) {
1989 return None;
1990 }
1991 let line_meta = app.viewport.transcript_cache.line_meta();
1992 let width = app
1993 .viewport
1994 .last_transcript_area
1995 .map(|area| area.width)
1996 .unwrap_or(80);
1997 let mut rendered = Vec::new();
1998 for line_index in start_index..=end_index {
1999 if let Some((cell_index, _)) = line_meta.get(line_index).and_then(|meta| meta.cell_line())
2000 && !rendered.contains(&cell_index)
2001 {
2002 rendered.push(cell_index);
2003 }
2004 }
2005 let mut seen_original = Vec::new();
2006 let mut parts = Vec::new();
2007 for rendered_index in rendered {
2008 let original = app.original_cell_index_for_rendered(rendered_index);
2009 if seen_original.contains(&original) {
2010 continue;
2011 }
2012 seen_original.push(original);
2013 let Some(cell) = app.cell_at_virtual_index(original) else {
2014 continue;
2015 };
2016 let text = history_cell_to_clipboard_text(cell, width);
2017 if !text.trim().is_empty() {
2018 parts.push(text);
2019 }
2020 }
2021 if parts.is_empty() {
2022 return None;
2023 }
2024 let count = parts.len();
2025 Some((parts.join("\n\n"), count))
2026 }
2027 pub(crate) fn clear_transcript_selection(app: &mut App) {
2028 app.needs_redraw |= app.viewport.transcript_selection.is_active();
2029 app.viewport.transcript_selection.clear();
2030 }
2031 pub(crate) fn selection_to_text(app: &App) -> Option<String> {
2032 let (start, end) = app.viewport.transcript_selection.ordered_endpoints()?;
2033 let lines = app.viewport.transcript_cache.lines();
2034 if lines.is_empty() {
2035 return None;
2036 }
2037 let end_index = end.line_index.min(lines.len().saturating_sub(1));
2038 let start_index = start.line_index.min(end_index);
2039
2040 let line_meta = app.viewport.transcript_cache.line_meta();
2041 let mut selected = String::new();
2042 let mut separator_before = None;
2043 #[allow(clippy::needless_range_loop)]
2044 for line_index in start_index..=end_index {
2045 if let Some(separator) = separator_before {
2046 selected.push_str(separator);
2047 }
2048 // Rail-prefix decorations are stored as cache metadata rather than
2049 // detected from glyphs, so new decoration types are covered without
2050 // changes to the copy path (#1163).
2051 let rail_width = app.viewport.transcript_cache.rail_prefix_width(line_index);
2052 // Convert the rendered line to plain text (strips OSC-8), then
2053 // slice off the rail prefix so subsequent column offsets operate
2054 // on content-only text.
2055 let full_text = line_to_plain(&lines[line_index]);
2056 // Selection columns are painted terminal cells, where control
2057 // characters are invisible (ratatui strips them). Measure and slice
2058 // in that space so columns after a tab stay aligned with what the
2059 // user dragged over; the fixed-width fallback would shift every
2060 // downstream column.
2061 let line_after_rail = if rail_width > 0 {
2062 slice_visible_columns(&full_text, rail_width, text_visible_width(&full_text))
2063 } else {
2064 full_text
2065 };
2066 let line_after_rail_width = text_visible_width(&line_after_rail);
2067 let copy_prefix_width = line_meta
2068 .get(line_index)
2069 .map(|meta| meta.copy_prefix_width())
2070 .unwrap_or(0)
2071 .min(line_after_rail_width);
2072 let line_text = if copy_prefix_width > 0 {
2073 slice_visible_columns(&line_after_rail, copy_prefix_width, line_after_rail_width)
2074 } else {
2075 line_after_rail
2076 };
2077 let visual_prefix_width = rail_width.saturating_add(copy_prefix_width);
2078 let line_width = text_visible_width(&line_text);
2079 // Selection coordinates are recorded in rendered-column space, which
2080 // includes visual prefixes. Add them back so the column window maps
2081 // correctly into copy-only text.
2082 let (raw_col_start, raw_col_end) = if start_index == end_index {
2083 (start.column, end.column)
2084 } else if line_index == start_index {
2085 (start.column, line_width.saturating_add(visual_prefix_width))
2086 } else if line_index == end_index {
2087 (0, end.column)
2088 } else {
2089 (0, line_width.saturating_add(visual_prefix_width))
2090 };
2091
2092 let col_start = raw_col_start
2093 .saturating_sub(visual_prefix_width)
2094 .min(line_width);
2095 let col_end = raw_col_end
2096 .saturating_sub(visual_prefix_width)
2097 .min(line_width);
2098
2099 let slice = slice_visible_columns(&line_text, col_start, col_end);
2100 selected.push_str(&slice);
2101 separator_before = line_meta
2102 .get(line_index)
2103 .map(|meta| meta.copy_separator_after().as_str())
2104 .or(Some("\n"));
2105 }
2106 Some(selected)
2107 }
2108
2109 #[cfg(test)]
2110 mod tests {
2111 use super::{
2112 agent_transcript_text, build_context_menu_entries, handle_composer_mouse,
2113 handle_mouse_event, sidebar_click_action,
2114 };
2115 use crate::config::Config;
2116 use crate::tui::app::{
2117 App, SidebarHoverRow, SidebarHoverSection, SidebarRowAction, TuiOptions,
2118 };
2119 use crate::tui::tideline::{
2120 ContextBudgetSnapshot, InspectDetail, InteractionAction, InteractionFocus,
2121 InteractionTarget, InteractionTargetId,
2122 };
2123 use crate::tui::views::{ContextMenuAction, ModalKind, ViewEvent};
2124 use codewhale_models::Role;
2125 use codewhale_models::{ContentBlock, Message};
2126 use crossterm::event::{
2127 KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
2128 };
2129 use ratatui::layout::Rect;
2130 use serde_json::json;
2131 use std::path::PathBuf;
2132 use tempfile::tempdir;
2133
2134 pub(super) fn create_test_app() -> App {
2135 let options = TuiOptions {
2136 ..crate::test_support::test_tui_options(PathBuf::from("."))
2137 };
2138 let mut app = App::new(options, &Config::default());
2139 // Legacy strip geometry (see ui.rs); Bottom default has its own tests.
2140 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Top;
2141 app
2142 }
2143
2144 #[test]
2145 fn composer_click_maps_tabs_as_painted() {
2146 // A tab paints no cells, so clicking the visible char after one
2147 // must resolve past it instead of stopping on the tab itself.
2148 let mut app = create_test_app();
2149 let area = Rect::new(0, 0, 80, 10);
2150 app.input = "a\tb".to_string();
2151 assert_eq!(super::mouse_pos_to_char_index(&app, 1, 0, area), Some(2));
2152 app.input = "\ta".to_string();
2153 assert_eq!(super::mouse_pos_to_char_index(&app, 0, 0, area), Some(1));
2154 }
2155
2156 fn hover_row(row_y: u16, action: Option<&str>) -> SidebarHoverRow {
2157 SidebarHoverRow {
2158 row_y,
2159 display_text: "row".to_string(),
2160 full_text: "row".to_string(),
2161 detail: None,
2162 is_truncated: false,
2163 click_action: action.map(|action| SidebarRowAction::Command(action.to_string())),
2164 stop_action: None,
2165 stop_zone_start_col: None,
2166 stop_zone_end_col: None,
2167 }
2168 }
2169
2170 fn hover_row_with_stop(row_y: u16, action: &str, stop_action: &str) -> SidebarHoverRow {
2171 SidebarHoverRow {
2172 row_y,
2173 display_text: "job row [x]".to_string(),
2174 full_text: "job row [x]".to_string(),
2175 detail: None,
2176 is_truncated: false,
2177 click_action: Some(SidebarRowAction::Command(action.to_string())),
2178 stop_action: Some(SidebarRowAction::Command(stop_action.to_string())),
2179 stop_zone_start_col: Some(68),
2180 stop_zone_end_col: Some(71),
2181 }
2182 }
2183
2184 fn action_command(action: Option<SidebarRowAction>) -> Option<String> {
2185 action
2186 .as_ref()
2187 .and_then(SidebarRowAction::as_command)
2188 .map(str::to_string)
2189 }
2190
2191 fn left_click(column: u16, row: u16) -> MouseEvent {
2192 MouseEvent {
2193 kind: MouseEventKind::Down(MouseButton::Left),
2194 column,
2195 row,
2196 modifiers: KeyModifiers::NONE,
2197 }
2198 }
2199
2200 fn right_click(column: u16, row: u16) -> MouseEvent {
2201 MouseEvent {
2202 kind: MouseEventKind::Down(MouseButton::Right),
2203 column,
2204 row,
2205 modifiers: KeyModifiers::NONE,
2206 }
2207 }
2208
2209 fn mouse_move(column: u16, row: u16) -> MouseEvent {
2210 MouseEvent {
2211 kind: MouseEventKind::Moved,
2212 column,
2213 row,
2214 modifiers: KeyModifiers::NONE,
2215 }
2216 }
2217
2218 #[test]
2219 fn the_launch_card_does_not_swallow_the_rest_of_the_screen() {
2220 // Founder live-test: "the clickability and the mouse pointing thing
2221 // isn't working". While the opening screen was a separate surface it
2222 // was right for it to consume every mouse event and return; the
2223 // moment it became content on the ordinary screen that gate made
2224 // scrolling, the composer and the work surface unreachable. A click
2225 // that misses the card's rows must fall through.
2226 let mut app = create_test_app();
2227 app.launch.visible = true;
2228 app.launch.row_hitboxes = vec![(
2229 crate::tui::app::LaunchRowId::NewSession,
2230 Rect::new(2, 5, 30, 1),
2231 )];
2232 app.viewport.last_transcript_area = Some(Rect::new(0, 0, 80, 20));
2233 app.viewport.pending_scroll_delta = 0;
2234
2235 // A wheel tick on the launch screen still scrolls.
2236 handle_mouse_event(
2237 &mut app,
2238 MouseEvent {
2239 kind: MouseEventKind::ScrollDown,
2240 column: 10,
2241 row: 10,
2242 modifiers: KeyModifiers::NONE,
2243 },
2244 );
2245 assert_ne!(
2246 app.viewport.pending_scroll_delta, 0,
2247 "the wheel must reach the transcript on the opening screen"
2248 );
2249
2250 // A click away from the card's rows starts no launch action.
2251 app.pending_launch_action = None;
2252 handle_mouse_event(&mut app, left_click(60, 15));
2253 assert_eq!(
2254 app.pending_launch_action, None,
2255 "a click off the card must not be read as a card action"
2256 );
2257
2258 // A click on a row still runs it.
2259 handle_mouse_event(&mut app, left_click(4, 5));
2260 assert_eq!(
2261 app.pending_launch_action,
2262 Some(crate::tui::underwater::LaunchAction::NewSession),
2263 "the card's own rows still work"
2264 );
2265 }
2266
2267 #[test]
2268 fn clicking_a_recent_row_opens_the_resume_confirmation_popup() {
2269 // Founder live-test: "you just click it and boom you're there ... you
2270 // don't realize it's happening", then, on the first fix: "the
2271 // resuming confirmation needs to be a popup not something in the
2272 // composer that's even more confusing". Resuming replaces the whole
2273 // session context, so the click opens a popup that names the session
2274 // and asks; nothing resumes until that is confirmed.
2275 let mut app = create_test_app();
2276 app.launch.visible = true;
2277 app.launch.recent = vec![crate::tui::app::LaunchRecentSession {
2278 id: "sess-1".to_string(),
2279 title: "refactor the parser".to_string(),
2280 updated_at: chrono::Utc::now(),
2281 message_count: 12,
2282 }];
2283 app.launch.row_hitboxes = vec![(
2284 crate::tui::app::LaunchRowId::Recent("sess-1".to_string()),
2285 Rect::new(2, 5, 30, 1),
2286 )];
2287 app.pending_launch_action = None;
2288
2289 handle_mouse_event(&mut app, left_click(4, 5));
2290 assert_eq!(
2291 app.pending_launch_action, None,
2292 "the click must not resume anything on its own"
2293 );
2294 assert_eq!(
2295 app.view_stack.top_kind(),
2296 Some(crate::tui::views::ModalKind::LaunchResumeConfirm),
2297 "it opens the confirmation popup instead"
2298 );
2299 assert!(
2300 app.launch.status.is_none(),
2301 "and nothing is written over the composer dock"
2302 );
2303 }
2304
2305 #[test]
2306 fn a_new_session_row_still_takes_one_click() {
2307 // Only resuming discards context, so New session keeps its single
2308 // click; adding a confirm step there would be friction for nothing.
2309 let mut app = create_test_app();
2310 app.launch.visible = true;
2311 app.launch.row_hitboxes = vec![(
2312 crate::tui::app::LaunchRowId::NewSession,
2313 Rect::new(2, 5, 30, 1),
2314 )];
2315 app.pending_launch_action = None;
2316
2317 handle_mouse_event(&mut app, left_click(4, 5));
2318 assert_eq!(
2319 app.pending_launch_action,
2320 Some(crate::tui::underwater::LaunchAction::NewSession),
2321 "New session runs on the first click"
2322 );
2323 }
2324
2325 #[test]
2326 fn idle_pointer_enter_and_leave_request_hover_redraws() {
2327 let _guard = crate::tui::hover_layer::HOVER_TEST_LOCK.lock().unwrap();
2328 crate::tui::hover_layer::clear_pointer();
2329 crate::tui::hover_layer::begin_frame();
2330 crate::tui::hover_layer::register_rect(
2331 crate::tui::hover_hit::HoverTargetKind::TruncatedText,
2332 Rect::new(10, 5, 20, 1),
2333 "full clipped row",
2334 false,
2335 );
2336
2337 let mut app = create_test_app();
2338 app.launch.visible = false;
2339 app.needs_redraw = false;
2340 handle_mouse_event(&mut app, mouse_move(12, 5));
2341 assert!(
2342 app.needs_redraw,
2343 "entering a target must repaint while idle"
2344 );
2345 assert_eq!(
2346 crate::tui::hover_layer::current_hover().map(|hit| hit.kind),
2347 Some(crate::tui::hover_hit::HoverTargetKind::TruncatedText)
2348 );
2349
2350 app.needs_redraw = false;
2351 handle_mouse_event(&mut app, mouse_move(40, 5));
2352 assert!(app.needs_redraw, "leaving a target must clear its popover");
2353 assert!(crate::tui::hover_layer::current_hover().is_none());
2354 crate::tui::hover_layer::clear_pointer();
2355 }
2356
2357 #[test]
2358 fn slash_autocomplete_click_selects_and_second_click_applies() {
2359 let mut app = create_test_app();
2360 app.launch.visible = false;
2361 app.work_surface.last_area = None;
2362 app.input = "/he".to_string();
2363 app.cursor_position = app.input.chars().count();
2364 app.slash_menu_hidden = false;
2365 app.slash_menu_selected = 0;
2366 // Simulate two painted rows from ComposerWidget.
2367 app.viewport.last_composer_area = Some(Rect::new(0, 18, 80, 6));
2368 *app.viewport.last_slash_menu_hitboxes.borrow_mut() =
2369 vec![(0, Rect::new(1, 20, 78, 1)), (1, Rect::new(1, 21, 78, 1))];
2370
2371 assert!(
2372 handle_composer_mouse(&mut app, left_click(5, 21)),
2373 "slash row click must be consumed by the composer"
2374 );
2375 assert_eq!(
2376 app.slash_menu_selected, 1,
2377 "click on another row highlights it"
2378 );
2379 let before = app.input.clone();
2380 assert_eq!(
2381 before, "/he",
2382 "select-only click must not rewrite the composer"
2383 );
2384
2385 assert!(handle_composer_mouse(&mut app, left_click(5, 21)));
2386 assert_ne!(app.input, before, "click on the highlighted row applies it");
2387 assert!(
2388 app.input.starts_with('/'),
2389 "applied slash entry must replace the composer: {:?}",
2390 app.input
2391 );
2392 }
2393
2394 #[test]
2395 fn slash_autocomplete_wheel_moves_selection() {
2396 let mut app = create_test_app();
2397 app.launch.visible = false;
2398 app.work_surface.last_area = None;
2399 app.input = "/he".to_string();
2400 app.cursor_position = app.input.chars().count();
2401 app.slash_menu_hidden = false;
2402 app.slash_menu_selected = 0;
2403 app.viewport.last_composer_area = Some(Rect::new(0, 18, 80, 6));
2404 *app.viewport.last_slash_menu_hitboxes.borrow_mut() =
2405 vec![(0, Rect::new(1, 20, 78, 1)), (1, Rect::new(1, 21, 78, 1))];
2406 let entries = crate::tui::slash_menu::visible_slash_menu_entries(&app, 128);
2407 assert!(entries.len() >= 2, "prefix must offer multiple entries");
2408
2409 assert!(handle_composer_mouse(
2410 &mut app,
2411 MouseEvent {
2412 kind: MouseEventKind::ScrollDown,
2413 column: 5,
2414 row: 20,
2415 modifiers: KeyModifiers::NONE,
2416 },
2417 ));
2418 assert_eq!(app.slash_menu_selected, 1);
2419
2420 assert!(handle_composer_mouse(
2421 &mut app,
2422 MouseEvent {
2423 kind: MouseEventKind::ScrollUp,
2424 column: 5,
2425 row: 20,
2426 modifiers: KeyModifiers::NONE,
2427 },
2428 ));
2429 assert_eq!(app.slash_menu_selected, 0);
2430 }
2431
2432 #[test]
2433 fn active_composer_send_click_queues_the_keyboard_submit_chord() {
2434 let mut app = create_test_app();
2435 app.launch.visible = false;
2436 app.composer_border = true;
2437 app.input = "ship it".to_string();
2438 app.cursor_position = app.input.chars().count();
2439 let area = Rect::new(0, 20, 80, 4);
2440 app.viewport.last_composer_area = Some(area);
2441 // Match the frame's submit-aware input plane: x=74 stays blank,
2442 // then the shared `[↵]` target begins at x=75.
2443 app.viewport.last_composer_content = Some(Rect::new(1, 21, 73, 2));
2444 let submit = crate::tui::widgets::active_composer_submit_rect(&app, area)
2445 .expect("enclosed composer submit");
2446
2447 handle_mouse_event(&mut app, left_click(submit.x, submit.y));
2448 assert_eq!(
2449 app.pending_composer_submit,
2450 Some(crate::tui::app::ComposerSubmitChord::Enter)
2451 );
2452 assert_eq!(app.input, "ship it");
2453 assert_eq!(app.cursor_position, app.input.chars().count());
2454
2455 app.pending_composer_submit = None;
2456 handle_mouse_event(&mut app, left_click(area.x + 4, area.y + 1));
2457 assert_eq!(app.pending_composer_submit, None);
2458
2459 app.input.clear();
2460 app.cursor_position = 0;
2461 handle_mouse_event(&mut app, left_click(submit.x, submit.y));
2462 assert_eq!(app.pending_composer_submit, None);
2463 assert!(app.input.is_empty());
2464 }
2465
2466 #[test]
2467 fn context_meter_click_uses_the_same_inspector_as_the_keyboard_shortcut() {
2468 let mut app = create_test_app();
2469 app.launch.visible = false;
2470 app.viewport
2471 .interaction_targets
2472 .register(InteractionTarget {
2473 id: InteractionTargetId::HEADER_CONTEXT,
2474 area: Rect::new(52, 0, 20, 1),
2475 focus: InteractionFocus::Direct,
2476 keyboard_action: Some(InteractionAction::InspectContext),
2477 mouse_action: Some(InteractionAction::InspectContext),
2478 inspect_detail: InspectDetail::ContextBudget(ContextBudgetSnapshot {
2479 used_tokens: 3_000,
2480 max_tokens: 10_000,
2481 percent_basis_points: 3_000,
2482 }),
2483 });
2484
2485 handle_mouse_event(&mut app, left_click(60, 0));
2486
2487 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextInspector));
2488 assert!(
2489 crate::tui::shell_key_routing::is_context_inspector_shortcut(&KeyEvent::new(
2490 KeyCode::Char('c'),
2491 KeyModifiers::ALT
2492 ))
2493 );
2494 }
2495
2496 #[test]
2497 fn topbar_route_click_emits_provider_picker_request() {
2498 let mut app = create_test_app();
2499 // The launch screen shares the same header, so this specifically
2500 // protects against its old catch-all mouse route swallowing the
2501 // topbar affordance before it reached the event handler.
2502 app.launch.visible = true;
2503 app.viewport
2504 .interaction_targets
2505 .register(InteractionTarget {
2506 id: InteractionTargetId::HEADER_ROUTE,
2507 area: Rect::new(20, 0, 24, 1),
2508 focus: InteractionFocus::Direct,
2509 keyboard_action: Some(InteractionAction::OpenProviderPicker),
2510 mouse_action: Some(InteractionAction::OpenProviderPicker),
2511 inspect_detail: InspectDetail::Route,
2512 });
2513
2514 let events = handle_mouse_event(&mut app, left_click(24, 0));
2515
2516 assert!(matches!(
2517 events.as_slice(),
2518 [ViewEvent::TopbarRoutePickerRequested]
2519 ));
2520 assert!(app.view_stack.is_empty());
2521 }
2522
2523 #[test]
2524 fn context_menu_keeps_paste_first_outside_sidebar() {
2525 let mut app = create_test_app();
2526 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
2527
2528 let entries = build_context_menu_entries(&app, right_click(10, 4));
2529
2530 assert!(matches!(
2531 entries.first().map(|entry| &entry.action),
2532 Some(ContextMenuAction::Paste)
2533 ));
2534 }
2535
2536 #[test]
2537 fn sidebar_context_menu_omits_paste_without_row_action() {
2538 let mut app = create_test_app();
2539 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
2540 app.sidebar_hover.sections.push(SidebarHoverSection {
2541 content_area: Rect::new(60, 4, 20, 6),
2542 lines: vec!["header".to_string()],
2543 rows: vec![hover_row(4, None)],
2544 });
2545
2546 let entries = build_context_menu_entries(&app, right_click(65, 4));
2547
2548 assert!(
2549 !entries
2550 .iter()
2551 .any(|entry| matches!(entry.action, ContextMenuAction::Paste)),
2552 "sidebar menu should not offer paste: {entries:?}"
2553 );
2554 }
2555
2556 #[test]
2557 fn sidebar_context_menu_runs_clickable_row_action() {
2558 let mut app = create_test_app();
2559 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
2560 app.sidebar_hover.sections.push(SidebarHoverSection {
2561 content_area: Rect::new(60, 4, 20, 6),
2562 lines: vec!["job row".to_string()],
2563 rows: vec![hover_row(4, Some("/jobs show shell_x"))],
2564 });
2565
2566 let entries = build_context_menu_entries(&app, right_click(65, 4));
2567
2568 let first = entries.first().expect("sidebar row should have menu");
2569 assert_eq!(first.label, "Run");
2570 assert_eq!(first.description, "/jobs show shell_x");
2571 assert!(matches!(
2572 &first.action,
2573 ContextMenuAction::ExecuteCommand { command } if command == "/jobs show shell_x"
2574 ));
2575 assert!(
2576 !entries
2577 .iter()
2578 .any(|entry| matches!(entry.action, ContextMenuAction::Paste)),
2579 "clickable sidebar menu should not offer paste: {entries:?}"
2580 );
2581 }
2582
2583 #[test]
2584 fn sidebar_click_resolves_row_actions_inside_section() {
2585 let mut app = create_test_app();
2586 app.sidebar_hover.sections.push(SidebarHoverSection {
2587 content_area: Rect::new(60, 4, 20, 6),
2588 lines: vec![
2589 "header".to_string(),
2590 "job row".to_string(),
2591 "job detail".to_string(),
2592 "agent row".to_string(),
2593 ],
2594 rows: vec![
2595 hover_row(4, None),
2596 hover_row(5, Some("/jobs show shell_x")),
2597 hover_row(6, Some("/jobs cancel shell_x")),
2598 SidebarHoverRow {
2599 row_y: 7,
2600 display_text: "agent row".to_string(),
2601 full_text: "agent row".to_string(),
2602 detail: None,
2603 is_truncated: false,
2604 click_action: Some(SidebarRowAction::OpenAgentDetail {
2605 agent_id: "agent_123".to_string(),
2606 }),
2607 stop_action: None,
2608 stop_zone_start_col: None,
2609 stop_zone_end_col: None,
2610 },
2611 ],
2612 });
2613
2614 assert_eq!(
2615 action_command(sidebar_click_action(&app, left_click(65, 5))).as_deref(),
2616 Some("/jobs show shell_x"),
2617 "job label row resolves to its show action"
2618 );
2619 assert_eq!(
2620 action_command(sidebar_click_action(&app, left_click(79, 6))).as_deref(),
2621 Some("/jobs cancel shell_x"),
2622 "job detail row resolves to its cancel action"
2623 );
2624 assert!(matches!(
2625 sidebar_click_action(&app, left_click(60, 7)),
2626 Some(SidebarRowAction::OpenAgentDetail { agent_id })
2627 if agent_id == "agent_123"
2628 ));
2629 assert_eq!(
2630 sidebar_click_action(&app, left_click(65, 4)),
2631 None,
2632 "header row has no action"
2633 );
2634 }
2635
2636 #[test]
2637 fn sidebar_click_routes_inline_stop_zone_before_row_action() {
2638 let mut app = create_test_app();
2639 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 4));
2640 app.sidebar_hover.sections.push(SidebarHoverSection {
2641 content_area: Rect::new(60, 4, 20, 4),
2642 lines: vec!["job row [x]".to_string()],
2643 rows: vec![hover_row_with_stop(
2644 4,
2645 "/jobs show shell_x",
2646 "/jobs cancel shell_x",
2647 )],
2648 });
2649
2650 assert_eq!(
2651 action_command(sidebar_click_action(&app, left_click(62, 4))).as_deref(),
2652 Some("/jobs show shell_x"),
2653 "clicking the label opens the job"
2654 );
2655 assert_eq!(
2656 action_command(sidebar_click_action(&app, left_click(69, 4))).as_deref(),
2657 Some("/jobs cancel shell_x"),
2658 "clicking [x] cancels the job"
2659 );
2660 }
2661
2662 #[test]
2663 fn sidebar_click_routes_agent_inline_stop_zone_before_peek_action() {
2664 let mut app = create_test_app();
2665 app.work_surface.last_area = Some(Rect::new(60, 4, 24, 4));
2666 app.sidebar_hover.sections.push(SidebarHoverSection {
2667 content_area: Rect::new(60, 4, 24, 4),
2668 lines: vec!["[~] worker Agent 1 [x]".to_string()],
2669 rows: vec![SidebarHoverRow {
2670 row_y: 4,
2671 display_text: "[~] Agent 1 is working [x]".to_string(),
2672 full_text: "[~] Agent 1 is working [x]".to_string(),
2673 detail: None,
2674 is_truncated: false,
2675 click_action: Some(SidebarRowAction::OpenAgentDetail {
2676 agent_id: "agent_123".to_string(),
2677 }),
2678 stop_action: Some(SidebarRowAction::CancelAgent {
2679 agent_id: "agent_123".to_string(),
2680 }),
2681 stop_zone_start_col: Some(68),
2682 stop_zone_end_col: Some(71),
2683 }],
2684 });
2685
2686 assert!(matches!(
2687 sidebar_click_action(&app, left_click(62, 4)),
2688 Some(SidebarRowAction::OpenAgentDetail { agent_id })
2689 if agent_id == "agent_123"
2690 ));
2691 assert!(matches!(
2692 sidebar_click_action(&app, left_click(69, 4)),
2693 Some(SidebarRowAction::CancelAgent { agent_id }) if agent_id == "agent_123"
2694 ));
2695 }
2696
2697 #[test]
2698 fn sidebar_context_menu_offers_copy_of_hovered_row() {
2699 let mut app = create_test_app();
2700 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
2701 app.sidebar_hover.sections.push(SidebarHoverSection {
2702 content_area: Rect::new(60, 4, 20, 6),
2703 lines: vec!["agent row".to_string()],
2704 rows: vec![SidebarHoverRow {
2705 row_y: 4,
2706 display_text: "[~] worker doc-che…".to_string(),
2707 full_text: "[~] worker doc-checker".to_string(),
2708 detail: Some("id: agent_123 · 2 step(s)".to_string()),
2709 is_truncated: true,
2710 click_action: None,
2711 stop_action: None,
2712 stop_zone_start_col: None,
2713 stop_zone_end_col: None,
2714 }],
2715 });
2716
2717 let entries = build_context_menu_entries(&app, right_click(65, 4));
2718
2719 let copy = entries
2720 .iter()
2721 .find(|entry| matches!(entry.action, ContextMenuAction::CopyText { .. }))
2722 .expect("sidebar row should offer Copy");
2723 assert_eq!(copy.label, "Copy");
2724 assert!(matches!(
2725 &copy.action,
2726 ContextMenuAction::CopyText { text }
2727 if text == "[~] worker doc-checker\nid: agent_123 · 2 step(s)"
2728 ));
2729 }
2730
2731 #[test]
2732 fn sidebar_click_outside_section_resolves_to_none() {
2733 let mut app = create_test_app();
2734 app.sidebar_hover.sections.push(SidebarHoverSection {
2735 content_area: Rect::new(60, 4, 20, 6),
2736 lines: vec!["job row".to_string()],
2737 rows: vec![hover_row(4, Some("/jobs show shell_x"))],
2738 });
2739
2740 // Left of the sidebar (transcript area).
2741 assert_eq!(sidebar_click_action(&app, left_click(10, 4)), None);
2742 // Below the section's content area.
2743 assert_eq!(sidebar_click_action(&app, left_click(65, 30)), None);
2744 // Inside the section but on an empty row without metadata.
2745 assert_eq!(sidebar_click_action(&app, left_click(65, 8)), None);
2746 }
2747
2748 #[test]
2749 fn worker_transcript_formats_visible_activity_without_thinking() {
2750 let transcript = agent_transcript_text(&json!({
2751 "message_count": 2,
2752 "messages": [
2753 {"role": "user", "content": [{"type": "text", "text": "Survey Harnesses", "cache_control": null}]},
2754 {"role": "assistant", "content": [
2755 {"type": "thinking", "thinking": "private chain of thought", "signature": null},
2756 {"type": "tool_use", "id": "call_1", "name": "list_dir", "input": {"path": "/tmp"}, "caller": null},
2757 {"type": "text", "text": "I found the workspace.", "cache_control": null}
2758 ]}
2759 ]
2760 }));
2761
2762 assert!(transcript.contains("── user ──\nSurvey Harnesses"));
2763 assert!(transcript.contains("→ list_dir"));
2764 assert!(transcript.contains("I found the workspace."));
2765 assert!(!transcript.contains("private chain of thought"));
2766 }
2767
2768 #[test]
2769 fn worker_open_reads_first_and_last_turns_from_complete_artifact() {
2770 let tmp = tempdir().expect("tempdir");
2771 let agent_id = "agent_large_chat";
2772 let early = format!("EARLY-OPEN-MARKER\n{}", "a".repeat(1_100_000));
2773 let messages = vec![
2774 Message {
2775 role: Role::User,
2776 content: vec![ContentBlock::Text {
2777 text: early,
2778 cache_control: None,
2779 }],
2780 },
2781 Message {
2782 role: Role::Assistant,
2783 content: vec![ContentBlock::Text {
2784 text: "LAST-OPEN-MARKER".to_string(),
2785 cache_control: None,
2786 }],
2787 },
2788 ];
2789 let artifact = crate::tools::subagent::write_subagent_transcript_artifact_for_test(
2790 tmp.path(),
2791 agent_id,
2792 &messages,
2793 )
2794 .expect("write complete worker transcript");
2795 assert!(
2796 std::fs::metadata(artifact)
2797 .expect("artifact metadata")
2798 .len()
2799 > 1024 * 1024,
2800 "regression requires a transcript larger than the resident handle budget"
2801 );
2802
2803 let mut app = create_test_app();
2804 app.workspace = tmp.path().to_path_buf();
2805 {
2806 let mut store = app
2807 .runtime_services
2808 .handle_store
2809 .try_lock()
2810 .expect("handle store");
2811 let _ = store.insert_json(
2812 format!("agent:{agent_id}"),
2813 "full_transcript",
2814 json!({
2815 "kind": "subagent_full_transcript",
2816 "message_count": 2,
2817 "omitted_messages": 1,
2818 "messages_complete": false,
2819 "messages": [messages[1].clone()],
2820 }),
2821 );
2822 }
2823
2824 crate::tui::agent_focus::focus_agent(&mut app, agent_id);
2825 let focus = app.agent_focus.as_ref().expect("Open focuses the worker");
2826 let body = focus
2827 .cells
2828 .iter()
2829 .flat_map(|cell| cell.transcript_lines(120))
2830 .map(|line| line.to_string())
2831 .collect::<Vec<_>>()
2832 .join("\n");
2833 assert!(body.contains("EARLY-OPEN-MARKER"), "{body}");
2834 assert!(body.contains("LAST-OPEN-MARKER"), "{body}");
2835 assert_eq!(
2836 focus.omitted_messages, 0,
2837 "Open must use the complete artifact, not the compacted resident tail"
2838 );
2839 }
2840
2841 #[cfg(test)]
2842 mod composer_selection_tests {
2843 use super::super::*;
2844
2845 #[test]
2846 fn word_bounds_select_words_and_respect_cjk() {
2847 // Bytes: fix(0-2) sp(3) the(4-6) sp(7) 深=3B(8-10) 海=3B(11-13) sp(14) test.rs(15-21)
2848 let text = "fix the 深海 test.rs";
2849 assert_eq!(composer_word_bounds(text, 2), (0, 3)); // 'fix'
2850 assert_eq!(composer_word_bounds(text, 5), (4, 7)); // 'the'
2851 assert_eq!(composer_word_bounds(text, 10), (8, 14)); // '深海' (space at 14)
2852 assert_eq!(composer_word_bounds(text, 15), (15, 19)); // 'test' (stops at '.')
2853 assert_eq!(composer_word_bounds(text, 19), (19, 20)); // '.'
2854 assert_eq!(composer_word_bounds(text, 20), (20, 22)); // 'rs'
2855 }
2856
2857 #[test]
2858 fn word_bounds_at_punctuation_returns_the_single_char() {
2859 let text = "a, b";
2860 assert_eq!(composer_word_bounds(text, 1), (1, 2)); // ','
2861 }
2862
2863 #[test]
2864 fn line_bounds_exclude_the_newline() {
2865 let text = "first\nsecond third\nfourth";
2866 assert_eq!(composer_line_bounds(text, 2), (0, 5));
2867 assert_eq!(composer_line_bounds(text, 9), (6, 18));
2868 assert_eq!(composer_line_bounds(text, 20), (19, 25));
2869 assert_eq!(composer_line_bounds(text, 0), (0, 5));
2870 }
2871
2872 #[test]
2873 fn click_classification_resets_outside_the_window_or_slop() {
2874 let mut trace = None;
2875 assert_eq!(
2876 classify_composer_click(&mut trace, 10, 4),
2877 ComposerClickGesture::Caret
2878 );
2879 assert_eq!(
2880 classify_composer_click(&mut trace, 10, 4),
2881 ComposerClickGesture::Word
2882 );
2883 assert_eq!(
2884 classify_composer_click(&mut trace, 10, 4),
2885 ComposerClickGesture::Line
2886 );
2887 // A click far away resets the chain back to a caret.
2888 assert_eq!(
2889 classify_composer_click(&mut trace, 10, 40),
2890 ComposerClickGesture::Caret
2891 );
2892 assert_eq!(
2893 classify_composer_click(&mut trace, 10, 40),
2894 ComposerClickGesture::Word
2895 );
2896 }
2897 }
2898 }
2899
2900 #[cfg(test)]
2901 mod primary_tests;
2902
2902 lines RUST