返回 CodeWhale
mouse_ui.rs
根目录 / crates / tui / src / tui / mouse_ui.rs
1 use std::time::{Duration, Instant};
2
3 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
4 use ratatui::layout::Rect;
5 use unicode_segmentation::UnicodeSegmentation;
6 use unicode_width::UnicodeWidthStr;
7
8 use crate::localization::MessageId;
9 use crate::models::{ContentBlock, Message};
10 use crate::tui::app::{App, SidebarRowAction};
11 use crate::tui::command_palette::{
12 CommandPaletteView, build_entries as build_command_palette_entries,
13 };
14 use crate::tui::context_menu::{ContextMenuEntry, ContextMenuView};
15 use crate::tui::history::HistoryCell;
16 use crate::tui::pager::PagerView;
17 use crate::tui::scrolling::{ScrollDirection, TranscriptScroll};
18 use crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};
19 use crate::tui::ui_text::{
20 history_cell_to_text, line_to_plain, slice_text, text_display_width, truncate_line_to_width,
21 };
22 use crate::tui::views::{ContextMenuAction, HelpView, ModalKind, ViewEvent};
23
24 // These functions will need to be imported from ui.rs or we can just import crate::tui::ui::*.
25 use crate::tui::ui::{
26 copy_cell_to_clipboard, detail_target_label, open_context_inspector,
27 open_details_pager_for_cell, open_pager_for_selection,
28 };
29
30 const COMPOSER_MOUSE_SCROLL_LINES: usize = 3;
31
32 pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) -> bool {
33 if !app.is_loading {
34 return false;
35 }
36
37 match mouse.kind {
38 // v0.9.1: keep a cheap hover hit-test alive while streaming. Motion
39 // events are no longer dropped wholesale — the frame limiter bounds
40 // redraw cost. Only expensive transcript reflow stays deferred.
41 MouseEventKind::Moved => false,
42 MouseEventKind::Drag(_) => {
43 // Divider drags must stay live during active turns — dropping
44 // these events wedges the resize state mid-drag (#3063).
45 !app.viewport.transcript_selection.dragging
46 && !app.viewport.transcript_scrollbar_dragging
47 && !app.work_surface.is_resizing()
48 }
49 _ => false,
50 }
51 }
52
53 fn toggle_tool_run_expand(app: &mut App, mouse: MouseEvent) -> bool {
54 if !app.tool_collapse_active() {
55 return false;
56 }
57 let Some(rendered_idx) = transcript_cell_index_from_mouse(app, mouse) else {
58 return false;
59 };
60 let original_idx = app.original_cell_index_for_rendered(rendered_idx);
61 if app.tool_run_start_for_history_index(original_idx) != Some(original_idx) {
62 return false;
63 }
64 app.toggle_tool_run_expansion_at(original_idx)
65 }
66
67 /// Map a mouse (column, row) within the composer area to a char index
68 /// in the composer input string. Uses the canonical prompt-adjusted text rect
69 /// for coordinate mapping, and accounts for vertical padding and scroll offset.
70 fn mouse_pos_to_char_index(app: &App, col: u16, row: u16, text_area: Rect) -> Option<usize> {
71 let rel_col = col.saturating_sub(text_area.x) as usize;
72 let rel_row = row.saturating_sub(text_area.y) as usize;
73
74 if app.input.is_empty() {
75 return Some(0);
76 }
77
78 let width = text_area.width.max(1) as usize;
79 let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(&app.input, width);
80
81 // Subtract the vertical top-padding (centering of short inputs).
82 let text_row = rel_row.saturating_sub(app.viewport.last_composer_top_padding);
83
84 // Add the scroll offset (lines scrolled out of view).
85 let absolute_row = text_row + app.viewport.last_composer_scroll_offset;
86
87 if absolute_row >= wrapped.len() {
88 return Some(app.input.chars().count());
89 }
90
91 let (line_start, line_text) = &wrapped[absolute_row];
92
93 let mut char_offset = 0usize;
94 let mut col_used = 0usize;
95 for g in line_text.graphemes(true) {
96 let gw = g.width();
97 if col_used + gw > rel_col {
98 break;
99 }
100 col_used += gw;
101 char_offset += g.chars().count();
102 }
103 Some(line_start + char_offset)
104 }
105
106 fn composer_wrapped_cursor_row_col(
107 input: &str,
108 cursor: usize,
109 wrapped: &[(usize, String)],
110 ) -> (usize, usize) {
111 let total = input.chars().count();
112 let cursor = cursor.min(total);
113
114 for (idx, (line_start, line_text)) in wrapped.iter().enumerate() {
115 let next_start = wrapped
116 .get(idx + 1)
117 .map(|(start, _)| *start)
118 .unwrap_or_else(|| total.saturating_add(1));
119
120 if cursor >= *line_start && cursor < next_start {
121 let line_len = line_text.chars().count();
122 return (idx, cursor.saturating_sub(*line_start).min(line_len));
123 }
124 }
125
126 let row = wrapped.len().saturating_sub(1);
127 let col = wrapped
128 .get(row)
129 .map(|(_, line_text)| line_text.chars().count())
130 .unwrap_or(0);
131 (row, col)
132 }
133
134 /// Move the composer caret by wrapped rows. Returns whether the caret actually
135 /// moved: a draft that is empty, unwrapped, or already at the boundary in this
136 /// direction reports `false` so the wheel can reach the transcript instead of
137 /// dying in the composer (#5223).
138 fn move_composer_cursor_by_wrapped_rows(app: &mut App, text_area: Rect, rows: isize) -> bool {
139 if app.input.is_empty() || rows == 0 {
140 return false;
141 }
142
143 let width = text_area.width.max(1) as usize;
144 let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(&app.input, width);
145 if wrapped.len() <= 1 {
146 return false;
147 }
148
149 let (current_row, current_col) =
150 composer_wrapped_cursor_row_col(&app.input, app.cursor_position, &wrapped);
151 let max_row = wrapped.len().saturating_sub(1);
152 let target_row = if rows.is_negative() {
153 current_row.saturating_sub(rows.unsigned_abs())
154 } else {
155 current_row.saturating_add(rows as usize).min(max_row)
156 };
157
158 if target_row == current_row {
159 return false;
160 }
161
162 let (target_start, target_text) = &wrapped[target_row];
163 let target_len = target_text.chars().count();
164 let total = app.input.chars().count();
165 app.clear_selection();
166 app.cursor_position = target_start
167 .saturating_add(current_col.min(target_len))
168 .min(total);
169 app.needs_redraw = true;
170 true
171 }
172
173 /// Click the WorkflowPanel header to toggle expand/collapse, or the trailing
174 /// cancel affordance while a run is active (#4121).
175 fn handle_workflow_panel_mouse(app: &mut App, mouse: MouseEvent) -> bool {
176 if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
177 return false;
178 }
179 let Some(area) = app.viewport.last_workflow_panel_area else {
180 return false;
181 };
182 if !mouse_hits_rect(mouse, Some(area)) {
183 return false;
184 }
185 if app.workflow_panel.is_none() {
186 return false;
187 }
188
189 if let Some(panel) = app.workflow_panel.as_mut() {
190 panel.keyboard_focus = true;
191 }
192
193 let on_header_row = mouse.row == area.y;
194 let in_cancel_zone =
195 on_header_row && mouse_hits_rect(mouse, app.viewport.last_workflow_cancel_area);
196 let running = app
197 .workflow_panel
198 .as_ref()
199 .is_some_and(|panel| panel.lifecycle.is_running());
200
201 if in_cancel_zone && running {
202 let run_id = app
203 .workflow_panel
204 .as_ref()
205 .map(|panel| panel.run_id.clone())
206 .expect("running panel has an id");
207 app.input = format!("/workflow cancel {run_id}");
208 app.cursor_position = app.input.chars().count();
209 app.status_message = Some(app.tr(MessageId::SidebarDestructiveArmed).into_owned());
210 if let Some(panel) = app.workflow_panel.as_mut() {
211 panel.keyboard_focus = false;
212 }
213 app.needs_redraw = true;
214 return true;
215 }
216
217 // Any other click on the panel toggles expand/collapse.
218 app.toggle_workflow_panel();
219 true
220 }
221
222 /// Handle mouse events within the composer area.
223 /// Returns true if the event was consumed.
224 pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool {
225 // Use outer area for hit-testing (includes border).
226 let Some(area) = app.viewport.last_composer_area else {
227 return false;
228 };
229 if mouse.column < area.x
230 || mouse.column >= area.x + area.width
231 || mouse.row < area.y
232 || mouse.row >= area.y + area.height
233 {
234 return false;
235 }
236 // Resolve the border-aware inner rect through the same persistent prompt
237 // geometry used by rendering, cursor placement, and viewport bookkeeping.
238 let inner = app.viewport.last_composer_content.unwrap_or(area);
239 let text_area =
240 crate::tui::widgets::composer_content_geometry(inner, app.is_history_search_active())
241 .text_area;
242
243 match mouse.kind {
244 // Only claim the wheel while the caret still has somewhere to go. At
245 // the top or bottom of the draft — or with no wrapped draft at all —
246 // fall through so the transcript scrolls instead of the event being
247 // silently swallowed by the composer rect (#5223).
248 MouseEventKind::ScrollUp => move_composer_cursor_by_wrapped_rows(
249 app,
250 text_area,
251 -(COMPOSER_MOUSE_SCROLL_LINES as isize),
252 ),
253 MouseEventKind::ScrollDown => move_composer_cursor_by_wrapped_rows(
254 app,
255 text_area,
256 COMPOSER_MOUSE_SCROLL_LINES as isize,
257 ),
258 MouseEventKind::Down(MouseButton::Left) => {
259 if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, text_area) {
260 app.cursor_position = pos;
261 app.selection_anchor = None;
262 app.needs_redraw = true;
263 }
264 true
265 }
266 MouseEventKind::Drag(MouseButton::Left) => {
267 if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, text_area) {
268 if app.selection_anchor.is_none() {
269 app.selection_anchor = Some(app.cursor_position);
270 }
271 app.cursor_position = pos;
272 app.needs_redraw = true;
273 }
274 true
275 }
276 MouseEventKind::Up(MouseButton::Left) => {
277 if app.selection_anchor == Some(app.cursor_position) {
278 app.selection_anchor = None;
279 }
280 true
281 }
282 _ => false,
283 }
284 }
285
286 pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEvent> {
287 if app.view_stack.top_kind() == Some(ModalKind::ContextMenu) {
288 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
289 app.view_stack.pop();
290 open_context_menu(app, mouse);
291 return Vec::new();
292 }
293 return app.view_stack.handle_mouse(mouse);
294 }
295
296 // The approval prompt is intentionally inline: its card stays focused,
297 // but the wheel reviews the transcript that remains visible above it.
298 // Preserve ownership of visible side surfaces, though: wheeling over the
299 // sidebar or Ocean work surface must not move an unrelated transcript.
300 // Other modals still own their wheel input exclusively (#4371).
301 if app.view_stack.top_kind() == Some(ModalKind::Approval) {
302 let over_approval = mouse_hits_rect(mouse, app.viewport.last_approval_area);
303 let over_side_surface = mouse_hits_rect(mouse, app.work_surface.last_area);
304 match mouse.kind {
305 MouseEventKind::ScrollUp => {
306 if over_approval || !over_side_surface {
307 scroll_transcript_with_mouse(app, ScrollDirection::Up);
308 }
309 return Vec::new();
310 }
311 MouseEventKind::ScrollDown => {
312 if over_approval || !over_side_surface {
313 scroll_transcript_with_mouse(app, ScrollDirection::Down);
314 }
315 return Vec::new();
316 }
317 _ => {}
318 }
319 }
320
321 if !app.view_stack.is_empty() {
322 app.needs_redraw = true;
323 return app.view_stack.handle_mouse(mouse);
324 }
325
326 // The launch surface owns the whole frame until a session is chosen.
327 // Consume every mouse event here so wheel input cannot leak into the
328 // transcript or composer behind the splash.
329 if app.launch.visible {
330 match mouse.kind {
331 MouseEventKind::ScrollUp => {
332 crate::tui::underwater::handle_launch_key(
333 &mut app.launch,
334 KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
335 app.ui_locale,
336 );
337 }
338 MouseEventKind::ScrollDown => {
339 crate::tui::underwater::handle_launch_key(
340 &mut app.launch,
341 KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
342 app.ui_locale,
343 );
344 }
345 MouseEventKind::Down(MouseButton::Left) => {
346 if let Some((index, _)) = app
347 .launch
348 .row_areas
349 .iter()
350 .enumerate()
351 .find(|(_, area)| mouse_hits_rect(mouse, Some(**area)))
352 {
353 app.launch.selected = index;
354 app.pending_launch_action = Some(crate::tui::underwater::handle_launch_key(
355 &mut app.launch,
356 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
357 app.ui_locale,
358 ));
359 }
360 }
361 _ => {}
362 }
363 app.needs_redraw = true;
364 return Vec::new();
365 }
366
367 // Ocean work surface owns its rect, scrolling, focus, and row actions.
368 // Route it before workflow/composer/transcript so wheel events never leak
369 // into an unrelated viewport.
370 let work_surface = crate::tui::work_surface::handle_mouse(app, mouse);
371 if let Some(action) = work_surface.action {
372 return apply_sidebar_row_action(app, action);
373 }
374 if work_surface.consumed {
375 return Vec::new();
376 }
377
378 // WorkflowPanel toggle / cancel (#4121) before composer so the strip
379 // above the input remains clickable.
380 if handle_workflow_panel_mouse(app, mouse) {
381 return Vec::new();
382 }
383
384 // Composer mouse events take priority over transcript.
385 if handle_composer_mouse(app, mouse) {
386 return Vec::new();
387 }
388
389 match mouse.kind {
390 MouseEventKind::Moved => {
391 // Update last mouse position for tooltip rendering + hover layer.
392 app.last_mouse_pos = Some((mouse.column, mouse.row));
393 crate::tui::hover_layer::set_pointer(mouse.column, mouse.row);
394
395 // Check sidebar sections for hover popovers. Only surface a
396 // popover when the hovered row lost information in the compact
397 // sidebar view.
398 let mut found = false;
399 for section in &app.sidebar_hover.sections {
400 if mouse.column >= section.content_area.x
401 && mouse.column
402 < section
403 .content_area
404 .x
405 .saturating_add(section.content_area.width)
406 && mouse.row >= section.content_area.y
407 && mouse.row
408 < section
409 .content_area
410 .y
411 .saturating_add(section.content_area.height)
412 {
413 if let Some(row) = section.rows.iter().find(|row| row.row_y == mouse.row) {
414 let desired = row.is_truncated.then(|| {
415 if let Some(detail) = row.detail.as_deref()
416 && !detail.trim().is_empty()
417 {
418 format!("{}\n{detail}", row.full_text)
419 } else {
420 row.full_text.clone()
421 }
422 });
423 if app.sidebar_hover_tooltip != desired {
424 app.sidebar_hover_tooltip = desired;
425 app.needs_redraw = true;
426 }
427 found = true;
428 break;
429 } else if section.rows.is_empty() {
430 let line_idx = (mouse.row.saturating_sub(section.content_area.y)) as usize;
431 if let Some(full) = section.lines.get(line_idx) {
432 let truncated =
433 text_display_width(full) > section.content_area.width as usize;
434 let desired = truncated.then(|| full.clone());
435 if app.sidebar_hover_tooltip != desired {
436 app.sidebar_hover_tooltip = desired;
437 app.needs_redraw = true;
438 }
439 found = true;
440 break;
441 }
442 }
443 }
444 }
445 if !found && app.sidebar_hover_tooltip.is_some() {
446 app.sidebar_hover_tooltip = None;
447 app.needs_redraw = true;
448 }
449 }
450 MouseEventKind::ScrollUp => {
451 scroll_transcript_with_mouse(app, ScrollDirection::Up);
452 }
453 MouseEventKind::ScrollDown => {
454 scroll_transcript_with_mouse(app, ScrollDirection::Down);
455 }
456 MouseEventKind::Down(MouseButton::Left) => {
457 app.viewport.transcript_scrollbar_dragging = false;
458 app.viewport.selection_autoscroll = None;
459
460 // #3028/#4009: Check sidebar hover state for clickable rows before
461 // falling through to transcript selection. Command rows still use
462 // the command-palette pipeline; agent rows are direct UI actions.
463 if let Some(action) = sidebar_click_action(app, mouse) {
464 return apply_sidebar_row_action(app, action);
465 }
466
467 // Click on the transcript scrollbar gutter starts a scrollbar
468 // drag so the visible thumb remains interactive for users who
469 // prefer mouse-based navigation.
470 if mouse_hits_transcript_scrollbar(app, mouse) {
471 app.viewport.transcript_scrollbar_dragging = true;
472 return Vec::new();
473 }
474
475 if mouse_hits_rect(mouse, app.viewport.jump_to_latest_button_area) {
476 app.scroll_to_bottom();
477 return Vec::new();
478 }
479
480 if toggle_tool_run_expand(app, mouse) {
481 return Vec::new();
482 }
483
484 if let Some(point) = selection_point_from_mouse(app, mouse) {
485 app.viewport.transcript_selection.anchor = Some(point);
486 app.viewport.transcript_selection.head = Some(point);
487 app.viewport.transcript_selection.dragging = true;
488
489 if app.is_loading
490 && app.viewport.transcript_scroll.is_at_tail()
491 && let Some(anchor) = TranscriptScroll::anchor_for(
492 app.viewport.transcript_cache.line_meta(),
493 app.viewport.last_transcript_top,
494 )
495 {
496 app.viewport.transcript_scroll = anchor;
497 }
498 } else if app.viewport.transcript_selection.is_active() {
499 app.viewport.transcript_selection.clear();
500 }
501 }
502 MouseEventKind::Drag(MouseButton::Left) => {
503 if app.viewport.transcript_scrollbar_dragging {
504 scroll_transcript_to_mouse_row(app, mouse.row);
505 return Vec::new();
506 }
507
508 if app.viewport.transcript_selection.dragging {
509 update_selection_drag(app, mouse);
510 }
511 }
512 MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_scrollbar_dragging => {
513 app.viewport.transcript_scrollbar_dragging = false;
514 app.viewport.selection_autoscroll = None;
515 app.needs_redraw = true;
516 }
517 MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_selection.dragging => {
518 app.viewport.transcript_selection.dragging = false;
519 app.viewport.selection_autoscroll = None;
520 if selection_has_content(app) {
521 copy_active_selection(app);
522 }
523 }
524 MouseEventKind::Down(MouseButton::Right) => {
525 open_context_menu(app, mouse);
526 }
527 _ => {}
528 }
529
530 Vec::new()
531 }
532
533 fn scroll_transcript_with_mouse(app: &mut App, direction: ScrollDirection) {
534 let update = app.viewport.mouse_scroll.on_scroll(direction);
535 app.viewport.pending_scroll_delta = app
536 .viewport
537 .pending_scroll_delta
538 .saturating_add(update.delta_lines);
539 if update.delta_lines != 0 {
540 app.user_scrolled_during_stream = true;
541 app.needs_redraw = true;
542 }
543 }
544
545 /// Resolve a right-click in the sidebar to the hovered row's full copyable
546 /// text: the row's untruncated text plus its hover detail when present.
547 fn sidebar_row_copy_text(app: &App, mouse: MouseEvent) -> Option<String> {
548 for section in &app.sidebar_hover.sections {
549 if !mouse_hits_rect(mouse, Some(section.content_area)) {
550 continue;
551 }
552 if let Some(row) = section.rows.iter().find(|row| row.row_y == mouse.row) {
553 let mut text = row.full_text.clone();
554 if let Some(detail) = row.detail.as_deref()
555 && !detail.trim().is_empty()
556 {
557 text.push('\n');
558 text.push_str(detail);
559 }
560 return Some(text).filter(|text| !text.trim().is_empty());
561 }
562 let line_idx = (mouse.row.saturating_sub(section.content_area.y)) as usize;
563 if let Some(full) = section.lines.get(line_idx) {
564 return Some(full.clone()).filter(|text| !text.trim().is_empty());
565 }
566 }
567 None
568 }
569
570 fn first_line(text: &str) -> &str {
571 text.lines().next().unwrap_or(text)
572 }
573
574 /// Resolve a left-click in the sidebar to a typed row action, if the clicked
575 /// row has a click action assigned (#3028, #4009).
576 fn sidebar_click_action(app: &App, mouse: MouseEvent) -> Option<SidebarRowAction> {
577 for section in &app.sidebar_hover.sections {
578 if mouse.column >= section.content_area.x
579 && mouse.column
580 < section
581 .content_area
582 .x
583 .saturating_add(section.content_area.width)
584 && mouse.row >= section.content_area.y
585 && mouse.row
586 < section
587 .content_area
588 .y
589 .saturating_add(section.content_area.height)
590 && let Some(row) = section.rows.iter().find(|row| row.row_y == mouse.row)
591 {
592 if let (Some(action), Some(start), Some(end)) = (
593 row.stop_action.as_ref(),
594 row.stop_zone_start_col,
595 row.stop_zone_end_col,
596 ) && mouse.column >= start
597 && mouse.column < end
598 {
599 return Some(action.clone());
600 }
601 return row.click_action.clone();
602 }
603 }
604 None
605 }
606
607 pub(crate) fn apply_sidebar_row_action(app: &mut App, action: SidebarRowAction) -> Vec<ViewEvent> {
608 match action {
609 SidebarRowAction::Command(command) => {
610 use crate::tui::views::CommandPaletteAction;
611 vec![ViewEvent::CommandPaletteSelected {
612 action: CommandPaletteAction::ExecuteCommand { command },
613 }]
614 }
615 SidebarRowAction::PrefillCommand(command) => {
616 app.input = command;
617 app.cursor_position = app.input.len();
618 app.status_message = Some(app.tr(MessageId::SidebarDestructiveArmed).into_owned());
619 app.needs_redraw = true;
620 Vec::new()
621 }
622 SidebarRowAction::ToggleAgentDetails { agent_id } => {
623 if !app.expanded_sidebar_agents.insert(agent_id.clone()) {
624 app.expanded_sidebar_agents.remove(&agent_id);
625 app.status_message = Some("Agent details collapsed".to_string());
626 } else {
627 app.status_message = Some("Agent details expanded".to_string());
628 }
629 app.needs_redraw = true;
630 Vec::new()
631 }
632 SidebarRowAction::ShowSubagentsPanel => {
633 app.work_surface.panel = crate::tui::work_surface::RailPanel::Agents;
634 app.work_surface.focused = true;
635 app.status_message = Some("Showing subagents".to_string());
636 app.needs_redraw = true;
637 Vec::new()
638 }
639 SidebarRowAction::OpenAgentDetail { agent_id } => {
640 if !crate::tui::agent_details::open_agent_details(app, &agent_id) {
641 crate::tui::work_surface::agent_details_closed(app, &agent_id);
642 app.status_message = Some("Agent details are unavailable".to_string());
643 }
644 app.needs_redraw = true;
645 Vec::new()
646 }
647 SidebarRowAction::OpenAgentTranscript { agent_id } => {
648 if !open_agent_chat_pager(app, &agent_id) {
649 app.status_message = Some("Exact agent transcript is unavailable".to_string());
650 }
651 app.needs_redraw = true;
652 Vec::new()
653 }
654 SidebarRowAction::CancelAgent { agent_id } => {
655 vec![ViewEvent::SidebarAgentCancel { agent_id }]
656 }
657 SidebarRowAction::InspectWork {
658 title,
659 body,
660 stop_action,
661 } => {
662 let width = app
663 .viewport
664 .last_transcript_area
665 .map(|area| area.width)
666 .unwrap_or(80);
667 let mut pager =
668 crate::tui::pager::PagerView::from_text(title, &body, width.saturating_sub(2))
669 .with_copy_text(body);
670 let stop_event = stop_action.and_then(|action| match *action {
671 SidebarRowAction::Command(command) => {
672 use crate::tui::views::CommandPaletteAction;
673 Some(ViewEvent::CommandPaletteSelected {
674 action: CommandPaletteAction::ExecuteCommand { command },
675 })
676 }
677 SidebarRowAction::CancelAgent { agent_id } => {
678 Some(ViewEvent::SidebarAgentCancel { agent_id })
679 }
680 _ => None,
681 });
682 if let Some(event) = stop_event {
683 pager = pager.with_destructive_action(
684 's',
685 app.tr(MessageId::SidebarStopControl),
686 app.tr(MessageId::WorkSurfaceStopConfirmHint),
687 event,
688 );
689 }
690 app.view_stack.push(pager);
691 app.needs_redraw = true;
692 Vec::new()
693 }
694 }
695 }
696
697 pub(crate) fn open_agent_chat_pager(app: &mut App, agent_id: &str) -> bool {
698 let Some(text) = resolve_agent_transcript_text(app, agent_id) else {
699 return false;
700 };
701 push_agent_chat_pager(app, agent_id, &text);
702 true
703 }
704
705 fn resolve_agent_transcript_text(app: &App, agent_id: &str) -> Option<String> {
706 use crate::tools::handle::{HandleValue, VarHandle};
707
708 let lookup = VarHandle {
709 kind: "var_handle".to_string(),
710 session_id: format!("agent:{agent_id}"),
711 name: "full_transcript".to_string(),
712 type_name: String::new(),
713 length: 0,
714 repr_preview: String::new(),
715 sha256: String::new(),
716 };
717 let payload = match app.runtime_services.handle_store.try_lock() {
718 Ok(store) => match store.get(&lookup) {
719 Some(record) => match &record.value {
720 HandleValue::Json(value) => Some(value.clone()),
721 HandleValue::Text(_) => None,
722 },
723 None => None,
724 },
725 Err(_) => return None,
726 };
727
728 // The handle is a deliberately bounded live projection. Prefer the private
729 // on-disk message stream so Open means the entire chat, including early
730 // turns that no longer fit in the 1 MiB resident tail. While the worker is
731 // live, require its artifact count to match the latest handle count; a
732 // failed/stale append must fall back to the explicit omission banner. With
733 // no process-local handle (for example after restart), the validated
734 // artifact remains the durable source of truth.
735 if let Ok(messages) =
736 crate::tools::subagent::load_subagent_transcript_artifact(&app.workspace, agent_id)
737 {
738 let matches_resident_count = payload.as_ref().is_none_or(|resident| {
739 resident
740 .get("message_count")
741 .and_then(serde_json::Value::as_u64)
742 .and_then(|count| usize::try_from(count).ok())
743 == Some(messages.len())
744 && resident
745 .get("complete_transcript_artifact")
746 .and_then(|artifact| artifact.get("complete"))
747 .and_then(serde_json::Value::as_bool)
748 .unwrap_or(true)
749 });
750 if matches_resident_count {
751 let text = agent_messages_text(&messages);
752 if !text.trim().is_empty() {
753 return Some(text);
754 }
755 }
756 }
757
758 let payload = payload?;
759 let text = agent_transcript_text(&payload);
760 if text.trim().is_empty() {
761 return None;
762 }
763 Some(text)
764 }
765
766 pub(crate) fn resident_agent_transcript_available(app: &App, agent_id: &str) -> bool {
767 use crate::tools::handle::{HandleValue, VarHandle};
768
769 let lookup = VarHandle {
770 kind: "var_handle".to_string(),
771 session_id: format!("agent:{agent_id}"),
772 name: "full_transcript".to_string(),
773 type_name: String::new(),
774 length: 0,
775 repr_preview: String::new(),
776 sha256: String::new(),
777 };
778 let Ok(store) = app.runtime_services.handle_store.try_lock() else {
779 return false;
780 };
781 let Some(record) = store.get(&lookup) else {
782 return false;
783 };
784 match &record.value {
785 HandleValue::Json(payload) => !agent_transcript_text(payload).trim().is_empty(),
786 HandleValue::Text(_) => false,
787 }
788 }
789
790 pub(crate) fn agent_transcript_evidence_available(app: &App, agent_id: &str) -> bool {
791 resolve_agent_transcript_text(app, agent_id).is_some()
792 }
793
794 fn push_agent_chat_pager(app: &mut App, agent_id: &str, text: &str) {
795 let width = app
796 .viewport
797 .last_transcript_area
798 .map(|area| area.width)
799 .unwrap_or(80);
800 let display_name = crate::tui::agent_details::safe_agent_display_name(app, agent_id);
801 app.view_stack.push(PagerView::from_text(
802 format!("Agent transcript — {display_name}"),
803 text,
804 width.saturating_sub(2),
805 ));
806 }
807
808 /// Turn the agent transcript handle into a readable conversation. The worker
809 /// may retain tool calls and results, but private model thinking never appears
810 /// here; the parent transcript has the same default privacy behavior.
811 fn agent_transcript_text(payload: &serde_json::Value) -> String {
812 let Some(messages) = payload
813 .get("messages")
814 .and_then(serde_json::Value::as_array)
815 else {
816 return String::new();
817 };
818
819 let omitted = payload
820 .get("omitted_messages")
821 .and_then(serde_json::Value::as_u64)
822 .unwrap_or_default();
823 let total = payload
824 .get("message_count")
825 .and_then(serde_json::Value::as_u64)
826 .unwrap_or(messages.len() as u64);
827 let mut text = String::new();
828 if omitted > 0 {
829 text.push_str(&format!(
830 "Showing the latest {} of {total} worker messages. Earlier messages were omitted from the in-memory transcript.\n\n",
831 messages.len()
832 ));
833 }
834
835 let parsed: Vec<Message> = messages
836 .iter()
837 .filter_map(|raw| serde_json::from_value::<Message>(raw.clone()).ok())
838 .collect();
839 text.push_str(&agent_messages_text(&parsed));
840 text
841 }
842
843 fn agent_messages_text(messages: &[Message]) -> String {
844 let mut text = String::new();
845 for message in messages {
846 let body = agent_message_text(message);
847 if body.trim().is_empty() {
848 continue;
849 }
850 text.push_str(&format!("── {} ──\n{body}\n\n", message.role));
851 }
852 text
853 }
854
855 fn agent_message_text(message: &Message) -> String {
856 let mut text = String::new();
857 for block in &message.content {
858 match block {
859 ContentBlock::Text { text: body, .. } => {
860 if !body.trim().is_empty() {
861 text.push_str(body);
862 text.push('\n');
863 }
864 }
865 ContentBlock::ToolUse { name, input, .. }
866 | ContentBlock::ServerToolUse { name, input, .. } => {
867 text.push_str(&format!(
868 "→ {name}\n{}\n",
869 serde_json::to_string_pretty(input).unwrap_or_else(|_| input.to_string())
870 ));
871 }
872 ContentBlock::ToolResult {
873 tool_use_id,
874 content,
875 is_error,
876 ..
877 } => {
878 let label = if is_error.unwrap_or(false) {
879 "← tool error"
880 } else {
881 "← tool result"
882 };
883 text.push_str(&format!("{label} ({tool_use_id})\n{content}\n"));
884 }
885 ContentBlock::ImageUrl { image_url } => {
886 text.push_str(&format!("[image: {}]\n", image_url.url));
887 }
888 // Thinking blocks are deliberately not surfaced in the main TUI
889 // and should not leak through a worker detail view either.
890 ContentBlock::Thinking { .. } => {}
891 other => {
892 text.push_str(&format!(
893 "{}\n",
894 serde_json::to_string_pretty(other).unwrap_or_else(|_| "[worker event]".into())
895 ));
896 }
897 }
898 }
899 text.trim_end().to_string()
900 }
901
902 pub(crate) fn mouse_hits_transcript_scrollbar(app: &App, mouse: MouseEvent) -> bool {
903 let Some(area) = app.viewport.last_transcript_area else {
904 return false;
905 };
906 if area.width <= 1 || app.viewport.last_transcript_total <= app.viewport.last_transcript_visible
907 {
908 return false;
909 }
910
911 let scrollbar_col = area.x.saturating_add(area.width.saturating_sub(1));
912 mouse.column == scrollbar_col
913 && mouse.row >= area.y
914 && mouse.row < area.y.saturating_add(area.height)
915 }
916
917 pub(crate) fn scroll_transcript_to_mouse_row(app: &mut App, row: u16) -> bool {
918 let Some(area) = app.viewport.last_transcript_area else {
919 return false;
920 };
921 let total = app.viewport.last_transcript_total;
922 let visible = app.viewport.last_transcript_visible;
923 if area.height == 0 || total <= visible {
924 return false;
925 }
926
927 let max_start = total.saturating_sub(visible);
928 if max_start == 0 {
929 app.scroll_to_bottom();
930 return true;
931 }
932
933 let max_row = usize::from(area.height.saturating_sub(1));
934 let relative_row = usize::from(row.saturating_sub(area.y)).min(max_row);
935 let numerator = relative_row
936 .saturating_mul(max_start)
937 .saturating_add(max_row / 2);
938 // Round to the nearest transcript offset so short thumbs still feel
939 // responsive on compact terminals.
940 let top = numerator.checked_div(max_row).unwrap_or(0);
941
942 app.viewport.transcript_scroll = if top >= max_start {
943 TranscriptScroll::to_bottom()
944 } else {
945 TranscriptScroll::at_line(top)
946 };
947 app.viewport.pending_scroll_delta = 0;
948 app.user_scrolled_during_stream = !app.viewport.transcript_scroll.is_at_tail();
949 app.needs_redraw = true;
950 true
951 }
952
953 /// Cadence between auto-scroll ticks while drag-selecting past the
954 /// transcript edge (#1163). 30 ms ≈ 33 lines/sec, comparable to the feel
955 /// of a steady scroll-wheel drag.
956 const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30);
957
958 /// Update the transcript selection while the left button is dragging.
959 /// When the mouse leaves the transcript rect vertically, arm
960 /// `selection_autoscroll` so the main loop can advance the viewport on a
961 /// fixed cadence; when the mouse returns inside, disarm it.
962 pub(crate) fn update_selection_drag(app: &mut App, mouse: MouseEvent) {
963 if let Some(point) = selection_point_from_mouse(app, mouse) {
964 app.viewport.transcript_selection.head = Some(point);
965 app.viewport.selection_autoscroll = None;
966 return;
967 }
968
969 let Some(area) = app.viewport.last_transcript_area else {
970 return;
971 };
972 if area.height == 0 || area.width == 0 {
973 return;
974 }
975
976 let direction = if mouse.row < area.y {
977 -1
978 } else if mouse.row >= area.y.saturating_add(area.height) {
979 1
980 } else {
981 // Outside horizontally only — leave selection head where it is.
982 return;
983 };
984
985 let max_col = area.x.saturating_add(area.width.saturating_sub(1));
986 let column = mouse.column.clamp(area.x, max_col);
987
988 // Fire on the next tick immediately by setting `next_tick` to now.
989 app.viewport.selection_autoscroll = Some(SelectionAutoscroll {
990 direction,
991 column,
992 next_tick: Instant::now(),
993 });
994 app.needs_redraw = true;
995 }
996
997 /// Advance the drag-edge auto-scroll one step if its cadence has elapsed.
998 /// Called once per main-loop iteration.
999 pub(crate) fn tick_selection_autoscroll(app: &mut App) {
1000 let Some(state) = app.viewport.selection_autoscroll else {
1001 return;
1002 };
1003
1004 if !app.viewport.transcript_selection.dragging {
1005 app.viewport.selection_autoscroll = None;
1006 return;
1007 }
1008
1009 let Some(area) = app.viewport.last_transcript_area else {
1010 return;
1011 };
1012 if area.height == 0 {
1013 return;
1014 }
1015
1016 let now = Instant::now();
1017 if now < state.next_tick {
1018 return;
1019 }
1020
1021 app.viewport.pending_scroll_delta = app
1022 .viewport
1023 .pending_scroll_delta
1024 .saturating_add(state.direction);
1025 app.user_scrolled_during_stream = true;
1026
1027 let edge_row = if state.direction < 0 {
1028 area.y
1029 } else {
1030 area.y.saturating_add(area.height.saturating_sub(1))
1031 };
1032 if let Some(point) = selection_point_from_position(
1033 area,
1034 state.column,
1035 edge_row,
1036 app.viewport.last_transcript_top,
1037 app.viewport.last_transcript_total,
1038 app.viewport.last_transcript_padding_top,
1039 ) {
1040 app.viewport.transcript_selection.head = Some(point);
1041 }
1042
1043 app.viewport.selection_autoscroll = Some(SelectionAutoscroll {
1044 next_tick: now + SELECTION_AUTOSCROLL_INTERVAL,
1045 ..state
1046 });
1047 app.needs_redraw = true;
1048 }
1049
1050 pub(crate) fn mouse_hits_rect(mouse: MouseEvent, area: Option<Rect>) -> bool {
1051 point_hits_rect(mouse.column, mouse.row, area)
1052 }
1053
1054 fn point_hits_rect(column: u16, row: u16, area: Option<Rect>) -> bool {
1055 let Some(area) = area else {
1056 return false;
1057 };
1058
1059 column >= area.x
1060 && column < area.x.saturating_add(area.width)
1061 && row >= area.y
1062 && row < area.y.saturating_add(area.height)
1063 }
1064
1065 pub(crate) fn open_context_menu(app: &mut App, mouse: MouseEvent) {
1066 let entries = build_context_menu_entries(app, mouse);
1067 if entries.is_empty() {
1068 return;
1069 }
1070 let title = app.tr(MessageId::CtxMenuTitle).to_string();
1071 let reduced = app.motion_policy().as_low_motion();
1072 app.view_stack.push(ContextMenuView::new_with_motion(
1073 entries,
1074 mouse.column,
1075 mouse.row,
1076 title,
1077 reduced,
1078 ));
1079 app.needs_redraw = true;
1080 }
1081
1082 pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec<ContextMenuEntry> {
1083 let mut entries = Vec::new();
1084 let mut git_path = None;
1085 let on_sidebar = mouse_hits_rect(mouse, app.work_surface.last_area);
1086
1087 if on_sidebar {
1088 if let Some(command) = sidebar_click_action(app, mouse)
1089 .and_then(|action| action.as_command().map(str::to_string))
1090 {
1091 entries.push(
1092 ContextMenuEntry::new(
1093 "Run",
1094 command.clone(),
1095 ContextMenuAction::ExecuteCommand { command },
1096 )
1097 .with_glyph("▶")
1098 .primary(),
1099 );
1100 }
1101 // Copy the hovered row's full text (sidebar rows can't be
1102 // mouse-selected, so the menu is the only copy path).
1103 if let Some(text) = sidebar_row_copy_text(app, mouse) {
1104 entries.push(
1105 ContextMenuEntry::new(
1106 "Copy",
1107 truncate_line_to_width(first_line(&text), 28),
1108 ContextMenuAction::CopyText { text },
1109 )
1110 .with_glyph("⎘")
1111 .with_hint("y"),
1112 );
1113 }
1114 } else {
1115 // Paste first — the most common action when right-clicking in the
1116 // composer or transcript after copying text from the output area.
1117 entries.push(
1118 ContextMenuEntry::new(
1119 app.tr(MessageId::CtxMenuPaste),
1120 app.tr(MessageId::CtxMenuPasteDesc),
1121 ContextMenuAction::Paste,
1122 )
1123 .with_glyph("📋")
1124 .with_hint("p")
1125 .primary(),
1126 );
1127 }
1128
1129 if selection_has_content(app) {
1130 entries.push(
1131 ContextMenuEntry::new(
1132 app.tr(MessageId::CtxMenuCopySelection),
1133 app.tr(MessageId::CtxMenuCopySelectionDesc),
1134 ContextMenuAction::CopySelection,
1135 )
1136 .with_glyph("⎘")
1137 .with_hint("y")
1138 .section_start(),
1139 );
1140 entries.push(
1141 ContextMenuEntry::new(
1142 app.tr(MessageId::CtxMenuOpenSelection),
1143 app.tr(MessageId::CtxMenuOpenSelectionDesc),
1144 ContextMenuAction::OpenSelection,
1145 )
1146 .with_glyph("↗"),
1147 );
1148 entries.push(
1149 ContextMenuEntry::new(
1150 app.tr(MessageId::CtxMenuClearSelection),
1151 "",
1152 ContextMenuAction::ClearSelection,
1153 )
1154 .with_glyph("×"),
1155 );
1156 }
1157
1158 if !on_sidebar && let Some(filtered_cell_index) = transcript_cell_index_from_mouse(app, mouse) {
1159 let cell_index = app.original_cell_index_for_rendered(filtered_cell_index);
1160 git_path = context_menu_git_path(app, cell_index);
1161
1162 let target = detail_target_label(app, cell_index)
1163 .map(|label| truncate_line_to_width(label.as_str(), 28))
1164 .unwrap_or_else(|| "message".to_string());
1165 entries.push(
1166 ContextMenuEntry::new(
1167 app.tr(MessageId::CtxMenuOpenDetails),
1168 target,
1169 ContextMenuAction::OpenDetails { cell_index },
1170 )
1171 .with_glyph("▣")
1172 .section_start(),
1173 );
1174 entries.push(
1175 ContextMenuEntry::new(
1176 app.tr(MessageId::CtxMenuCopyMessage),
1177 app.tr(MessageId::CtxMenuCopyMessageDesc),
1178 ContextMenuAction::CopyCell { cell_index },
1179 )
1180 .with_glyph("⎘"),
1181 );
1182 entries.push(
1183 ContextMenuEntry::new(
1184 app.tr(MessageId::CtxMenuOpenInEditor),
1185 app.tr(MessageId::CtxMenuOpenInEditorDesc),
1186 ContextMenuAction::OpenFileAtLine { cell_index },
1187 )
1188 .with_glyph("↗")
1189 .with_hint("e"),
1190 );
1191 // Hide/show cell toggle.
1192 if app.collapsed_cells.contains(&cell_index) {
1193 entries.push(
1194 ContextMenuEntry::new(
1195 app.tr(MessageId::CtxMenuShowCell),
1196 app.tr(MessageId::CtxMenuShowCellDesc),
1197 ContextMenuAction::ShowCell { cell_index },
1198 )
1199 .with_glyph("◇"),
1200 );
1201 } else {
1202 entries.push(
1203 ContextMenuEntry::new(
1204 app.tr(MessageId::CtxMenuHideCell),
1205 app.tr(MessageId::CtxMenuHideCellDesc),
1206 ContextMenuAction::HideCell { cell_index },
1207 )
1208 .with_glyph("○"),
1209 );
1210 }
1211 }
1212
1213 // When cells are hidden, offer a way to show them all.
1214 if !app.collapsed_cells.is_empty() {
1215 let count = app.collapsed_cells.len();
1216 let label = app.tr(MessageId::CtxMenuShowHidden).to_string();
1217 entries.push(
1218 ContextMenuEntry::new(
1219 format!("{label} ({count})"),
1220 app.tr(MessageId::CtxMenuShowHiddenDesc),
1221 ContextMenuAction::ShowAllHidden,
1222 )
1223 .with_glyph("◇")
1224 .section_start(),
1225 );
1226 }
1227
1228 entries.push(
1229 ContextMenuEntry::new(
1230 app.tr(MessageId::CtxMenuCmdPalette),
1231 app.tr(MessageId::CtxMenuCmdPaletteDesc),
1232 ContextMenuAction::OpenCommandPalette,
1233 )
1234 .with_glyph("⌘")
1235 .section_start(),
1236 );
1237 entries.push(
1238 ContextMenuEntry::new(
1239 app.tr(MessageId::CtxMenuContextInspector),
1240 app.tr(MessageId::CtxMenuContextInspectorDesc),
1241 ContextMenuAction::OpenContextInspector,
1242 )
1243 .with_glyph("ⓘ"),
1244 );
1245 entries.push(
1246 ContextMenuEntry::new(
1247 app.tr(MessageId::CtxMenuHelp),
1248 app.tr(MessageId::CtxMenuHelpDesc),
1249 ContextMenuAction::OpenHelp,
1250 )
1251 .with_glyph("?"),
1252 );
1253
1254 let branch = git_path
1255 .as_deref()
1256 .and_then(|_| crate::tui::workspace_context::branch(&app.workspace));
1257 crate::tui::context_menu::with_git_actions(entries, git_path.as_deref(), branch.as_deref())
1258 }
1259
1260 fn context_menu_git_path(app: &App, cell_index: usize) -> Option<String> {
1261 use crate::tui::history::ToolCell;
1262
1263 match app.cell_at_virtual_index(cell_index)? {
1264 HistoryCell::Tool(ToolCell::PatchSummary(patch)) => Some(patch.path.clone()),
1265 HistoryCell::Tool(ToolCell::ViewImage(image)) => {
1266 Some(image.path.to_string_lossy().into_owned())
1267 }
1268 _ => None,
1269 }
1270 }
1271
1272 pub(crate) fn transcript_cell_index_from_mouse(app: &App, mouse: MouseEvent) -> Option<usize> {
1273 let point = selection_point_from_mouse(app, mouse)?;
1274 app.viewport
1275 .transcript_cache
1276 .line_meta()
1277 .get(point.line_index)
1278 .and_then(|meta| meta.cell_line())
1279 .map(|(cell_index, _)| cell_index)
1280 }
1281
1282 pub(crate) fn handle_context_menu_action(app: &mut App, action: ContextMenuAction) {
1283 match action {
1284 ContextMenuAction::CopySelection => {
1285 copy_active_selection(app);
1286 }
1287 ContextMenuAction::OpenSelection => {
1288 if !open_pager_for_selection(app) {
1289 app.status_message = Some("No selection to open".to_string());
1290 }
1291 }
1292 ContextMenuAction::ClearSelection => {
1293 app.viewport.transcript_selection.clear();
1294 app.status_message = Some("Selection cleared".to_string());
1295 }
1296 ContextMenuAction::CopyCell { cell_index } => {
1297 copy_cell_to_clipboard(app, cell_index);
1298 }
1299 ContextMenuAction::OpenDetails { cell_index } => {
1300 if !open_details_pager_for_cell(app, cell_index) {
1301 app.status_message = Some("No details available for that line".to_string());
1302 }
1303 }
1304 ContextMenuAction::Paste => {
1305 app.paste_from_clipboard();
1306 }
1307 ContextMenuAction::ExecuteCommand { command } => {
1308 app.input = command;
1309 app.status_message = Some("Command staged in composer".to_string());
1310 app.needs_redraw = true;
1311 }
1312 ContextMenuAction::CopyText { text } => {
1313 if app.clipboard.write_text(&text).is_ok() {
1314 app.status_message = Some("Copied".to_string());
1315 } else {
1316 app.status_message = Some("Copy failed".to_string());
1317 }
1318 }
1319 ContextMenuAction::OpenCommandPalette => {
1320 codewhale_telemetry::session_counters()
1321 .bump(codewhale_telemetry::Counter::CommandPaletteOpen);
1322 app.view_stack.push(CommandPaletteView::new_for_locale(
1323 app.ui_locale,
1324 build_command_palette_entries(
1325 app.ui_locale,
1326 &app.skills_dir,
1327 app.skills_scan_codewhale_only,
1328 &app.workspace,
1329 &app.mcp_config_path,
1330 app.mcp_snapshot.as_ref(),
1331 ),
1332 ));
1333 }
1334 ContextMenuAction::OpenContextInspector => {
1335 open_context_inspector(app);
1336 }
1337 ContextMenuAction::OpenHelp => {
1338 let help =
1339 HelpView::new_for_workspace(app.ui_locale, &app.workspace, &app.cached_skills);
1340 app.view_stack.push(help);
1341 }
1342 ContextMenuAction::OpenFileAtLine { cell_index } => {
1343 let width = app
1344 .viewport
1345 .last_transcript_area
1346 .map(|area| area.width)
1347 .unwrap_or(80);
1348 let text = history_cell_to_text(
1349 app.cell_at_virtual_index(cell_index)
1350 .unwrap_or(&HistoryCell::System {
1351 content: String::new(),
1352 }),
1353 width,
1354 );
1355 if crate::tui::history::try_open_file_at_line(&text, &app.workspace) {
1356 app.status_message = Some("Opened file in editor".to_string());
1357 } else {
1358 app.status_message = Some("No file:line pattern found in selection".to_string());
1359 }
1360 }
1361 ContextMenuAction::HideCell { cell_index } => {
1362 app.collapsed_cells.insert(cell_index);
1363 app.status_message = Some("Cell hidden".to_string());
1364 }
1365 ContextMenuAction::ShowCell { cell_index } => {
1366 app.collapsed_cells.remove(&cell_index);
1367 app.status_message = Some("Cell shown".to_string());
1368 }
1369 ContextMenuAction::ShowAllHidden => {
1370 let count = app.collapsed_cells.len();
1371 app.collapsed_cells.clear();
1372 app.status_message = Some(format!("{count} hidden cell(s) restored"));
1373 }
1374 }
1375 app.needs_redraw = true;
1376 }
1377
1378 pub(crate) fn selection_point_from_mouse(
1379 app: &App,
1380 mouse: MouseEvent,
1381 ) -> Option<TranscriptSelectionPoint> {
1382 selection_point_from_position(
1383 app.viewport.last_transcript_area?,
1384 mouse.column,
1385 mouse.row,
1386 app.viewport.last_transcript_top,
1387 app.viewport.last_transcript_total,
1388 app.viewport.last_transcript_padding_top,
1389 )
1390 }
1391
1392 pub(crate) fn selection_point_from_position(
1393 area: Rect,
1394 column: u16,
1395 row: u16,
1396 transcript_top: usize,
1397 transcript_total: usize,
1398 padding_top: usize,
1399 ) -> Option<TranscriptSelectionPoint> {
1400 if column < area.x
1401 || column >= area.x + area.width
1402 || row < area.y
1403 || row >= area.y + area.height
1404 {
1405 return None;
1406 }
1407
1408 if transcript_total == 0 {
1409 return None;
1410 }
1411
1412 let row = row.saturating_sub(area.y) as usize;
1413 if row < padding_top {
1414 return None;
1415 }
1416 let row = row.saturating_sub(padding_top);
1417
1418 let col = column.saturating_sub(area.x) as usize;
1419 let line_index = transcript_top
1420 .saturating_add(row)
1421 .min(transcript_total.saturating_sub(1));
1422
1423 Some(TranscriptSelectionPoint {
1424 line_index,
1425 column: col,
1426 })
1427 }
1428
1429 pub(crate) fn selection_has_content(app: &App) -> bool {
1430 // Composer selection takes priority (same as Cmd+C handler above).
1431 if !app.selected_text().is_empty() {
1432 return true;
1433 }
1434 selection_to_text(app).is_some_and(|text| !text.is_empty())
1435 }
1436
1437 /// Branches taken by the Ctrl+C key handler. The order encodes priority and is
1438 /// the unit-tested contract for #1337 / #1367: a transcript selection always
1439 /// wins (so users learn that Ctrl+C copies when there's something to copy);
1440 /// otherwise an active turn is interrupted; otherwise the quit-arm flow runs.
1441 #[derive(Debug, PartialEq, Eq)]
1442 pub(crate) enum CtrlCDisposition {
1443 CopySelection,
1444 CancelTurn,
1445 ConfirmExit,
1446 ArmExit,
1447 }
1448
1449 pub(crate) fn ctrl_c_disposition(app: &App) -> CtrlCDisposition {
1450 if selection_has_content(app) {
1451 CtrlCDisposition::CopySelection
1452 } else if app.is_loading {
1453 CtrlCDisposition::CancelTurn
1454 } else if app.quit_is_armed() {
1455 CtrlCDisposition::ConfirmExit
1456 } else {
1457 CtrlCDisposition::ArmExit
1458 }
1459 }
1460
1461 /// Normalize the raw Ctrl+C control byte to canonical `Ctrl+C`.
1462 ///
1463 /// In PTY/raw-mode the terminal driver delivers Ctrl+C as the literal byte
1464 /// `0x03` (the ETX control character). crossterm usually decodes that to
1465 /// `Char('c') + CONTROL`, but some terminal / kitty-keyboard-protocol
1466 /// combinations surface it as `Char('\u{3}')` instead, where it slips past the
1467 /// `Char('c') + CONTROL` arm of the key handler and never reaches the
1468 /// quit-arm flow (#4090). Rewriting every encoding of Ctrl+C to the canonical
1469 /// form here keeps the double-press-to-exit behavior consistent across PTY,
1470 /// raw-mode, and kitty-enhanced terminals.
1471 pub(crate) fn normalize_raw_ctrl_c(key: &mut KeyEvent) {
1472 if matches!(key.code, KeyCode::Char('\u{3}')) {
1473 key.code = KeyCode::Char('c');
1474 key.modifiers.insert(KeyModifiers::CONTROL);
1475 }
1476 }
1477
1478 pub(crate) fn copy_active_selection(app: &mut App) {
1479 // Composer selection takes priority.
1480 let sel = app.selected_text();
1481 if !sel.is_empty() {
1482 if app.clipboard.write_text(&sel).is_ok() {
1483 app.status_message = Some("Selection copied".to_string());
1484 app.clear_selection();
1485 } else {
1486 app.status_message = Some("Copy failed".to_string());
1487 }
1488 return;
1489 }
1490 if !app.viewport.transcript_selection.is_active() {
1491 return;
1492 }
1493 if let Some(text) = selection_to_text(app).filter(|text| !text.is_empty()) {
1494 if app.clipboard.write_text(&text).is_ok() {
1495 app.status_message = Some("Selection copied".to_string());
1496 } else {
1497 app.status_message = Some("Copy failed".to_string());
1498 }
1499 } else {
1500 app.viewport.transcript_selection.clear();
1501 app.status_message = Some("No selection to copy".to_string());
1502 }
1503 }
1504
1505 pub(crate) fn selection_to_text(app: &App) -> Option<String> {
1506 let (start, end) = app.viewport.transcript_selection.ordered_endpoints()?;
1507 let lines = app.viewport.transcript_cache.lines();
1508 if lines.is_empty() {
1509 return None;
1510 }
1511 let end_index = end.line_index.min(lines.len().saturating_sub(1));
1512 let start_index = start.line_index.min(end_index);
1513
1514 let line_meta = app.viewport.transcript_cache.line_meta();
1515 let mut selected = String::new();
1516 let mut separator_before = None;
1517 #[allow(clippy::needless_range_loop)]
1518 for line_index in start_index..=end_index {
1519 if let Some(separator) = separator_before {
1520 selected.push_str(separator);
1521 }
1522 // Rail-prefix decorations are stored as cache metadata rather than
1523 // detected from glyphs, so new decoration types are covered without
1524 // changes to the copy path (#1163).
1525 let rail_width = app.viewport.transcript_cache.rail_prefix_width(line_index);
1526 // Convert the rendered line to plain text (strips OSC-8), then
1527 // slice off the rail prefix so subsequent column offsets operate
1528 // on content-only text.
1529 let full_text = line_to_plain(&lines[line_index]);
1530 let line_after_rail = if rail_width > 0 {
1531 slice_text(&full_text, rail_width, text_display_width(&full_text))
1532 } else {
1533 full_text
1534 };
1535 let line_after_rail_width = text_display_width(&line_after_rail);
1536 let copy_prefix_width = line_meta
1537 .get(line_index)
1538 .map(|meta| meta.copy_prefix_width())
1539 .unwrap_or(0)
1540 .min(line_after_rail_width);
1541 let line_text = if copy_prefix_width > 0 {
1542 slice_text(&line_after_rail, copy_prefix_width, line_after_rail_width)
1543 } else {
1544 line_after_rail
1545 };
1546 let line_width = text_display_width(&line_text);
1547 let visual_prefix_width = rail_width.saturating_add(copy_prefix_width);
1548 // Selection coordinates are recorded in rendered-column space, which
1549 // includes visual prefixes. Add them back so the column window maps
1550 // correctly into copy-only text.
1551 let (raw_col_start, raw_col_end) = if start_index == end_index {
1552 (start.column, end.column)
1553 } else if line_index == start_index {
1554 (start.column, line_width.saturating_add(visual_prefix_width))
1555 } else if line_index == end_index {
1556 (0, end.column)
1557 } else {
1558 (0, line_width.saturating_add(visual_prefix_width))
1559 };
1560
1561 let col_start = raw_col_start
1562 .saturating_sub(visual_prefix_width)
1563 .min(line_width);
1564 let col_end = raw_col_end
1565 .saturating_sub(visual_prefix_width)
1566 .min(line_width);
1567
1568 let slice = slice_text(&line_text, col_start, col_end);
1569 selected.push_str(&slice);
1570 separator_before = line_meta
1571 .get(line_index)
1572 .map(|meta| meta.copy_separator_after().as_str())
1573 .or(Some("\n"));
1574 }
1575 Some(selected)
1576 }
1577
1578 #[cfg(test)]
1579 mod tests {
1580 use super::{
1581 agent_transcript_text, build_context_menu_entries, open_agent_chat_pager,
1582 sidebar_click_action,
1583 };
1584 use crate::config::Config;
1585 use crate::models::{ContentBlock, Message};
1586 use crate::tui::app::{
1587 App, SidebarHoverRow, SidebarHoverSection, SidebarRowAction, TuiOptions,
1588 };
1589 use crate::tui::pager::PagerView;
1590 use crate::tui::views::ContextMenuAction;
1591 use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
1592 use ratatui::layout::Rect;
1593 use serde_json::json;
1594 use std::path::PathBuf;
1595 use tempfile::tempdir;
1596
1597 fn create_test_app() -> App {
1598 let options = TuiOptions {
1599 ..crate::test_support::test_tui_options(PathBuf::from("."))
1600 };
1601 App::new(options, &Config::default())
1602 }
1603
1604 fn hover_row(row_y: u16, action: Option<&str>) -> SidebarHoverRow {
1605 SidebarHoverRow {
1606 row_y,
1607 display_text: "row".to_string(),
1608 full_text: "row".to_string(),
1609 detail: None,
1610 is_truncated: false,
1611 click_action: action.map(|action| SidebarRowAction::Command(action.to_string())),
1612 stop_action: None,
1613 stop_zone_start_col: None,
1614 stop_zone_end_col: None,
1615 }
1616 }
1617
1618 fn hover_row_with_stop(row_y: u16, action: &str, stop_action: &str) -> SidebarHoverRow {
1619 SidebarHoverRow {
1620 row_y,
1621 display_text: "job row [x]".to_string(),
1622 full_text: "job row [x]".to_string(),
1623 detail: None,
1624 is_truncated: false,
1625 click_action: Some(SidebarRowAction::Command(action.to_string())),
1626 stop_action: Some(SidebarRowAction::Command(stop_action.to_string())),
1627 stop_zone_start_col: Some(68),
1628 stop_zone_end_col: Some(71),
1629 }
1630 }
1631
1632 fn action_command(action: Option<SidebarRowAction>) -> Option<String> {
1633 action
1634 .as_ref()
1635 .and_then(SidebarRowAction::as_command)
1636 .map(str::to_string)
1637 }
1638
1639 fn left_click(column: u16, row: u16) -> MouseEvent {
1640 MouseEvent {
1641 kind: MouseEventKind::Down(MouseButton::Left),
1642 column,
1643 row,
1644 modifiers: KeyModifiers::NONE,
1645 }
1646 }
1647
1648 fn right_click(column: u16, row: u16) -> MouseEvent {
1649 MouseEvent {
1650 kind: MouseEventKind::Down(MouseButton::Right),
1651 column,
1652 row,
1653 modifiers: KeyModifiers::NONE,
1654 }
1655 }
1656
1657 #[test]
1658 fn context_menu_keeps_paste_first_outside_sidebar() {
1659 let mut app = create_test_app();
1660 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
1661
1662 let entries = build_context_menu_entries(&app, right_click(10, 4));
1663
1664 assert!(matches!(
1665 entries.first().map(|entry| &entry.action),
1666 Some(ContextMenuAction::Paste)
1667 ));
1668 }
1669
1670 #[test]
1671 fn sidebar_context_menu_omits_paste_without_row_action() {
1672 let mut app = create_test_app();
1673 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
1674 app.sidebar_hover.sections.push(SidebarHoverSection {
1675 content_area: Rect::new(60, 4, 20, 6),
1676 lines: vec!["header".to_string()],
1677 rows: vec![hover_row(4, None)],
1678 });
1679
1680 let entries = build_context_menu_entries(&app, right_click(65, 4));
1681
1682 assert!(
1683 !entries
1684 .iter()
1685 .any(|entry| matches!(entry.action, ContextMenuAction::Paste)),
1686 "sidebar menu should not offer paste: {entries:?}"
1687 );
1688 }
1689
1690 #[test]
1691 fn sidebar_context_menu_runs_clickable_row_action() {
1692 let mut app = create_test_app();
1693 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
1694 app.sidebar_hover.sections.push(SidebarHoverSection {
1695 content_area: Rect::new(60, 4, 20, 6),
1696 lines: vec!["job row".to_string()],
1697 rows: vec![hover_row(4, Some("/jobs show shell_x"))],
1698 });
1699
1700 let entries = build_context_menu_entries(&app, right_click(65, 4));
1701
1702 let first = entries.first().expect("sidebar row should have menu");
1703 assert_eq!(first.label, "Run");
1704 assert_eq!(first.description, "/jobs show shell_x");
1705 assert!(matches!(
1706 &first.action,
1707 ContextMenuAction::ExecuteCommand { command } if command == "/jobs show shell_x"
1708 ));
1709 assert!(
1710 !entries
1711 .iter()
1712 .any(|entry| matches!(entry.action, ContextMenuAction::Paste)),
1713 "clickable sidebar menu should not offer paste: {entries:?}"
1714 );
1715 }
1716
1717 #[test]
1718 fn sidebar_click_resolves_row_actions_inside_section() {
1719 let mut app = create_test_app();
1720 app.sidebar_hover.sections.push(SidebarHoverSection {
1721 content_area: Rect::new(60, 4, 20, 6),
1722 lines: vec![
1723 "header".to_string(),
1724 "job row".to_string(),
1725 "job detail".to_string(),
1726 "agent row".to_string(),
1727 ],
1728 rows: vec![
1729 hover_row(4, None),
1730 hover_row(5, Some("/jobs show shell_x")),
1731 hover_row(6, Some("/jobs cancel shell_x")),
1732 SidebarHoverRow {
1733 row_y: 7,
1734 display_text: "agent row".to_string(),
1735 full_text: "agent row".to_string(),
1736 detail: None,
1737 is_truncated: false,
1738 click_action: Some(SidebarRowAction::ToggleAgentDetails {
1739 agent_id: "agent_123".to_string(),
1740 }),
1741 stop_action: None,
1742 stop_zone_start_col: None,
1743 stop_zone_end_col: None,
1744 },
1745 ],
1746 });
1747
1748 assert_eq!(
1749 action_command(sidebar_click_action(&app, left_click(65, 5))).as_deref(),
1750 Some("/jobs show shell_x"),
1751 "job label row resolves to its show action"
1752 );
1753 assert_eq!(
1754 action_command(sidebar_click_action(&app, left_click(79, 6))).as_deref(),
1755 Some("/jobs cancel shell_x"),
1756 "job detail row resolves to its cancel action"
1757 );
1758 assert!(matches!(
1759 sidebar_click_action(&app, left_click(60, 7)),
1760 Some(SidebarRowAction::ToggleAgentDetails { agent_id })
1761 if agent_id == "agent_123"
1762 ));
1763 assert_eq!(
1764 sidebar_click_action(&app, left_click(65, 4)),
1765 None,
1766 "header row has no action"
1767 );
1768 }
1769
1770 #[test]
1771 fn sidebar_click_routes_inline_stop_zone_before_row_action() {
1772 let mut app = create_test_app();
1773 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 4));
1774 app.sidebar_hover.sections.push(SidebarHoverSection {
1775 content_area: Rect::new(60, 4, 20, 4),
1776 lines: vec!["job row [x]".to_string()],
1777 rows: vec![hover_row_with_stop(
1778 4,
1779 "/jobs show shell_x",
1780 "/jobs cancel shell_x",
1781 )],
1782 });
1783
1784 assert_eq!(
1785 action_command(sidebar_click_action(&app, left_click(62, 4))).as_deref(),
1786 Some("/jobs show shell_x"),
1787 "clicking the label opens the job"
1788 );
1789 assert_eq!(
1790 action_command(sidebar_click_action(&app, left_click(69, 4))).as_deref(),
1791 Some("/jobs cancel shell_x"),
1792 "clicking [x] cancels the job"
1793 );
1794 }
1795
1796 #[test]
1797 fn sidebar_click_routes_agent_inline_stop_zone_before_peek_action() {
1798 let mut app = create_test_app();
1799 app.work_surface.last_area = Some(Rect::new(60, 4, 24, 4));
1800 app.sidebar_hover.sections.push(SidebarHoverSection {
1801 content_area: Rect::new(60, 4, 24, 4),
1802 lines: vec!["[~] worker Agent 1 [x]".to_string()],
1803 rows: vec![SidebarHoverRow {
1804 row_y: 4,
1805 display_text: "[~] Agent 1 is working [x]".to_string(),
1806 full_text: "[~] Agent 1 is working [x]".to_string(),
1807 detail: None,
1808 is_truncated: false,
1809 click_action: Some(SidebarRowAction::ToggleAgentDetails {
1810 agent_id: "agent_123".to_string(),
1811 }),
1812 stop_action: Some(SidebarRowAction::CancelAgent {
1813 agent_id: "agent_123".to_string(),
1814 }),
1815 stop_zone_start_col: Some(68),
1816 stop_zone_end_col: Some(71),
1817 }],
1818 });
1819
1820 assert!(matches!(
1821 sidebar_click_action(&app, left_click(62, 4)),
1822 Some(SidebarRowAction::ToggleAgentDetails { agent_id })
1823 if agent_id == "agent_123"
1824 ));
1825 assert!(matches!(
1826 sidebar_click_action(&app, left_click(69, 4)),
1827 Some(SidebarRowAction::CancelAgent { agent_id }) if agent_id == "agent_123"
1828 ));
1829 }
1830
1831 #[test]
1832 fn sidebar_context_menu_offers_copy_of_hovered_row() {
1833 let mut app = create_test_app();
1834 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
1835 app.sidebar_hover.sections.push(SidebarHoverSection {
1836 content_area: Rect::new(60, 4, 20, 6),
1837 lines: vec!["agent row".to_string()],
1838 rows: vec![SidebarHoverRow {
1839 row_y: 4,
1840 display_text: "[~] worker doc-che…".to_string(),
1841 full_text: "[~] worker doc-checker".to_string(),
1842 detail: Some("id: agent_123 · 2 step(s)".to_string()),
1843 is_truncated: true,
1844 click_action: None,
1845 stop_action: None,
1846 stop_zone_start_col: None,
1847 stop_zone_end_col: None,
1848 }],
1849 });
1850
1851 let entries = build_context_menu_entries(&app, right_click(65, 4));
1852
1853 let copy = entries
1854 .iter()
1855 .find(|entry| matches!(entry.action, ContextMenuAction::CopyText { .. }))
1856 .expect("sidebar row should offer Copy");
1857 assert_eq!(copy.label, "Copy");
1858 assert!(matches!(
1859 &copy.action,
1860 ContextMenuAction::CopyText { text }
1861 if text == "[~] worker doc-checker\nid: agent_123 · 2 step(s)"
1862 ));
1863 }
1864
1865 #[test]
1866 fn sidebar_click_outside_section_resolves_to_none() {
1867 let mut app = create_test_app();
1868 app.sidebar_hover.sections.push(SidebarHoverSection {
1869 content_area: Rect::new(60, 4, 20, 6),
1870 lines: vec!["job row".to_string()],
1871 rows: vec![hover_row(4, Some("/jobs show shell_x"))],
1872 });
1873
1874 // Left of the sidebar (transcript area).
1875 assert_eq!(sidebar_click_action(&app, left_click(10, 4)), None);
1876 // Below the section's content area.
1877 assert_eq!(sidebar_click_action(&app, left_click(65, 30)), None);
1878 // Inside the section but on an empty row without metadata.
1879 assert_eq!(sidebar_click_action(&app, left_click(65, 8)), None);
1880 }
1881
1882 #[test]
1883 fn worker_transcript_formats_visible_activity_without_thinking() {
1884 let transcript = agent_transcript_text(&json!({
1885 "message_count": 2,
1886 "messages": [
1887 {"role": "user", "content": [{"type": "text", "text": "Survey Harnesses", "cache_control": null}]},
1888 {"role": "assistant", "content": [
1889 {"type": "thinking", "thinking": "private chain of thought", "signature": null},
1890 {"type": "tool_use", "id": "call_1", "name": "list_dir", "input": {"path": "/tmp"}, "caller": null},
1891 {"type": "text", "text": "I found the workspace.", "cache_control": null}
1892 ]}
1893 ]
1894 }));
1895
1896 assert!(transcript.contains("── user ──\nSurvey Harnesses"));
1897 assert!(transcript.contains("→ list_dir"));
1898 assert!(transcript.contains("I found the workspace."));
1899 assert!(!transcript.contains("private chain of thought"));
1900 }
1901
1902 #[test]
1903 fn worker_open_reads_first_and_last_turns_from_complete_artifact() {
1904 let tmp = tempdir().expect("tempdir");
1905 let agent_id = "agent_large_chat";
1906 let early = format!("EARLY-OPEN-MARKER\n{}", "a".repeat(1_100_000));
1907 let messages = vec![
1908 Message {
1909 role: "user".to_string(),
1910 content: vec![ContentBlock::Text {
1911 text: early,
1912 cache_control: None,
1913 }],
1914 },
1915 Message {
1916 role: "assistant".to_string(),
1917 content: vec![ContentBlock::Text {
1918 text: "LAST-OPEN-MARKER".to_string(),
1919 cache_control: None,
1920 }],
1921 },
1922 ];
1923 let artifact = crate::tools::subagent::write_subagent_transcript_artifact_for_test(
1924 tmp.path(),
1925 agent_id,
1926 &messages,
1927 )
1928 .expect("write complete worker transcript");
1929 assert!(
1930 std::fs::metadata(artifact)
1931 .expect("artifact metadata")
1932 .len()
1933 > 1024 * 1024,
1934 "regression requires a transcript larger than the resident handle budget"
1935 );
1936
1937 let mut app = create_test_app();
1938 app.workspace = tmp.path().to_path_buf();
1939 {
1940 let mut store = app
1941 .runtime_services
1942 .handle_store
1943 .try_lock()
1944 .expect("handle store");
1945 let _ = store.insert_json(
1946 format!("agent:{agent_id}"),
1947 "full_transcript",
1948 json!({
1949 "kind": "subagent_full_transcript",
1950 "message_count": 2,
1951 "omitted_messages": 1,
1952 "messages_complete": false,
1953 "messages": [messages[1].clone()],
1954 }),
1955 );
1956 }
1957
1958 assert!(open_agent_chat_pager(&mut app, agent_id));
1959 let mut view = app.view_stack.pop().expect("agent chat pager");
1960 let pager = view
1961 .as_any_mut()
1962 .downcast_mut::<PagerView>()
1963 .expect("Open should push a pager");
1964 let body = pager.body_text();
1965 assert!(body.contains("EARLY-OPEN-MARKER"));
1966 assert!(body.contains("LAST-OPEN-MARKER"));
1967 assert!(
1968 !body.contains("Earlier messages were omitted"),
1969 "Open must use the complete artifact, not the compacted resident tail"
1970 );
1971 }
1972 }
1973
1973 lines RUST