返回 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 /// Two clicks closer than this are one double-click (composer word select,
16 /// and the context menu's guard against confirming a destructive row).
17 pub(crate) const DOUBLE_CLICK_MS: u64 = 400;
18 const COMPOSER_CLICK_SLOP_CELLS: u16 = 1;
19
20 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
21 pub(crate) enum ComposerClickGesture {
22 Caret,
23 Word,
24 Line,
25 }
26
27 fn classify_composer_click(
28 trace: &mut Option<ComposerClickTrace>,
29 column: u16,
30 row: u16,
31 ) -> ComposerClickGesture {
32 let at = Instant::now();
33 let next = match trace.as_ref() {
34 Some(prev)
35 if at.duration_since(prev.at).as_millis() <= u128::from(DOUBLE_CLICK_MS)
36 && prev.row.abs_diff(row) <= COMPOSER_CLICK_SLOP_CELLS
37 && prev.column.abs_diff(column) <= COMPOSER_CLICK_SLOP_CELLS =>
38 {
39 ComposerClickTrace {
40 at,
41 column,
42 row,
43 count: prev.count.saturating_add(1),
44 }
45 }
46 _ => ComposerClickTrace {
47 at,
48 column,
49 row,
50 count: 1,
51 },
52 };
53 let count = next.count;
54 *trace = Some(next);
55 match count {
56 2 => ComposerClickGesture::Word,
57 n if n >= 3 => ComposerClickGesture::Line,
58 _ => ComposerClickGesture::Caret,
59 }
60 }
61
62 /// Char-index bounds of the word (or CJK run) containing char `pos`.
63 ///
64 /// Takes and returns char indices, the unit of `App::cursor_position` and
65 /// `App::selection_anchor`, so a multi-byte composer never mixes the two.
66 fn composer_word_bounds(text: &str, pos: usize) -> (usize, usize) {
67 let chars: Vec<char> = text.chars().collect();
68 if chars.is_empty() {
69 return (0, 0);
70 }
71 let is_word = |ch: char| ch.is_alphanumeric() || (ch as u32) >= 0x80;
72 let idx = pos.min(chars.len() - 1);
73 if !is_word(chars[idx]) {
74 return (idx, idx + 1);
75 }
76 let mut start = idx;
77 while start > 0 && is_word(chars[start - 1]) {
78 start -= 1;
79 }
80 let mut end = idx + 1;
81 while end < chars.len() && is_word(chars[end]) {
82 end += 1;
83 }
84 (start, end)
85 }
86
87 /// Char-index bounds of the logical line containing char `pos` (excluding
88 /// the newline).
89 fn composer_line_bounds(text: &str, pos: usize) -> (usize, usize) {
90 let chars: Vec<char> = text.chars().collect();
91 let pos = pos.min(chars.len());
92 let start = chars[..pos]
93 .iter()
94 .rposition(|&ch| ch == '\n')
95 .map_or(0, |i| i + 1);
96 let end = chars[pos..]
97 .iter()
98 .position(|&ch| ch == '\n')
99 .map_or(chars.len(), |offset| pos + offset);
100 (start, end)
101 }
102
103 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
104 use ratatui::layout::Rect;
105
106 use crate::tui::app::{App, SidebarRowAction, StatusToastLevel};
107 use crate::tui::command_palette::{
108 CommandPaletteView, build_entries_with_plugins as build_command_palette_entries,
109 };
110 use crate::tui::context_menu::{ContextMenuEntry, ContextMenuView};
111 use crate::tui::scrolling::{ScrollDirection, TranscriptScroll};
112 use crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};
113 use crate::tui::tideline::InteractionAction;
114 use crate::tui::ui_text::{
115 history_cell_to_clipboard_text, history_cell_to_text, line_to_plain, slice_visible_columns,
116 text_display_width, text_visible_width, truncate_line_to_width,
117 };
118 use crate::tui::views::{ContextMenuAction, HelpView, ModalKind, ViewEvent};
119 use codewhale_localization::MessageId;
120 use codewhale_models::{ContentBlock, Message};
121
122 // These functions will need to be imported from ui.rs or we can just import crate::tui::ui::*.
123 use crate::tui::ui::{
124 copy_cell_to_clipboard, detail_target_label, open_context_inspector,
125 open_details_pager_for_cell, open_pager_for_selection,
126 };
127
128 const COMPOSER_MOUSE_SCROLL_LINES: usize = 3;
129
130 pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) -> bool {
131 if !app.is_loading {
132 return false;
133 }
134
135 match mouse.kind {
136 // v0.9.1: keep a cheap hover hit-test alive while streaming. Motion
137 // events are no longer dropped wholesale — the frame limiter bounds
138 // redraw cost. Only expensive transcript reflow stays deferred.
139 MouseEventKind::Moved => false,
140 MouseEventKind::Drag(_) => {
141 // Divider drags must stay live during active turns — dropping
142 // these events wedges the resize state mid-drag (#3063).
143 !app.viewport.transcript_selection.dragging
144 && !app.viewport.transcript_scrollbar_dragging
145 && !app.work_surface.is_resizing()
146 }
147 _ => false,
148 }
149 }
150
151 fn toggle_tool_run_expand(app: &mut App, mouse: MouseEvent) -> bool {
152 if !app.tool_collapse_active() {
153 return false;
154 }
155 let Some(rendered_idx) = transcript_cell_index_from_mouse(app, mouse) else {
156 return false;
157 };
158 let original_idx = app.original_cell_index_for_rendered(rendered_idx);
159 if app.tool_run_start_for_history_index(original_idx) != Some(original_idx) {
160 return false;
161 }
162 app.toggle_tool_run_expansion_at(original_idx)
163 }
164
165 /// Map a mouse (column, row) within the composer area to a char index
166 /// in the composer input string. Uses the canonical prompt-adjusted text rect
167 /// for coordinate mapping, and accounts for vertical padding and scroll offset.
168 fn mouse_pos_to_char_index(app: &App, col: u16, row: u16, text_area: Rect) -> Option<usize> {
169 Some(codewhale_ratatui::native_composer_source_at(
170 &app.input,
171 usize::from(text_area.width.max(1)),
172 usize::from(col.saturating_sub(text_area.x)),
173 usize::from(row.saturating_sub(text_area.y)),
174 app.viewport.last_composer_scroll_offset,
175 app.viewport.last_composer_top_padding,
176 ))
177 }
178
179 /// Wheel dispatch stays with the existing editor; row projection is shared
180 /// with painting and caret geometry, preserving its scalar-column policy.
181 fn move_composer_cursor_by_wrapped_rows(app: &mut App, text_area: Rect, rows: isize) -> bool {
182 let Some(cursor) = codewhale_ratatui::native_composer_step_row(
183 &app.input,
184 app.cursor_position,
185 usize::from(text_area.width.max(1)),
186 rows,
187 codewhale_ratatui::NativeComposerRowColumn::SourceScalars,
188 ) else {
189 return false;
190 };
191 app.clear_selection();
192 app.cursor_position = cursor;
193 app.needs_redraw = true;
194 true
195 }
196
197 /// A click on the workbar opens `/workflows`, the view that lists every run
198 /// with its agents and cancels one.
199 fn handle_workbar_mouse(app: &mut App, mouse: MouseEvent) -> bool {
200 if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
201 || !mouse_hits_rect(mouse, app.viewport.last_workbar_area)
202 || app.workflow_runs.is_empty()
203 {
204 return false;
205 }
206 crate::tui::views::workflows_manager::open(app);
207 true
208 }
209
210 fn handle_plugin_cta_mouse(app: &mut App, mouse: MouseEvent) -> Option<Vec<ViewEvent>> {
211 if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
212 return None;
213 }
214 if !mouse_hits_rect(mouse, app.viewport.last_plugin_cta_area) {
215 return None;
216 }
217 if mouse_hits_rect(mouse, app.viewport.last_plugin_cta_dismiss_area) {
218 // "Don't suggest again": the explicit, persisted dismissal.
219 let _ = app.dismiss_plugin_cta();
220 return Some(Vec::new());
221 }
222 // Only the labelled Review button opens the existing inventory. A click
223 // elsewhere is consumed; no suggested command or mutation is dispatched.
224 if mouse_hits_rect(mouse, app.viewport.last_plugin_cta_review_area)
225 && let Some(tab) = app.accept_plugin_cta_review()
226 && app.view_stack.top_kind() != Some(crate::tui::views::ModalKind::Extensions)
227 {
228 app.view_stack
229 .push(crate::tui::views::extensions::ExtensionsView::new(app, tab));
230 }
231 Some(Vec::new())
232 }
233
234 /// Slash-autocomplete rows painted inside the composer. Click selects
235 /// (second click on the same row applies, matching the command palette);
236 /// wheel moves the highlight. Returns true when the event was consumed so
237 /// the composer caret / draft-scroll path does not also handle it.
238 fn handle_slash_autocomplete_mouse(app: &mut App, mouse: MouseEvent) -> bool {
239 let hitboxes = app.viewport.last_slash_menu_hitboxes.borrow();
240 if hitboxes.is_empty() {
241 return false;
242 }
243 let over_row = hitboxes
244 .iter()
245 .find_map(|(idx, rect)| mouse_hits_rect(mouse, Some(*rect)).then_some(*idx));
246 let over_menu = over_row.is_some()
247 || hitboxes.iter().any(|(_, rect)| {
248 mouse.row >= rect.y
249 && mouse.row < rect.y.saturating_add(rect.height)
250 && mouse.column >= rect.x
251 && mouse.column < rect.x.saturating_add(rect.width)
252 });
253 // Wheel over any painted slash row moves selection (mouse == keys).
254 // Clicks only fire when the pointer is on a row rect.
255 match mouse.kind {
256 MouseEventKind::ScrollUp if over_menu => {
257 drop(hitboxes);
258 let entries = crate::tui::slash_menu::visible_slash_menu_entries(app, 128);
259 if entries.is_empty() {
260 return false;
261 }
262 crate::tui::composer_ui::select_previous_slash_menu_entry(app, entries.len());
263 app.needs_redraw = true;
264 true
265 }
266 MouseEventKind::ScrollDown if over_menu => {
267 drop(hitboxes);
268 let entries = crate::tui::slash_menu::visible_slash_menu_entries(app, 128);
269 if entries.is_empty() {
270 return false;
271 }
272 crate::tui::composer_ui::select_next_slash_menu_entry(app, entries.len());
273 app.needs_redraw = true;
274 true
275 }
276 MouseEventKind::Down(MouseButton::Left) => {
277 let Some(idx) = over_row else {
278 return false;
279 };
280 drop(hitboxes);
281 let entries = crate::tui::slash_menu::visible_slash_menu_entries(app, 128);
282 if entries.is_empty() || idx >= entries.len() {
283 return false;
284 }
285 // Same as command palette: click the highlighted row to apply;
286 // click another row to move the highlight (mouse == keys).
287 if app.slash_menu_selected == idx {
288 let _ = crate::tui::slash_menu::apply_slash_menu_selection(app, &entries, true);
289 } else {
290 app.slash_menu_selected = idx;
291 app.slash_menu_hidden = false;
292 }
293 app.needs_redraw = true;
294 true
295 }
296 _ => false,
297 }
298 }
299
300 /// Handle mouse events within the composer area.
301 /// Returns true if the event was consumed.
302 pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool {
303 if !app.view_stack.is_empty() {
304 return false;
305 }
306 // A transcript selection or scrollbar drag that ends over the composer
307 // belongs to the surface that started it: the transcript handler must
308 // still see the release to clear its drag state and publish the text.
309 if matches!(
310 mouse.kind,
311 MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
312 ) && (app.viewport.transcript_selection.dragging
313 || app.viewport.transcript_scrollbar_dragging)
314 {
315 return false;
316 }
317 // Use outer area for hit-testing (includes border).
318 let Some(area) = app.viewport.last_composer_area else {
319 return false;
320 };
321 if mouse.column < area.x
322 || mouse.column >= area.x + area.width
323 || mouse.row < area.y
324 || mouse.row >= area.y + area.height
325 {
326 return false;
327 }
328 // Slash autocomplete owns its painted rows before caret placement or
329 // draft scroll — otherwise a click on `/model` would only move the caret.
330 if handle_slash_autocomplete_mouse(app, mouse) {
331 return true;
332 }
333 // Resolve the border- and submit-aware input plane through the same
334 // persistent prompt geometry used by rendering, cursor placement, and
335 // viewport bookkeeping. The frame records it after reserving `[↵]`.
336 let input_plane = app.viewport.last_composer_content.unwrap_or(area);
337 let text_area =
338 crate::tui::widgets::composer_content_geometry(input_plane, app.is_history_search_active())
339 .text_area;
340
341 match mouse.kind {
342 // Only claim the wheel while the caret still has somewhere to go. At
343 // the top or bottom of the draft — or with no wrapped draft at all —
344 // fall through so the transcript scrolls instead of the event being
345 // silently swallowed by the composer rect (#5223).
346 MouseEventKind::ScrollUp => move_composer_cursor_by_wrapped_rows(
347 app,
348 text_area,
349 -(COMPOSER_MOUSE_SCROLL_LINES as isize),
350 ),
351 MouseEventKind::ScrollDown => move_composer_cursor_by_wrapped_rows(
352 app,
353 text_area,
354 COMPOSER_MOUSE_SCROLL_LINES as isize,
355 ),
356 MouseEventKind::Down(MouseButton::Left) => {
357 clear_transcript_selection(app);
358 crate::tui::work_surface::release_focus(app);
359 if let Some(submit) = crate::tui::widgets::active_composer_submit_rect(app, area)
360 && mouse_hits_rect(mouse, Some(submit))
361 {
362 // Same chord the keyboard Enter path uses. Empty / paste-burst
363 // clicks are consumed so they cannot also move the caret.
364 let action =
365 app.decide_composer_submit(crate::tui::app::ComposerSubmitChord::Enter);
366 if !matches!(action, crate::tui::app::ComposerSubmitAction::Noop)
367 && (app.composer_enter_would_submit()
368 || matches!(action, crate::tui::app::ComposerSubmitAction::SendQueuedNow))
369 {
370 app.pending_composer_submit = Some(crate::tui::app::ComposerSubmitChord::Enter);
371 }
372 app.needs_redraw = true;
373 return true;
374 }
375 if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, text_area) {
376 match classify_composer_click(
377 &mut app.viewport.composer_click_trace,
378 mouse.column,
379 mouse.row,
380 ) {
381 ComposerClickGesture::Word => {
382 let (start, end) = composer_word_bounds(&app.input, pos);
383 app.selection_anchor = Some(start);
384 app.cursor_position = end;
385 }
386 ComposerClickGesture::Line => {
387 let (start, end) = composer_line_bounds(&app.input, pos);
388 app.selection_anchor = Some(start);
389 app.cursor_position = end;
390 }
391 ComposerClickGesture::Caret => {
392 app.cursor_position = pos;
393 app.selection_anchor = None;
394 }
395 }
396 app.needs_redraw = true;
397 }
398 true
399 }
400 MouseEventKind::Drag(MouseButton::Left) => {
401 if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, text_area) {
402 if app.selection_anchor.is_none() {
403 app.selection_anchor = Some(app.cursor_position);
404 }
405 app.cursor_position = pos;
406 app.needs_redraw = true;
407 }
408 true
409 }
410 MouseEventKind::Up(MouseButton::Left) => {
411 if app.selection_anchor == Some(app.cursor_position) {
412 app.selection_anchor = None;
413 }
414 true
415 }
416 MouseEventKind::Down(MouseButton::Middle) if app.clipboard.uses_primary_selection() => {
417 if let Some(text) = app.clipboard.read_primary_text() {
418 // Flush already-typed bytes at their original caret first.
419 app.insert_paste_text("");
420 let Some(position) =
421 mouse_pos_to_char_index(app, mouse.column, mouse.row, text_area)
422 else {
423 return true;
424 };
425 // PRIMARY often contains this very selection. Insert at the
426 // pointer, preserving the selected original rather than cutting it.
427 app.selection_anchor = None;
428 app.cursor_position = position;
429 crate::tui::work_surface::release_focus(app);
430 app.insert_paste_text(&text);
431 }
432 true
433 }
434 _ => false,
435 }
436 }
437
438 pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEvent> {
439 if app.view_stack.top_kind() == Some(ModalKind::ContextMenu) {
440 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
441 app.view_stack.pop();
442 // A menu opened over a modal (the Extensions row menu) hands the
443 // new right-click back to that modal, not to the transcript.
444 if !app.view_stack.is_empty() {
445 app.needs_redraw = true;
446 return app.view_stack.handle_mouse(mouse);
447 }
448 open_context_menu(app, mouse);
449 return Vec::new();
450 }
451 return app.view_stack.handle_mouse(mouse);
452 }
453
454 // Decision prompts leave transcript evidence visible above them. A question
455 // sheet owns the wheel over its content; approval cards retain their existing
456 // transcript-scroll behavior. Visible side surfaces keep their ownership.
457 // Other modals still own wheel input exclusively (#4371, #6045).
458 if matches!(
459 app.view_stack.top_kind(),
460 Some(ModalKind::Approval | ModalKind::UserInput)
461 ) {
462 let over_prompt = mouse_hits_rect(mouse, app.viewport.last_prompt_area);
463 let over_side_surface = mouse_hits_rect(mouse, app.work_surface.last_area);
464 let direction = match mouse.kind {
465 MouseEventKind::ScrollUp => Some(ScrollDirection::Up),
466 MouseEventKind::ScrollDown => Some(ScrollDirection::Down),
467 _ => None,
468 };
469 if let Some(direction) = direction {
470 if over_prompt && app.view_stack.top_kind() == Some(ModalKind::UserInput) {
471 app.needs_redraw = true;
472 return app.view_stack.handle_mouse(mouse);
473 }
474 if over_prompt || !over_side_surface {
475 scroll_transcript_with_mouse(app, direction);
476 }
477 return Vec::new();
478 }
479 }
480
481 if !app.view_stack.is_empty() {
482 app.needs_redraw = true;
483 return app.view_stack.handle_mouse(mouse);
484 }
485
486 // A drag can finish outside the composer/transcript that started it.
487 // Publish once before other visible surfaces consume the release event.
488 if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left))
489 && app.clipboard.uses_primary_selection()
490 {
491 let text = if app.viewport.transcript_selection.dragging {
492 selection_to_text(app).unwrap_or_default()
493 } else {
494 app.selected_text()
495 };
496 let _ = app.clipboard.write_primary_text(&text);
497 }
498
499 // Topbar facts are typed controls, not decorative text. Route this before
500 // either launch or session content so a segment painted in the one shared
501 // header has identical mouse behavior in both shell states.
502 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
503 let action = app
504 .viewport
505 .interaction_targets
506 .target_at(mouse.column, mouse.row)
507 .and_then(|target| target.mouse_action);
508 if let Some(action) = action
509 && !matches!(
510 action,
511 InteractionAction::ShowDockPanel(_) | InteractionAction::DismissDock
512 )
513 {
514 app.needs_redraw = true;
515 return match action {
516 InteractionAction::InspectContext => {
517 open_context_inspector(app);
518 Vec::new()
519 }
520 InteractionAction::OpenProviderPicker => {
521 vec![ViewEvent::TopbarRoutePickerRequested]
522 }
523 InteractionAction::OpenAutomations => apply_sidebar_row_action(
524 app,
525 SidebarRowAction::Command("/automation".to_string()),
526 ),
527 InteractionAction::OpenModelPicker => {
528 vec![ViewEvent::TopbarModelPickerRequested]
529 }
530 InteractionAction::ShowDockPanel(_) | InteractionAction::DismissDock => {
531 unreachable!("dock targets defer to the strip")
532 }
533 };
534 }
535 }
536
537 // The launch card is content on the ordinary screen, not a surface that
538 // owns the frame. It used to consume every mouse event and return, which
539 // was right when it *was* a separate surface and became a bug the moment
540 // it stopped being one: scrolling, the real composer, the work surface
541 // and every other target were unreachable while it was up, and the
542 // send-glyph branch pointed at a `send_area` the deleted launch composer
543 // used to set. So the card takes its own rows and lets everything else
544 // fall through to the handlers that own it.
545 if app.launch.visible && !app.launch.row_hitboxes.is_empty() {
546 let hit = app
547 .launch
548 .row_hitboxes
549 .iter()
550 .position(|(_, area)| mouse_hits_rect(mouse, Some(*area)));
551 match mouse.kind {
552 MouseEventKind::Moved => {
553 if hit != app.launch.hovered_row {
554 app.launch.hovered_row = hit;
555 app.needs_redraw = true;
556 }
557 }
558 MouseEventKind::Down(MouseButton::Left) => {
559 if let Some(index) = hit {
560 let id = app.launch.row_hitboxes[index].0.clone();
561 match &id {
562 // Resuming replaces the whole session context —
563 // founder live-test: "you just click it and boom
564 // you're there ... you don't realize it's happening".
565 // It asks first. New session and See all stay one
566 // click, because neither discards anything.
567 crate::tui::app::LaunchRowId::Recent(session_id) => {
568 app.launch.menu_selected = Some(index);
569 crate::tui::underwater::open_launch_resume_confirm(app, session_id);
570 }
571 _ => {
572 app.launch.status = None;
573 app.pending_launch_action =
574 Some(crate::tui::underwater::launch_row_click_action(&id));
575 }
576 }
577 app.needs_redraw = true;
578 return Vec::new();
579 }
580 }
581 _ => {}
582 }
583 }
584
585 // Ocean work surface owns its rect, scrolling, focus, and row actions.
586 // Route it before workflow/composer/transcript so wheel events never leak
587 // into an unrelated viewport.
588 let work_surface = crate::tui::work_surface::handle_mouse(app, mouse);
589 if let Some(action) = work_surface.action {
590 return apply_sidebar_row_action(app, action);
591 }
592 if work_surface.consumed {
593 return Vec::new();
594 }
595 // The posture bar's live counts open the dock view they count. The
596 // strip's own tabs were consumed above; anything left carrying a dock
597 // action is a footer chip.
598 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
599 && let Some(InteractionAction::ShowDockPanel(panel)) = app
600 .viewport
601 .interaction_targets
602 .target_at(mouse.column, mouse.row)
603 .and_then(|target| target.mouse_action)
604 {
605 crate::tui::work_surface::select_dock_panel(app, panel);
606 // Clicking the affordance teaches it just as well as the chord does.
607 app.note_footer_hint_used(crate::tui::footer_hints::DOCK_OPEN);
608 return Vec::new();
609 }
610
611 if handle_workbar_mouse(app, mouse) {
612 return Vec::new();
613 }
614
615 if let Some(events) = handle_plugin_cta_mouse(app, mouse) {
616 return events;
617 }
618
619 // Composer mouse events take priority over transcript.
620 if handle_composer_mouse(app, mouse) {
621 return Vec::new();
622 }
623
624 match mouse.kind {
625 MouseEventKind::Moved => {
626 // Update last mouse position for tooltip rendering + hover layer.
627 app.last_mouse_pos = Some((mouse.column, mouse.row));
628 let previous_hover = crate::tui::hover_layer::current_hover();
629 crate::tui::hover_layer::set_pointer(mouse.column, mouse.row);
630 crate::tui::hover_layer::resolve_hover();
631 if crate::tui::hover_layer::current_hover() != previous_hover {
632 app.needs_redraw = true;
633 }
634
635 // Check sidebar sections for hover popovers. Only surface a
636 // popover when the hovered row lost information in the compact
637 // sidebar view.
638 let mut found = false;
639 for section in &app.sidebar_hover.sections {
640 if mouse.column >= section.content_area.x
641 && mouse.column
642 < section
643 .content_area
644 .x
645 .saturating_add(section.content_area.width)
646 && mouse.row >= section.content_area.y
647 && mouse.row
648 < section
649 .content_area
650 .y
651 .saturating_add(section.content_area.height)
652 {
653 if let Some(row) = section.rows.iter().find(|row| row.row_y == mouse.row) {
654 let desired = row.is_truncated.then(|| {
655 if let Some(detail) = row.detail.as_deref()
656 && !detail.trim().is_empty()
657 {
658 format!("{}\n{detail}", row.full_text)
659 } else {
660 row.full_text.clone()
661 }
662 });
663 if app.sidebar_hover_tooltip != desired {
664 app.sidebar_hover_tooltip = desired;
665 app.needs_redraw = true;
666 }
667 found = true;
668 break;
669 } else if section.rows.is_empty() {
670 let line_idx = (mouse.row.saturating_sub(section.content_area.y)) as usize;
671 if let Some(full) = section.lines.get(line_idx) {
672 let truncated =
673 text_display_width(full) > section.content_area.width as usize;
674 let desired = truncated.then(|| full.clone());
675 if app.sidebar_hover_tooltip != desired {
676 app.sidebar_hover_tooltip = desired;
677 app.needs_redraw = true;
678 }
679 found = true;
680 break;
681 }
682 }
683 }
684 }
685 if !found && app.sidebar_hover_tooltip.is_some() {
686 app.sidebar_hover_tooltip = None;
687 app.needs_redraw = true;
688 }
689 }
690 MouseEventKind::ScrollUp => {
691 scroll_transcript_with_mouse(app, ScrollDirection::Up);
692 }
693 MouseEventKind::ScrollDown => {
694 scroll_transcript_with_mouse(app, ScrollDirection::Down);
695 }
696 MouseEventKind::Down(MouseButton::Left) => {
697 app.viewport.transcript_scrollbar_dragging = false;
698 app.viewport.selection_autoscroll = None;
699
700 // #3028/#4009: Check sidebar hover state for clickable rows before
701 // falling through to transcript selection. Command rows still use
702 // the command-palette pipeline; agent rows are direct UI actions.
703 if let Some(action) = sidebar_click_action(app, mouse) {
704 return apply_sidebar_row_action(app, action);
705 }
706
707 // Click on the transcript scrollbar gutter starts a scrollbar
708 // drag so the visible thumb remains interactive for users who
709 // prefer mouse-based navigation.
710 if mouse_hits_transcript_scrollbar(app, mouse) {
711 app.viewport.transcript_scrollbar_dragging = true;
712 return Vec::new();
713 }
714
715 if mouse_hits_rect(mouse, app.viewport.jump_to_latest_button_area) {
716 app.scroll_to_bottom();
717 return Vec::new();
718 }
719
720 // The pinned prompt header names the user message scrolled just
721 // above the viewport; a click returns to it. Resolve the message
722 // against the current layout so a rewrite between paint and click
723 // cannot jump to a stale line offset.
724 if mouse_hits_rect(mouse, app.viewport.pinned_prompt_area) {
725 if let Some(line) = app.pinned_prompt_target_line() {
726 app.scroll_to_transcript_line(line);
727 }
728 return Vec::new();
729 }
730
731 if toggle_tool_run_expand(app, mouse) {
732 return Vec::new();
733 }
734
735 if let Some(point) = selection_point_from_mouse(app, mouse) {
736 app.viewport.transcript_selection.anchor = Some(point);
737 app.viewport.transcript_selection.head = Some(point);
738 app.viewport.transcript_selection.dragging = true;
739 app.needs_redraw = true;
740
741 if app.is_loading
742 && app.viewport.transcript_scroll.is_at_tail()
743 && let Some(anchor) = TranscriptScroll::anchor_for(
744 app.viewport.transcript_cache.line_meta(),
745 app.viewport.last_transcript_top,
746 )
747 {
748 app.viewport.transcript_scroll = anchor;
749 }
750 } else {
751 clear_transcript_selection(app);
752 }
753 }
754 MouseEventKind::Drag(MouseButton::Left) => {
755 if app.viewport.transcript_scrollbar_dragging {
756 scroll_transcript_to_mouse_row(app, mouse.row);
757 return Vec::new();
758 }
759
760 if app.viewport.transcript_selection.dragging {
761 update_selection_drag(app, mouse);
762 }
763 }
764 MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_scrollbar_dragging => {
765 app.viewport.transcript_scrollbar_dragging = false;
766 app.viewport.selection_autoscroll = None;
767 app.needs_redraw = true;
768 }
769 MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_selection.dragging => {
770 app.viewport.transcript_selection.dragging = false;
771 app.viewport.selection_autoscroll = None;
772 if selection_has_content(app) && !app.clipboard.uses_primary_selection() {
773 copy_active_selection(app);
774 }
775 }
776 MouseEventKind::Down(MouseButton::Right) => {
777 open_context_menu(app, mouse);
778 }
779 _ => {}
780 }
781
782 Vec::new()
783 }
784
785 fn scroll_transcript_with_mouse(app: &mut App, direction: ScrollDirection) {
786 let update = app.viewport.mouse_scroll.on_scroll(direction);
787 app.viewport.pending_scroll_delta = app
788 .viewport
789 .pending_scroll_delta
790 .saturating_add(update.delta_lines);
791 if update.delta_lines != 0 {
792 app.user_scrolled_during_stream = true;
793 app.needs_redraw = true;
794 }
795 }
796
797 /// Resolve a right-click in the sidebar to the hovered row's full copyable
798 /// text: the row's untruncated text plus its hover detail when present.
799 fn sidebar_row_copy_text(app: &App, mouse: MouseEvent) -> Option<String> {
800 for section in &app.sidebar_hover.sections {
801 if !mouse_hits_rect(mouse, Some(section.content_area)) {
802 continue;
803 }
804 if let Some(row) = section.rows.iter().find(|row| row.row_y == mouse.row) {
805 let mut text = row.full_text.clone();
806 if let Some(detail) = row.detail.as_deref()
807 && !detail.trim().is_empty()
808 {
809 text.push('\n');
810 text.push_str(detail);
811 }
812 return Some(text).filter(|text| !text.trim().is_empty());
813 }
814 let line_idx = (mouse.row.saturating_sub(section.content_area.y)) as usize;
815 if let Some(full) = section.lines.get(line_idx) {
816 return Some(full.clone()).filter(|text| !text.trim().is_empty());
817 }
818 }
819 None
820 }
821
822 fn first_line(text: &str) -> &str {
823 text.lines().next().unwrap_or(text)
824 }
825
826 /// Resolve a left-click in the sidebar to a typed row action, if the clicked
827 /// row has a click action assigned (#3028, #4009).
828 fn sidebar_click_action(app: &App, mouse: MouseEvent) -> Option<SidebarRowAction> {
829 let row = sidebar_row_at(app, mouse)?;
830 if let (Some(action), Some(start), Some(end)) = (
831 row.stop_action.as_ref(),
832 row.stop_zone_start_col,
833 row.stop_zone_end_col,
834 ) && mouse.column >= start
835 && mouse.column < end
836 {
837 return Some(action.clone());
838 }
839 row.click_action.clone()
840 }
841
842 /// The work-surface row under the pointer, if any.
843 fn sidebar_row_at(app: &App, mouse: MouseEvent) -> Option<&crate::tui::app::SidebarHoverRow> {
844 for section in &app.sidebar_hover.sections {
845 if mouse.column >= section.content_area.x
846 && mouse.column
847 < section
848 .content_area
849 .x
850 .saturating_add(section.content_area.width)
851 && mouse.row >= section.content_area.y
852 && mouse.row
853 < section
854 .content_area
855 .y
856 .saturating_add(section.content_area.height)
857 && let Some(row) = section.rows.iter().find(|row| row.row_y == mouse.row)
858 {
859 return Some(row);
860 }
861 }
862 None
863 }
864
865 pub(crate) fn apply_sidebar_row_action(app: &mut App, action: SidebarRowAction) -> Vec<ViewEvent> {
866 match action {
867 SidebarRowAction::Command(command) => {
868 use crate::tui::views::CommandPaletteAction;
869 vec![ViewEvent::CommandPaletteSelected {
870 action: CommandPaletteAction::ExecuteCommand { command },
871 }]
872 }
873 SidebarRowAction::PrefillCommand(command) => {
874 app.input = command;
875 app.cursor_position = app.input.len();
876 app.status_message = Some(app.tr(MessageId::SidebarDestructiveArmed).into_owned());
877 app.needs_redraw = true;
878 Vec::new()
879 }
880 SidebarRowAction::ShowSubagentsPanel => {
881 use crate::tui::work_surface::RailPanel;
882 // The register header is a two-way door: opening the Agents panel
883 // from anywhere, and returning to Tasks when it is already open,
884 // so the to-do list is never one click away with no way back.
885 let target = match app.work_surface.panel {
886 RailPanel::Agents => RailPanel::Tasks,
887 _ => RailPanel::Agents,
888 };
889 crate::tui::work_surface::select_dock_panel(app, target);
890 app.status_message = Some(
891 match target {
892 RailPanel::Agents => "Showing subagents",
893 _ => "Showing tasks",
894 }
895 .to_string(),
896 );
897 app.needs_redraw = true;
898 Vec::new()
899 }
900 SidebarRowAction::OpenAgentDetail { agent_id } => {
901 if !crate::tui::agent_details::open_agent_details(app, &agent_id) {
902 crate::tui::work_surface::agent_details_closed(app, &agent_id);
903 app.status_message = Some("Agent details are unavailable".to_string());
904 }
905 app.needs_redraw = true;
906 Vec::new()
907 }
908 SidebarRowAction::OpenAgentTranscript { agent_id } => {
909 // The primary agent destination: focus the worker in place. The
910 // focused view explains a missing capture instead of dead-ending.
911 crate::tui::agent_focus::focus_agent(app, &agent_id);
912 app.needs_redraw = true;
913 Vec::new()
914 }
915 SidebarRowAction::CancelAgent { agent_id } => {
916 vec![ViewEvent::SidebarAgentCancel { agent_id }]
917 }
918 SidebarRowAction::InspectWork {
919 title,
920 body,
921 stop_action,
922 } => {
923 let width = app
924 .viewport
925 .last_transcript_area
926 .map(|area| area.width)
927 .unwrap_or(80);
928 let mut pager =
929 crate::tui::pager::PagerView::from_text(title, &body, width.saturating_sub(2))
930 .with_copy_text(body);
931 let stop_event = stop_action.and_then(|action| match *action {
932 SidebarRowAction::Command(command) => {
933 use crate::tui::views::CommandPaletteAction;
934 Some(ViewEvent::CommandPaletteSelected {
935 action: CommandPaletteAction::ExecuteCommand { command },
936 })
937 }
938 SidebarRowAction::CancelAgent { agent_id } => {
939 Some(ViewEvent::SidebarAgentCancel { agent_id })
940 }
941 _ => None,
942 });
943 if let Some(event) = stop_event {
944 pager = pager.with_destructive_action(
945 's',
946 app.tr(MessageId::SidebarStopControl),
947 app.tr(MessageId::WorkSurfaceStopConfirmHint),
948 event,
949 );
950 }
951 app.view_stack.push(pager);
952 app.needs_redraw = true;
953 Vec::new()
954 }
955 }
956 }
957
958 pub(crate) fn resolve_agent_transcript_text(app: &App, agent_id: &str) -> Option<String> {
959 use crate::tools::handle::{HandleValue, VarHandle};
960
961 let lookup = VarHandle {
962 kind: "var_handle".to_string(),
963 session_id: format!("agent:{agent_id}"),
964 name: "full_transcript".to_string(),
965 type_name: String::new(),
966 length: 0,
967 repr_preview: String::new(),
968 sha256: String::new(),
969 };
970 let payload = match app.runtime_services.handle_store.try_lock() {
971 Ok(store) => match store.get(&lookup) {
972 Some(record) => match &record.value {
973 HandleValue::Json(value) => Some(value.clone()),
974 HandleValue::Text(_) => None,
975 },
976 None => None,
977 },
978 Err(_) => return None,
979 };
980
981 // The handle is a deliberately bounded live projection. Prefer the private
982 // on-disk message stream so Open means the entire chat, including early
983 // turns that no longer fit in the 1 MiB resident tail. While the worker is
984 // live, require its artifact count to match the latest handle count; a
985 // failed/stale append must fall back to the explicit omission banner. With
986 // no process-local handle (for example after restart), the validated
987 // artifact remains the durable source of truth.
988 if let Ok(messages) =
989 crate::tools::subagent::load_subagent_transcript_artifact(&app.workspace, agent_id)
990 {
991 let matches_resident_count = payload.as_ref().is_none_or(|resident| {
992 resident
993 .get("message_count")
994 .and_then(serde_json::Value::as_u64)
995 .and_then(|count| usize::try_from(count).ok())
996 == Some(messages.len())
997 && resident
998 .get("complete_transcript_artifact")
999 .and_then(|artifact| artifact.get("complete"))
1000 .and_then(serde_json::Value::as_bool)
1001 .unwrap_or(true)
1002 });
1003 if matches_resident_count {
1004 let text = agent_messages_text(&messages);
1005 if !text.trim().is_empty() {
1006 return Some(text);
1007 }
1008 }
1009 }
1010
1011 let payload = payload?;
1012 let text = agent_transcript_text(&payload);
1013 if text.trim().is_empty() {
1014 return None;
1015 }
1016 Some(text)
1017 }
1018
1019 pub(crate) fn agent_transcript_evidence_available(app: &App, agent_id: &str) -> bool {
1020 resolve_agent_transcript_text(app, agent_id).is_some()
1021 }
1022
1023 /// Turn the agent transcript handle into a readable conversation. The worker
1024 /// may retain tool calls and results, but private model thinking never appears
1025 /// here; the parent transcript has the same default privacy behavior.
1026 fn agent_transcript_text(payload: &serde_json::Value) -> String {
1027 let Some(messages) = payload
1028 .get("messages")
1029 .and_then(serde_json::Value::as_array)
1030 else {
1031 return String::new();
1032 };
1033
1034 let omitted = payload
1035 .get("omitted_messages")
1036 .and_then(serde_json::Value::as_u64)
1037 .unwrap_or_default();
1038 let total = payload
1039 .get("message_count")
1040 .and_then(serde_json::Value::as_u64)
1041 .unwrap_or(messages.len() as u64);
1042 let mut text = String::new();
1043 if omitted > 0 {
1044 text.push_str(&format!(
1045 "Showing the latest {} of {total} worker messages. Earlier messages were omitted from the in-memory transcript.\n\n",
1046 messages.len()
1047 ));
1048 }
1049
1050 let parsed: Vec<Message> = messages
1051 .iter()
1052 .filter_map(|raw| serde_json::from_value::<Message>(raw.clone()).ok())
1053 .collect();
1054 text.push_str(&agent_messages_text(&parsed));
1055 text
1056 }
1057
1058 fn agent_messages_text(messages: &[Message]) -> String {
1059 let mut text = String::new();
1060 for message in messages {
1061 let body = agent_message_text(message);
1062 if body.trim().is_empty() {
1063 continue;
1064 }
1065 text.push_str(&format!("── {} ──\n{body}\n\n", message.role));
1066 }
1067 text
1068 }
1069
1070 fn agent_message_text(message: &Message) -> String {
1071 let mut text = String::new();
1072 for block in &message.content {
1073 match block {
1074 ContentBlock::Text { text: body, .. } => {
1075 if !body.trim().is_empty() {
1076 text.push_str(body);
1077 text.push('\n');
1078 }
1079 }
1080 ContentBlock::ToolUse { name, input, .. }
1081 | ContentBlock::ServerToolUse { name, input, .. } => {
1082 text.push_str(&format!(
1083 "→ {name}\n{}\n",
1084 serde_json::to_string_pretty(input).unwrap_or_else(|_| input.to_string())
1085 ));
1086 }
1087 ContentBlock::ToolResult {
1088 tool_use_id,
1089 content,
1090 is_error,
1091 ..
1092 } => {
1093 let label = if is_error.unwrap_or(false) {
1094 "← tool error"
1095 } else {
1096 "← tool result"
1097 };
1098 text.push_str(&format!("{label} ({tool_use_id})\n{content}\n"));
1099 }
1100 ContentBlock::ImageUrl { image_url } => {
1101 text.push_str(&format!("[image: {}]\n", image_url.url));
1102 }
1103 // Thinking blocks are deliberately not surfaced in the main TUI
1104 // and should not leak through a worker detail view either.
1105 ContentBlock::Thinking { .. } => {}
1106 other => {
1107 text.push_str(&format!(
1108 "{}\n",
1109 serde_json::to_string_pretty(other).unwrap_or_else(|_| "[worker event]".into())
1110 ));
1111 }
1112 }
1113 }
1114 text.trim_end().to_string()
1115 }
1116
1117 pub(crate) fn mouse_hits_transcript_scrollbar(app: &App, mouse: MouseEvent) -> bool {
1118 let Some(area) = app.viewport.last_transcript_area else {
1119 return false;
1120 };
1121 if area.width <= 1 || app.viewport.last_transcript_total <= app.viewport.last_transcript_visible
1122 {
1123 return false;
1124 }
1125
1126 let scrollbar_col = area.x.saturating_add(area.width.saturating_sub(1));
1127 mouse.column == scrollbar_col
1128 && mouse.row >= area.y
1129 && mouse.row < area.y.saturating_add(area.height)
1130 }
1131
1132 pub(crate) fn scroll_transcript_to_mouse_row(app: &mut App, row: u16) -> bool {
1133 let Some(area) = app.viewport.last_transcript_area else {
1134 return false;
1135 };
1136 let total = app.viewport.last_transcript_total;
1137 let visible = app.viewport.last_transcript_visible;
1138 if area.height == 0 || total <= visible {
1139 return false;
1140 }
1141
1142 let max_start = total.saturating_sub(visible);
1143 if max_start == 0 {
1144 app.scroll_to_bottom();
1145 return true;
1146 }
1147
1148 let max_row = usize::from(area.height.saturating_sub(1));
1149 let relative_row = usize::from(row.saturating_sub(area.y)).min(max_row);
1150 let numerator = relative_row
1151 .saturating_mul(max_start)
1152 .saturating_add(max_row / 2);
1153 // Round to the nearest transcript offset so short thumbs still feel
1154 // responsive on compact terminals.
1155 let top = numerator.checked_div(max_row).unwrap_or(0);
1156
1157 app.viewport.transcript_scroll = if top >= max_start {
1158 TranscriptScroll::to_bottom()
1159 } else {
1160 TranscriptScroll::at_line(top)
1161 };
1162 app.viewport.pending_scroll_delta = 0;
1163 app.user_scrolled_during_stream = !app.viewport.transcript_scroll.is_at_tail();
1164 app.needs_redraw = true;
1165 true
1166 }
1167
1168 /// Cadence between auto-scroll ticks while drag-selecting past the
1169 /// transcript edge (#1163). 30 ms ≈ 33 lines/sec, comparable to the feel
1170 /// of a steady scroll-wheel drag.
1171 const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30);
1172
1173 /// Update the transcript selection while the left button is dragging.
1174 /// When the mouse leaves the transcript rect vertically, arm
1175 /// `selection_autoscroll` so the main loop can advance the viewport on a
1176 /// fixed cadence; when the mouse returns inside, disarm it.
1177 pub(crate) fn update_selection_drag(app: &mut App, mouse: MouseEvent) {
1178 if let Some(point) = selection_point_from_mouse(app, mouse) {
1179 app.viewport.transcript_selection.head = Some(point);
1180 app.viewport.selection_autoscroll = None;
1181 app.needs_redraw = true;
1182 return;
1183 }
1184
1185 let Some(area) = app.viewport.last_transcript_area else {
1186 return;
1187 };
1188 if area.height == 0 || area.width == 0 {
1189 return;
1190 }
1191
1192 let direction = if mouse.row < area.y {
1193 -1
1194 } else if mouse.row >= area.y.saturating_add(area.height) {
1195 1
1196 } else {
1197 // Outside horizontally only — leave selection head where it is.
1198 return;
1199 };
1200
1201 let max_col = area.x.saturating_add(area.width.saturating_sub(1));
1202 let column = mouse.column.clamp(area.x, max_col);
1203
1204 // Fire on the next tick immediately by setting `next_tick` to now.
1205 app.viewport.selection_autoscroll = Some(SelectionAutoscroll {
1206 direction,
1207 column,
1208 next_tick: Instant::now(),
1209 });
1210 app.needs_redraw = true;
1211 }
1212
1213 /// Advance the drag-edge auto-scroll one step if its cadence has elapsed.
1214 /// Called once per main-loop iteration.
1215 pub(crate) fn tick_selection_autoscroll(app: &mut App) {
1216 let Some(state) = app.viewport.selection_autoscroll else {
1217 return;
1218 };
1219
1220 if !app.viewport.transcript_selection.dragging {
1221 app.viewport.selection_autoscroll = None;
1222 return;
1223 }
1224
1225 let Some(area) = app.viewport.last_transcript_area else {
1226 return;
1227 };
1228 if area.height == 0 {
1229 return;
1230 }
1231
1232 let now = Instant::now();
1233 if now < state.next_tick {
1234 return;
1235 }
1236
1237 app.viewport.pending_scroll_delta = app
1238 .viewport
1239 .pending_scroll_delta
1240 .saturating_add(state.direction);
1241 app.user_scrolled_during_stream = true;
1242
1243 let edge_row = if state.direction < 0 {
1244 area.y
1245 } else {
1246 area.y.saturating_add(area.height.saturating_sub(1))
1247 };
1248 if let Some(point) = selection_point_from_position(
1249 area,
1250 state.column,
1251 edge_row,
1252 app.viewport.last_transcript_top,
1253 app.viewport.last_transcript_total,
1254 app.viewport.last_transcript_padding_top,
1255 ) {
1256 app.viewport.transcript_selection.head = Some(point);
1257 }
1258
1259 app.viewport.selection_autoscroll = Some(SelectionAutoscroll {
1260 next_tick: now + SELECTION_AUTOSCROLL_INTERVAL,
1261 ..state
1262 });
1263 app.needs_redraw = true;
1264 }
1265
1266 pub(crate) fn mouse_hits_rect(mouse: MouseEvent, area: Option<Rect>) -> bool {
1267 point_hits_rect(mouse.column, mouse.row, area)
1268 }
1269
1270 fn point_hits_rect(column: u16, row: u16, area: Option<Rect>) -> bool {
1271 let Some(area) = area else {
1272 return false;
1273 };
1274
1275 column >= area.x
1276 && column < area.x.saturating_add(area.width)
1277 && row >= area.y
1278 && row < area.y.saturating_add(area.height)
1279 }
1280
1281 pub(crate) fn open_context_menu(app: &mut App, mouse: MouseEvent) {
1282 let entries = build_context_menu_entries(app, mouse);
1283 if entries.is_empty() {
1284 return;
1285 }
1286 let title = app.tr(MessageId::CtxMenuTitle).to_string();
1287 push_context_menu(app, entries, mouse.column, mouse.row, title);
1288 }
1289
1290 /// Open a context menu at a screen position. Shared by the transcript/work
1291 /// surface menu and by modal views that ask for one over themselves.
1292 pub(crate) fn push_context_menu(
1293 app: &mut App,
1294 entries: Vec<ContextMenuEntry>,
1295 column: u16,
1296 row: u16,
1297 title: String,
1298 ) {
1299 let reduced = app.motion_policy().as_low_motion();
1300 app.view_stack.push(ContextMenuView::new_with_motion(
1301 entries, column, row, title, reduced,
1302 ));
1303 app.needs_redraw = true;
1304 }
1305
1306 pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec<ContextMenuEntry> {
1307 let mut entries = Vec::new();
1308 let on_work_surface = mouse_hits_rect(mouse, app.work_surface.last_area);
1309
1310 if on_work_surface {
1311 push_work_row_entries(app, mouse, &mut entries);
1312 } else {
1313 // Paste first — the most common action when right-clicking in the
1314 // composer or transcript after copying text from the output area.
1315 entries.push(
1316 ContextMenuEntry::new(
1317 app.tr(MessageId::CtxMenuPaste),
1318 app.tr(MessageId::CtxMenuPasteDesc),
1319 ContextMenuAction::Paste,
1320 )
1321 .with_glyph("📋")
1322 .with_hint("p")
1323 .primary(),
1324 );
1325 }
1326 let mut targeted = !entries.is_empty() && on_work_surface;
1327
1328 if selection_has_content(app) {
1329 targeted = true;
1330 entries.push(
1331 ContextMenuEntry::new(
1332 app.tr(MessageId::CtxMenuCopySelection),
1333 app.tr(MessageId::CtxMenuCopySelectionDesc),
1334 ContextMenuAction::CopySelection,
1335 )
1336 .with_glyph("⎘")
1337 .with_hint("y")
1338 .section_start(),
1339 );
1340 entries.push(
1341 ContextMenuEntry::new(
1342 app.tr(MessageId::CtxMenuOpenSelection),
1343 app.tr(MessageId::CtxMenuOpenSelectionDesc),
1344 ContextMenuAction::OpenSelection,
1345 )
1346 .with_glyph("↗"),
1347 );
1348 entries.push(
1349 ContextMenuEntry::new(
1350 app.tr(MessageId::CtxMenuClearSelection),
1351 "",
1352 ContextMenuAction::ClearSelection,
1353 )
1354 .with_glyph("×"),
1355 );
1356 }
1357
1358 if !on_work_surface
1359 && let Some(filtered_cell_index) = transcript_cell_index_from_mouse(app, mouse)
1360 {
1361 targeted = true;
1362 let cell_index = app.original_cell_index_for_rendered(filtered_cell_index);
1363 let target = detail_target_label(app, cell_index)
1364 .map(|label| truncate_line_to_width(label.as_str(), 28))
1365 .unwrap_or_else(|| "message".to_string());
1366 entries.push(
1367 ContextMenuEntry::new(
1368 app.tr(MessageId::CtxMenuOpenDetails),
1369 target,
1370 ContextMenuAction::OpenDetails { cell_index },
1371 )
1372 .with_glyph("▣")
1373 .section_start(),
1374 );
1375 entries.push(
1376 ContextMenuEntry::new(
1377 app.tr(MessageId::CtxMenuCopyMessage),
1378 app.tr(MessageId::CtxMenuCopyMessageDesc),
1379 ContextMenuAction::CopyCell { cell_index },
1380 )
1381 .with_glyph("⎘"),
1382 );
1383 // Offered only when a `path:line` on the clicked line (or, failing
1384 // that, in the cell) resolves to a file inside the workspace. Model
1385 // output is not trusted to name files outside it.
1386 if let Some((path, line)) = context_menu_file_reference(app, mouse, cell_index) {
1387 let shown = path
1388 .strip_prefix(&app.workspace)
1389 .unwrap_or(&path)
1390 .display()
1391 .to_string();
1392 entries.push(
1393 ContextMenuEntry::new(
1394 app.tr(MessageId::CtxMenuOpenInEditor),
1395 format!("{shown}:{line}"),
1396 ContextMenuAction::OpenFileAtLine { path, line },
1397 )
1398 .with_glyph("↗")
1399 .with_hint("e"),
1400 );
1401 }
1402 // Hide/show cell toggle.
1403 if app.collapsed_cells.contains(&cell_index) {
1404 entries.push(
1405 ContextMenuEntry::new(
1406 app.tr(MessageId::CtxMenuShowCell),
1407 app.tr(MessageId::CtxMenuShowCellDesc),
1408 ContextMenuAction::ShowCell { cell_index },
1409 )
1410 .with_glyph("◇"),
1411 );
1412 } else {
1413 entries.push(
1414 ContextMenuEntry::new(
1415 app.tr(MessageId::CtxMenuHideCell),
1416 app.tr(MessageId::CtxMenuHideCellDesc),
1417 ContextMenuAction::HideCell { cell_index },
1418 )
1419 .with_glyph("○"),
1420 );
1421 }
1422 }
1423
1424 // When cells are hidden, offer a way to show them all.
1425 if !app.collapsed_cells.is_empty() {
1426 let count = app.collapsed_cells.len();
1427 let label = app.tr(MessageId::CtxMenuShowHidden).to_string();
1428 entries.push(
1429 ContextMenuEntry::new(
1430 format!("{label} ({count})"),
1431 app.tr(MessageId::CtxMenuShowHiddenDesc),
1432 ContextMenuAction::ShowAllHidden,
1433 )
1434 .with_glyph("◇")
1435 .section_start(),
1436 );
1437 }
1438
1439 // App chrome belongs to empty space. On a message, a row or a selection
1440 // it only pushed the target's own actions off the bottom of the menu.
1441 if !targeted {
1442 push_chrome_entries(app, &mut entries);
1443 }
1444 entries
1445 }
1446
1447 fn push_chrome_entries(app: &App, entries: &mut Vec<ContextMenuEntry>) {
1448 entries.push(
1449 ContextMenuEntry::new(
1450 app.tr(MessageId::CtxMenuCmdPalette),
1451 app.tr(MessageId::CtxMenuCmdPaletteDesc),
1452 ContextMenuAction::OpenCommandPalette,
1453 )
1454 .with_glyph("⌘")
1455 .section_start(),
1456 );
1457 entries.push(
1458 ContextMenuEntry::new(
1459 app.tr(MessageId::CtxMenuContextInspector),
1460 app.tr(MessageId::CtxMenuContextInspectorDesc),
1461 ContextMenuAction::OpenContextInspector,
1462 )
1463 .with_glyph("ⓘ"),
1464 );
1465 entries.push(
1466 ContextMenuEntry::new(
1467 app.tr(MessageId::CtxMenuHelp),
1468 app.tr(MessageId::CtxMenuHelpDesc),
1469 ContextMenuAction::OpenHelp,
1470 )
1471 .with_glyph("?"),
1472 );
1473
1474 // Host window control (Windows only): pin/unpin the terminal window into
1475 // an always-on-top mini window. The label flips while pinned ("还原窗口"
1476 // instead of "弹出置顶小窗") so the entry always describes what the click
1477 // will do.
1478 if crate::tui::window_control::available() {
1479 let pinned = crate::tui::window_control::pinned();
1480 entries.push(
1481 ContextMenuEntry::new(
1482 app.tr(if pinned {
1483 MessageId::CtxMenuWindowUnpin
1484 } else {
1485 MessageId::CtxMenuWindowPin
1486 }),
1487 app.tr(MessageId::CtxMenuWindowPinDesc),
1488 ContextMenuAction::ToggleWindowPin,
1489 )
1490 .with_glyph(if pinned { "↩" } else { "📌" }),
1491 );
1492 }
1493 }
1494
1495 /// A work-surface row's menu: the row's own action first (what a left click
1496 /// does), then the agent or work item's other doors, then Copy, and any stop
1497 /// last behind an in-menu confirm. Each entry carries a typed row action, so
1498 /// the menu runs exactly what the row runs; nothing is a free-form command.
1499 ///
1500 /// Focus is the one agent destination: focusing shows the agent's chat and
1501 /// addresses the composer to it, so "Message agent" and "Open transcript"
1502 /// would be the same action under three names.
1503 fn push_work_row_entries(app: &App, mouse: MouseEvent, entries: &mut Vec<ContextMenuEntry>) {
1504 let confirm = || app.tr(MessageId::CtxMenuConfirmArmed).into_owned();
1505 let row = |label: String, action: SidebarRowAction| {
1506 ContextMenuEntry::new(label, "", ContextMenuAction::Row(action))
1507 };
1508 // The menu is built from the row's own action, never from its inline
1509 // stop zone: a right-click there must not make an unconfirmed stop the
1510 // primary entry. The row's stop, if any, goes last behind the confirm.
1511 let hovered = sidebar_row_at(app, mouse);
1512 let mut stop = hovered
1513 .and_then(|hovered| hovered.stop_action.clone())
1514 .map(|action| {
1515 let label = match action {
1516 SidebarRowAction::CancelAgent { .. } => MessageId::CtxMenuStopAgent,
1517 _ => MessageId::CtxMenuStopWork,
1518 };
1519 row(app.tr(label).into_owned(), action).confirm(confirm())
1520 });
1521 match hovered.and_then(|hovered| hovered.click_action.clone()) {
1522 Some(SidebarRowAction::OpenAgentTranscript { agent_id }) => {
1523 entries.push(
1524 ContextMenuEntry::new(
1525 app.tr(MessageId::CtxMenuFocusAgent),
1526 app.tr(MessageId::CtxMenuFocusAgentDesc),
1527 ContextMenuAction::Row(SidebarRowAction::OpenAgentTranscript {
1528 agent_id: agent_id.clone(),
1529 }),
1530 )
1531 .with_glyph("▶")
1532 .primary(),
1533 );
1534 entries.push(row(
1535 app.tr(MessageId::CtxMenuOpenDetails).into_owned(),
1536 SidebarRowAction::OpenAgentDetail {
1537 agent_id: agent_id.clone(),
1538 },
1539 ));
1540 entries.push(ContextMenuEntry::new(
1541 app.tr(MessageId::CtxMenuCopyId),
1542 agent_id.clone(),
1543 ContextMenuAction::CopyText {
1544 text: agent_id.clone(),
1545 },
1546 ));
1547 let running = app.subagent_cache.iter().any(|agent| {
1548 agent.agent_id == agent_id
1549 && matches!(
1550 agent.status,
1551 crate::tools::subagent::SubAgentStatus::Running
1552 )
1553 });
1554 if running {
1555 stop = Some(
1556 row(
1557 app.tr(MessageId::CtxMenuStopAgent).into_owned(),
1558 SidebarRowAction::CancelAgent { agent_id },
1559 )
1560 .confirm(confirm()),
1561 );
1562 }
1563 }
1564 Some(SidebarRowAction::CancelAgent { agent_id }) => {
1565 stop = Some(
1566 row(
1567 app.tr(MessageId::CtxMenuStopAgent).into_owned(),
1568 SidebarRowAction::CancelAgent { agent_id },
1569 )
1570 .confirm(confirm()),
1571 );
1572 }
1573 Some(action @ SidebarRowAction::InspectWork { .. }) => {
1574 if let SidebarRowAction::InspectWork {
1575 stop_action: Some(stop_action),
1576 ..
1577 } = &action
1578 {
1579 stop = Some(
1580 row(
1581 app.tr(MessageId::CtxMenuStopWork).into_owned(),
1582 (**stop_action).clone(),
1583 )
1584 .confirm(confirm()),
1585 );
1586 }
1587 entries.push(row(app.tr(MessageId::CtxMenuOpenDetails).into_owned(), action).primary());
1588 }
1589 Some(action @ SidebarRowAction::OpenAgentDetail { .. }) => {
1590 entries.push(row(app.tr(MessageId::CtxMenuOpenDetails).into_owned(), action).primary());
1591 }
1592 Some(action @ SidebarRowAction::ShowSubagentsPanel) => {
1593 entries.push(row(app.tr(MessageId::CtxMenuOpen).into_owned(), action).primary());
1594 }
1595 Some(SidebarRowAction::Command(command)) => {
1596 let label = app
1597 .tr(MessageId::CtxMenuRunCommand)
1598 .replace("{command}", &command);
1599 entries.push(row(label, SidebarRowAction::Command(command)).primary());
1600 }
1601 // Stages a destructive command in the composer; Enter there runs it.
1602 Some(SidebarRowAction::PrefillCommand(command)) => {
1603 let label = app
1604 .tr(MessageId::CtxMenuRunCommand)
1605 .replace("{command}", &command);
1606 entries.push(
1607 row(
1608 format!("{label}…"),
1609 SidebarRowAction::PrefillCommand(command),
1610 )
1611 .primary(),
1612 );
1613 }
1614 None => {}
1615 }
1616 // Copy the row's full text (rows can't be mouse-selected, so the menu is
1617 // the only copy path).
1618 if let Some(text) = sidebar_row_copy_text(app, mouse) {
1619 entries.push(
1620 ContextMenuEntry::new(
1621 app.tr(MessageId::CtxMenuCopyRow),
1622 truncate_line_to_width(first_line(&text), 28),
1623 ContextMenuAction::CopyText { text },
1624 )
1625 .with_glyph("⎘")
1626 .with_hint("y"),
1627 );
1628 }
1629 if let Some(stop) = stop {
1630 entries.push(stop.with_glyph("■").section_start());
1631 }
1632 }
1633
1634 /// The workspace file a right-click on a transcript cell points at: the first
1635 /// `path:line` on the clicked line, else the first in the cell. Both must be
1636 /// regular files inside the workspace, reached without links
1637 /// (`history::workspace_file`); `open_file_in_editor` checks again.
1638 fn context_menu_file_reference(
1639 app: &App,
1640 mouse: MouseEvent,
1641 cell_index: usize,
1642 ) -> Option<(std::path::PathBuf, u32)> {
1643 let clicked_line = selection_point_from_mouse(app, mouse).and_then(|point| {
1644 app.viewport
1645 .transcript_cache
1646 .lines()
1647 .get(point.line_index)
1648 .map(line_to_plain)
1649 });
1650 if let Some(found) = clicked_line
1651 .as_deref()
1652 .and_then(|line| crate::tui::history::file_line_reference(line, &app.workspace))
1653 {
1654 return Some(found);
1655 }
1656 let width = app
1657 .viewport
1658 .last_transcript_area
1659 .map(|area| area.width)
1660 .unwrap_or(80);
1661 let text = history_cell_to_text(app.cell_at_virtual_index(cell_index)?, width);
1662 crate::tui::history::first_file_line_reference(&text, &app.workspace)
1663 }
1664
1665 pub(crate) fn transcript_cell_index_from_mouse(app: &App, mouse: MouseEvent) -> Option<usize> {
1666 let point = selection_point_from_mouse(app, mouse)?;
1667 app.viewport
1668 .transcript_cache
1669 .line_meta()
1670 .get(point.line_index)
1671 .and_then(|meta| meta.cell_line())
1672 .map(|(cell_index, _)| cell_index)
1673 }
1674
1675 /// What a context-menu action needs from the host after `App` state changed.
1676 #[derive(Debug)]
1677 pub(crate) enum ContextMenuOutcome {
1678 Done,
1679 /// Row actions produce the same view events a left click on the row does;
1680 /// the host runs them through its view-event dispatcher.
1681 Events(Vec<ViewEvent>),
1682 /// Open a workspace file in `$EDITOR`. That needs the terminal (the TUI
1683 /// suspends while the editor owns it), so the host does it.
1684 OpenInEditor {
1685 path: std::path::PathBuf,
1686 line: u32,
1687 },
1688 }
1689
1690 /// Apply a context-menu action to `App`. Terminal work comes back as an
1691 /// outcome for the caller, so every action path is testable without one.
1692 pub(crate) fn apply_context_menu_action(
1693 app: &mut App,
1694 action: ContextMenuAction,
1695 ) -> ContextMenuOutcome {
1696 let mut outcome = ContextMenuOutcome::Done;
1697 match action {
1698 ContextMenuAction::CopySelection => {
1699 copy_active_selection(app);
1700 }
1701 ContextMenuAction::OpenSelection => {
1702 if !open_active_selection(app) {
1703 app.status_message = Some("No selection to open".to_string());
1704 }
1705 }
1706 ContextMenuAction::ClearSelection => {
1707 app.status_message = Some(
1708 if clear_active_selection(app) {
1709 "Selection cleared"
1710 } else {
1711 "No selection to clear"
1712 }
1713 .to_string(),
1714 );
1715 }
1716 ContextMenuAction::CopyCell { cell_index } => {
1717 copy_cell_to_clipboard(app, cell_index);
1718 }
1719 ContextMenuAction::OpenDetails { cell_index } => {
1720 if !open_details_pager_for_cell(app, cell_index) {
1721 app.status_message = Some("No details available for that line".to_string());
1722 }
1723 }
1724 ContextMenuAction::Paste => {
1725 app.paste_from_clipboard();
1726 }
1727 ContextMenuAction::Row(action) => {
1728 outcome = ContextMenuOutcome::Events(apply_sidebar_row_action(app, action));
1729 }
1730 ContextMenuAction::Extension { item_id, verb } => {
1731 match app.view_stack.extensions_menu_action(&item_id, verb) {
1732 Some(events) => outcome = ContextMenuOutcome::Events(events),
1733 None => {
1734 app.status_message = Some("That extension is no longer listed".to_string());
1735 }
1736 }
1737 }
1738 ContextMenuAction::CopyText { text } => {
1739 app.status_message = Some(match app.clipboard.write_text_status(&text) {
1740 Ok(transport) => copy_receipt(app, transport, app.tr(MessageId::ClipboardCopied)),
1741 Err(error) => format!("Copy failed: {error}"),
1742 });
1743 }
1744 ContextMenuAction::ToggleWindowPin => {
1745 crate::tui::window_control::toggle_pin(app);
1746 }
1747 ContextMenuAction::OpenCommandPalette => {
1748 codewhale_telemetry::session_counters()
1749 .bump(codewhale_telemetry::Counter::CommandPaletteOpen);
1750 app.view_stack.push(CommandPaletteView::new_for_locale(
1751 app.ui_locale,
1752 build_command_palette_entries(
1753 app.ui_locale,
1754 &app.skills_dir,
1755 app.skills_discovery_mode,
1756 &app.workspace,
1757 &app.mcp_config_path,
1758 app.mcp_snapshot.as_ref(),
1759 app.extension_plugin_view().as_ref(),
1760 ),
1761 ));
1762 }
1763 ContextMenuAction::OpenContextInspector => {
1764 open_context_inspector(app);
1765 }
1766 ContextMenuAction::OpenHelp => {
1767 let help =
1768 HelpView::new_for_app(app, false).with_groups_expanded(app.help_expand_groups);
1769 app.view_stack.push(help);
1770 }
1771 ContextMenuAction::OpenFileAtLine { path, line } => {
1772 outcome = ContextMenuOutcome::OpenInEditor { path, line };
1773 }
1774 ContextMenuAction::HideCell { cell_index } => {
1775 app.collapsed_cells.insert(cell_index);
1776 app.status_message = Some("Cell hidden".to_string());
1777 }
1778 ContextMenuAction::ShowCell { cell_index } => {
1779 app.collapsed_cells.remove(&cell_index);
1780 app.status_message = Some("Cell shown".to_string());
1781 }
1782 ContextMenuAction::ShowAllHidden => {
1783 let count = app.collapsed_cells.len();
1784 app.collapsed_cells.clear();
1785 app.status_message = Some(format!("{count} hidden cell(s) restored"));
1786 }
1787 }
1788 app.needs_redraw = true;
1789 outcome
1790 }
1791
1792 /// Open a workspace file at a line in `$EDITOR`. The editor gets the terminal
1793 /// through the same suspend path the composer and `/hooks edit` use, one at a
1794 /// time, and we wait for it. It used to be spawned detached while the TUI
1795 /// still held raw mode, the alt screen and mouse capture (#6235).
1796 /// The path `open_file_in_editor` may hand to the editor: `path` only while it
1797 /// is still a regular file inside `workspace` reached without links
1798 /// (`history::workspace_file`), else `None`.
1799 fn editor_target(
1800 workspace: &std::path::Path,
1801 path: &std::path::Path,
1802 ) -> Option<std::path::PathBuf> {
1803 path.to_str()
1804 .and_then(|raw| crate::tui::history::workspace_file(workspace, raw))
1805 }
1806
1807 pub(crate) fn open_file_in_editor(
1808 terminal: &mut ratatui::Terminal<crate::tui::color_compat::ColorCompatBackend<std::io::Stdout>>,
1809 app: &mut App,
1810 path: &std::path::Path,
1811 line: u32,
1812 ) {
1813 // The menu checked this path when it was built; a link can be swapped in
1814 // before the click, so check again right before the editor gets it.
1815 let Some(path) = editor_target(&app.workspace, path) else {
1816 app.status_message = Some(
1817 app.tr(MessageId::CtxMenuEditorRefused)
1818 .replace("{path}", &path.display().to_string()),
1819 );
1820 return;
1821 };
1822 let path = path.as_path();
1823 let outcome = crate::tui::external_editor::spawn_editor_for_path(
1824 terminal,
1825 app.use_alt_screen(),
1826 app.use_mouse_capture,
1827 app.use_bracketed_paste,
1828 path,
1829 Some(line),
1830 );
1831 app.needs_redraw = true;
1832 app.status_message = Some(match outcome {
1833 Ok(crate::tui::external_editor::EditorOutcome::Cancelled) => {
1834 format!("Editor exited without opening {}", path.display())
1835 }
1836 Ok(_) => format!("Closed editor for {}:{line}", path.display()),
1837 Err(error) => format!("Could not open the editor: {error}"),
1838 });
1839 }
1840
1841 pub(crate) fn selection_point_from_mouse(
1842 app: &App,
1843 mouse: MouseEvent,
1844 ) -> Option<TranscriptSelectionPoint> {
1845 selection_point_from_position(
1846 app.viewport.last_transcript_area?,
1847 mouse.column,
1848 mouse.row,
1849 app.viewport.last_transcript_top,
1850 app.viewport.last_transcript_total,
1851 app.viewport.last_transcript_padding_top,
1852 )
1853 }
1854
1855 pub(crate) fn selection_point_from_position(
1856 area: Rect,
1857 column: u16,
1858 row: u16,
1859 transcript_top: usize,
1860 transcript_total: usize,
1861 padding_top: usize,
1862 ) -> Option<TranscriptSelectionPoint> {
1863 if column < area.x
1864 || column >= area.x + area.width
1865 || row < area.y
1866 || row >= area.y + area.height
1867 {
1868 return None;
1869 }
1870
1871 if transcript_total == 0 {
1872 return None;
1873 }
1874
1875 let row = row.saturating_sub(area.y) as usize;
1876 if row < padding_top {
1877 return None;
1878 }
1879 let row = row.saturating_sub(padding_top);
1880
1881 let col = column.saturating_sub(area.x) as usize;
1882 let line_index = transcript_top
1883 .saturating_add(row)
1884 .min(transcript_total.saturating_sub(1));
1885
1886 Some(TranscriptSelectionPoint {
1887 line_index,
1888 column: col,
1889 })
1890 }
1891
1892 /// The one selection Copy, Open and Clear act on: the composer's when it has
1893 /// one, else the transcript's. The menu entries and Ctrl+C all read it here,
1894 /// so Copy cannot take the composer's text while Clear clears the
1895 /// transcript's and reports "Selection cleared".
1896 #[derive(Debug, Clone, PartialEq, Eq)]
1897 pub(crate) enum ActiveSelection {
1898 Composer(String),
1899 Transcript,
1900 }
1901
1902 pub(crate) fn active_selection(app: &App) -> Option<ActiveSelection> {
1903 let composer = app.selected_text();
1904 if !composer.is_empty() {
1905 return Some(ActiveSelection::Composer(composer));
1906 }
1907 selection_to_text(app)
1908 .is_some_and(|text| !text.is_empty())
1909 .then_some(ActiveSelection::Transcript)
1910 }
1911
1912 pub(crate) fn selection_has_content(app: &App) -> bool {
1913 active_selection(app).is_some()
1914 }
1915
1916 /// The receipt for a clipboard write. `native` is said only when a native
1917 /// clipboard took the text; a terminal (OSC 52 / tmux) write is never
1918 /// acknowledged, so its receipt says where the text went, not that it
1919 /// arrived. An asynchronous failure still replaces either receipt through
1920 /// the event loop's `poll_write_completion` drain.
1921 pub(crate) fn copy_receipt(
1922 app: &App,
1923 transport: crate::tui::clipboard::CopyTransport,
1924 native: impl Into<String>,
1925 ) -> String {
1926 match transport {
1927 crate::tui::clipboard::CopyTransport::Native => native.into(),
1928 crate::tui::clipboard::CopyTransport::Terminal => {
1929 app.tr(MessageId::ClipboardSentToTerminal).into_owned()
1930 }
1931 }
1932 }
1933
1934 /// Ctrl+X on a composer selection. The text is deleted only after a native
1935 /// clipboard confirmed the copy; an OSC 52 / tmux copy is never confirmed,
1936 /// so the text stays and the receipt says why.
1937 pub(crate) fn cut_selection(app: &mut App) {
1938 let sel = app.selected_text();
1939 if sel.is_empty() {
1940 return;
1941 }
1942 match app.clipboard.write_text_status(&sel) {
1943 Ok(crate::tui::clipboard::CopyTransport::Native) => {
1944 app.push_status_toast("Cut to clipboard", StatusToastLevel::Info, None);
1945 app.delete_selection();
1946 }
1947 Ok(crate::tui::clipboard::CopyTransport::Terminal) => {
1948 let receipt = app.tr(MessageId::ClipboardCutKeptText).into_owned();
1949 app.push_status_toast(receipt, StatusToastLevel::Info, None);
1950 }
1951 Err(_) => {
1952 app.push_status_toast("Cut failed", StatusToastLevel::Error, None);
1953 }
1954 }
1955 }
1956
1957 fn open_active_selection(app: &mut App) -> bool {
1958 match active_selection(app) {
1959 Some(ActiveSelection::Composer(text)) => {
1960 let width = app
1961 .viewport
1962 .last_transcript_area
1963 .map(|area| area.width)
1964 .unwrap_or(80);
1965 app.view_stack.push(crate::tui::pager::PagerView::from_text(
1966 "Selection",
1967 &text,
1968 width.saturating_sub(2),
1969 ));
1970 true
1971 }
1972 Some(ActiveSelection::Transcript) => open_pager_for_selection(app),
1973 None => false,
1974 }
1975 }
1976
1977 fn clear_active_selection(app: &mut App) -> bool {
1978 match active_selection(app) {
1979 Some(ActiveSelection::Composer(_)) => {
1980 app.clear_selection();
1981 app.needs_redraw = true;
1982 true
1983 }
1984 Some(ActiveSelection::Transcript) => {
1985 clear_transcript_selection(app);
1986 true
1987 }
1988 None => false,
1989 }
1990 }
1991
1992 /// Branches taken by the Ctrl+C key handler. The order encodes priority and is
1993 /// the unit-tested contract for #1337 / #1367: a transcript selection always
1994 /// wins (so users learn that Ctrl+C copies when there's something to copy);
1995 /// otherwise an active turn is interrupted; otherwise the quit-arm flow runs.
1996 #[derive(Debug, PartialEq, Eq)]
1997 pub(crate) enum CtrlCDisposition {
1998 CopySelection,
1999 CancelTurn,
2000 ConfirmExit,
2001 ArmExit,
2002 }
2003
2004 pub(crate) fn ctrl_c_disposition(app: &App) -> CtrlCDisposition {
2005 if selection_has_content(app) {
2006 CtrlCDisposition::CopySelection
2007 } else if app.is_loading
2008 || app.is_compacting
2009 || app.manual_compaction_queued
2010 || app.goal_continuation_waiting
2011 {
2012 CtrlCDisposition::CancelTurn
2013 } else if app.quit_is_armed() {
2014 CtrlCDisposition::ConfirmExit
2015 } else {
2016 CtrlCDisposition::ArmExit
2017 }
2018 }
2019
2020 /// Normalize the raw Ctrl+C control byte to canonical `Ctrl+C`.
2021 ///
2022 /// In PTY/raw-mode the terminal driver delivers Ctrl+C as the literal byte
2023 /// `0x03` (the ETX control character). crossterm usually decodes that to
2024 /// `Char('c') + CONTROL`, but some terminal / kitty-keyboard-protocol
2025 /// combinations surface it as `Char('\u{3}')` instead, where it slips past the
2026 /// `Char('c') + CONTROL` arm of the key handler and never reaches the
2027 /// quit-arm flow (#4090). Rewriting every encoding of Ctrl+C to the canonical
2028 /// form here keeps the double-press-to-exit behavior consistent across PTY,
2029 /// raw-mode, and kitty-enhanced terminals.
2030 pub(crate) fn normalize_raw_ctrl_c(key: &mut KeyEvent) {
2031 if matches!(key.code, KeyCode::Char('\u{3}')) {
2032 key.code = KeyCode::Char('c');
2033 key.modifiers.insert(KeyModifiers::CONTROL);
2034 }
2035 }
2036
2037 pub(crate) fn copy_active_selection(app: &mut App) {
2038 // Composer selection takes priority.
2039 let sel = app.selected_text();
2040 if !sel.is_empty() {
2041 match app.clipboard.write_text_status(&sel) {
2042 Ok(transport) => {
2043 app.status_message = Some(copy_receipt(app, transport, "Selection copied"));
2044 app.clear_selection();
2045 }
2046 Err(_) => app.status_message = Some("Copy failed".to_string()),
2047 }
2048 return;
2049 }
2050 if !app.viewport.transcript_selection.is_active() {
2051 return;
2052 }
2053 // Markdown source first (#6156): project every intersected cell through
2054 // the canonical clean-copy path. Falls back to rendered text when the
2055 // `[tui] selection_copy_markdown` key is off or no cell metadata
2056 // intersects the range.
2057 let payload = if app.viewport.selection_copy_markdown {
2058 selection_to_markdown(app).map(|(text, cells)| (text, Some(cells)))
2059 } else {
2060 None
2061 };
2062 let payload = payload.or_else(|| {
2063 selection_to_text(app)
2064 .filter(|text| !text.is_empty())
2065 .map(|text| (text, None))
2066 });
2067 if let Some((text, markdown_cells)) = payload {
2068 let written = app.clipboard.write_text_status(&text);
2069 if let Ok(crate::tui::clipboard::CopyTransport::Terminal) = written {
2070 app.status_message = Some(copy_receipt(
2071 app,
2072 crate::tui::clipboard::CopyTransport::Terminal,
2073 "",
2074 ));
2075 } else if written.is_ok() {
2076 match markdown_cells {
2077 Some(cells) => {
2078 let toast = app
2079 .tr(MessageId::SelectionCopiedAsMarkdown)
2080 .replace("{count}", &cells.to_string());
2081 app.push_status_toast(toast, StatusToastLevel::Info, None);
2082 }
2083 None => app.status_message = Some("Selection copied".to_string()),
2084 }
2085 } else {
2086 app.status_message = Some("Copy failed".to_string());
2087 }
2088 } else {
2089 clear_transcript_selection(app);
2090 app.status_message = Some("No selection to copy".to_string());
2091 }
2092 }
2093
2094 /// Whether a drag selection covers every cell it touches end to end (#6228).
2095 ///
2096 /// Two checks: the edge columns must reach the content edges on the boundary
2097 /// lines, and the line range must not cut a cell in half at either end.
2098 /// Middle lines are fully covered by construction, and cells render as
2099 /// contiguous spans, so the two edge cells decide for the whole range.
2100 fn selection_covers_cells_fully(
2101 app: &App,
2102 start: &TranscriptSelectionPoint,
2103 end: &TranscriptSelectionPoint,
2104 start_index: usize,
2105 end_index: usize,
2106 ) -> bool {
2107 let (first_head, _) = match content_column_span(app, start_index) {
2108 Some(span) => span,
2109 None => return false,
2110 };
2111 if start.column > first_head {
2112 return false;
2113 }
2114 let (_, last_tail) = match content_column_span(app, end_index) {
2115 Some(span) => span,
2116 None => return false,
2117 };
2118 if end.column < last_tail {
2119 return false;
2120 }
2121 let line_meta = app.viewport.transcript_cache.line_meta();
2122 let mut edge_cells = (start_index..=end_index).filter_map(|line_index| {
2123 line_meta
2124 .get(line_index)
2125 .and_then(|meta| meta.cell_line())
2126 .map(|(cell_index, _)| cell_index)
2127 });
2128 let Some(first_cell) = edge_cells.next() else {
2129 return false;
2130 };
2131 let last_cell = edge_cells.next_back().unwrap_or(first_cell);
2132 [first_cell, last_cell].into_iter().all(|cell| {
2133 let mut span = line_meta
2134 .iter()
2135 .enumerate()
2136 .filter_map(|(line_index, meta)| {
2137 meta.cell_line()
2138 .filter(|(cell_index, _)| *cell_index == cell)
2139 .map(|_| line_index)
2140 });
2141 match (span.next(), span.next_back()) {
2142 (Some(cell_first), Some(cell_last)) => {
2143 cell_first >= start_index && cell_last <= end_index
2144 }
2145 (Some(only), None) => start_index <= only && only <= end_index,
2146 (None, _) => false,
2147 }
2148 })
2149 }
2150
2151 /// Rendered-column span of selectable content on one transcript cache line.
2152 ///
2153 /// Mirrors the prefix math in [`selection_to_text`]: rail decorations plus
2154 /// copy-only prefixes are visual, so content runs from their combined width
2155 /// to that width plus the content's display width.
2156 fn content_column_span(app: &App, line_index: usize) -> Option<(usize, usize)> {
2157 let cache = &app.viewport.transcript_cache;
2158 let full_width = text_visible_width(&line_to_plain(cache.lines().get(line_index)?));
2159 let rail_width = cache.rail_prefix_width(line_index).min(full_width);
2160 let copy_prefix = cache
2161 .line_meta()
2162 .get(line_index)
2163 .map(|meta| meta.copy_prefix_width())
2164 .unwrap_or(0)
2165 .min(full_width.saturating_sub(rail_width));
2166 let head = rail_width.saturating_add(copy_prefix);
2167 let tail = head.saturating_add(
2168 full_width
2169 .saturating_sub(rail_width)
2170 .saturating_sub(copy_prefix),
2171 );
2172 Some((head, tail))
2173 }
2174
2175 /// Project a transcript drag selection to Markdown source (#6156).
2176 ///
2177 /// Collects every history cell intersecting the selection's rendered line
2178 /// range, in order, and serializes each through
2179 /// `history_cell_to_clipboard_text` — the same canonical projection Ctrl-Y
2180 /// and `/copy` use — joined with a blank line. Returns the payload plus the
2181 /// projected cell count for the toast.
2182 ///
2183 /// Markdown source is only truthful for whole cells, so a selection that
2184 /// cuts a cell in half is not projected here at all — it keeps its exact
2185 /// rendered text through the caller's [`selection_to_text`] fallback (#6228).
2186 ///
2187 /// Returns `None` when the selection is a fragment, when no cell metadata
2188 /// intersects the range, or when every projection is blank.
2189 pub(crate) fn selection_to_markdown(app: &App) -> Option<(String, usize)> {
2190 let (start, end) = app.viewport.transcript_selection.ordered_endpoints()?;
2191 let lines = app.viewport.transcript_cache.lines();
2192 if lines.is_empty() {
2193 return None;
2194 }
2195 let end_index = end.line_index.min(lines.len().saturating_sub(1));
2196 let start_index = start.line_index.min(end_index);
2197 if !selection_covers_cells_fully(app, &start, &end, start_index, end_index) {
2198 return None;
2199 }
2200 let line_meta = app.viewport.transcript_cache.line_meta();
2201 let width = app
2202 .viewport
2203 .last_transcript_area
2204 .map(|area| area.width)
2205 .unwrap_or(80);
2206 let mut rendered = Vec::new();
2207 for line_index in start_index..=end_index {
2208 if let Some((cell_index, _)) = line_meta.get(line_index).and_then(|meta| meta.cell_line())
2209 && !rendered.contains(&cell_index)
2210 {
2211 rendered.push(cell_index);
2212 }
2213 }
2214 let mut seen_original = Vec::new();
2215 let mut parts = Vec::new();
2216 for rendered_index in rendered {
2217 let original = app.original_cell_index_for_rendered(rendered_index);
2218 if seen_original.contains(&original) {
2219 continue;
2220 }
2221 seen_original.push(original);
2222 let Some(cell) = app.cell_at_virtual_index(original) else {
2223 continue;
2224 };
2225 let text = history_cell_to_clipboard_text(cell, width);
2226 if !text.trim().is_empty() {
2227 parts.push(text);
2228 }
2229 }
2230 if parts.is_empty() {
2231 return None;
2232 }
2233 let count = parts.len();
2234 Some((parts.join("\n\n"), count))
2235 }
2236 pub(crate) fn clear_transcript_selection(app: &mut App) {
2237 app.needs_redraw |= app.viewport.transcript_selection.is_active();
2238 app.viewport.transcript_selection.clear();
2239 }
2240 pub(crate) fn selection_to_text(app: &App) -> Option<String> {
2241 let (start, end) = app.viewport.transcript_selection.ordered_endpoints()?;
2242 let lines = app.viewport.transcript_cache.lines();
2243 if lines.is_empty() {
2244 return None;
2245 }
2246 let end_index = end.line_index.min(lines.len().saturating_sub(1));
2247 let start_index = start.line_index.min(end_index);
2248
2249 let line_meta = app.viewport.transcript_cache.line_meta();
2250 let mut selected = String::new();
2251 let mut separator_before = None;
2252 #[allow(clippy::needless_range_loop)]
2253 for line_index in start_index..=end_index {
2254 if let Some(separator) = separator_before {
2255 selected.push_str(separator);
2256 }
2257 // Rail-prefix decorations are stored as cache metadata rather than
2258 // detected from glyphs, so new decoration types are covered without
2259 // changes to the copy path (#1163).
2260 let rail_width = app.viewport.transcript_cache.rail_prefix_width(line_index);
2261 // Convert the rendered line to plain text (strips OSC-8), then
2262 // slice off the rail prefix so subsequent column offsets operate
2263 // on content-only text.
2264 let full_text = line_to_plain(&lines[line_index]);
2265 // Selection columns are painted terminal cells, where control
2266 // characters are invisible (ratatui strips them). Measure and slice
2267 // in that space so columns after a tab stay aligned with what the
2268 // user dragged over; the fixed-width fallback would shift every
2269 // downstream column.
2270 let line_after_rail = if rail_width > 0 {
2271 slice_visible_columns(&full_text, rail_width, text_visible_width(&full_text))
2272 } else {
2273 full_text
2274 };
2275 let line_after_rail_width = text_visible_width(&line_after_rail);
2276 let copy_prefix_width = line_meta
2277 .get(line_index)
2278 .map(|meta| meta.copy_prefix_width())
2279 .unwrap_or(0)
2280 .min(line_after_rail_width);
2281 let line_text = if copy_prefix_width > 0 {
2282 slice_visible_columns(&line_after_rail, copy_prefix_width, line_after_rail_width)
2283 } else {
2284 line_after_rail
2285 };
2286 let visual_prefix_width = rail_width.saturating_add(copy_prefix_width);
2287 let line_width = text_visible_width(&line_text);
2288 // Selection coordinates are recorded in rendered-column space, which
2289 // includes visual prefixes. Add them back so the column window maps
2290 // correctly into copy-only text.
2291 let (raw_col_start, raw_col_end) = if start_index == end_index {
2292 (start.column, end.column)
2293 } else if line_index == start_index {
2294 (start.column, line_width.saturating_add(visual_prefix_width))
2295 } else if line_index == end_index {
2296 (0, end.column)
2297 } else {
2298 (0, line_width.saturating_add(visual_prefix_width))
2299 };
2300
2301 let col_start = raw_col_start
2302 .saturating_sub(visual_prefix_width)
2303 .min(line_width);
2304 let col_end = raw_col_end
2305 .saturating_sub(visual_prefix_width)
2306 .min(line_width);
2307
2308 let slice = slice_visible_columns(&line_text, col_start, col_end);
2309 selected.push_str(&slice);
2310 separator_before = line_meta
2311 .get(line_index)
2312 .map(|meta| meta.copy_separator_after().as_str())
2313 .or(Some("\n"));
2314 }
2315 Some(selected)
2316 }
2317
2318 #[cfg(test)]
2319 mod tests {
2320 use super::{
2321 agent_transcript_text, build_context_menu_entries, handle_composer_mouse,
2322 handle_mouse_event, sidebar_click_action,
2323 };
2324 use crate::config::Config;
2325 use crate::tui::app::{
2326 App, SidebarHoverRow, SidebarHoverSection, SidebarRowAction, TuiOptions,
2327 };
2328 use crate::tui::tideline::{
2329 ContextBudgetSnapshot, InspectDetail, InteractionAction, InteractionFocus,
2330 InteractionTarget, InteractionTargetId,
2331 };
2332 use crate::tui::views::{ContextMenuAction, ModalKind, ViewEvent};
2333 use codewhale_models::Role;
2334 use codewhale_models::{ContentBlock, Message};
2335 use crossterm::event::{
2336 KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
2337 };
2338 use ratatui::layout::Rect;
2339 use serde_json::json;
2340 use std::path::PathBuf;
2341 use tempfile::tempdir;
2342
2343 pub(super) fn create_test_app() -> App {
2344 let options = TuiOptions {
2345 ..crate::test_support::test_tui_options(PathBuf::from("."))
2346 };
2347 let mut app = App::new(options, &Config::default());
2348 // Legacy strip geometry (see ui.rs); Bottom default has its own tests.
2349 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Top;
2350 app
2351 }
2352
2353 #[test]
2354 fn plugin_review_click_opens_inventory_and_emits_no_install_or_trust_command() {
2355 let _lock = crate::test_support::lock_test_env();
2356 let root = tempdir().unwrap();
2357 let _home =
2358 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
2359 let mut app = App::new(
2360 crate::test_support::test_tui_options(root.path()),
2361 &Config::default(),
2362 );
2363 app.surface_plugin_review_request("catalog-only", "/plugin install arbitrary-source");
2364 assert!(app.plugin_cta.phase.is_visible());
2365 let before = app.plugin_registry.list().len();
2366 let area = Rect::new(0, 0, 140, 1);
2367 let mut buffer = ratatui::buffer::Buffer::empty(area);
2368 crate::tui::plugin_suggestions::draw_plugin_cta(&mut app, area, &mut buffer);
2369 assert!(
2370 super::handle_plugin_cta_mouse(&mut app, left_click(0, 0))
2371 .unwrap()
2372 .is_empty()
2373 );
2374 assert!(app.view_stack.is_empty());
2375 assert!(app.plugin_cta.phase.is_visible());
2376 let button = app.viewport.last_plugin_cta_review_area.unwrap();
2377 let events =
2378 super::handle_plugin_cta_mouse(&mut app, left_click(button.x, button.y)).unwrap();
2379 assert!(
2380 events.is_empty(),
2381 "review navigation must not dispatch commands"
2382 );
2383 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Extensions));
2384 assert!(!app.plugin_cta.phase.is_visible());
2385 assert_eq!(app.plugin_registry.list().len(), before);
2386 assert!(app.plugin_registry.get("catalog-only").is_none());
2387 }
2388
2389 #[test]
2390 fn workbar_click_opens_the_workflows_view() {
2391 let mut app = create_test_app();
2392 app.workflow_runs
2393 .push(crate::tui::widgets::workflow_panel::WorkflowPanel::new(
2394 "workflow_1",
2395 "audit",
2396 1,
2397 ));
2398 app.viewport.last_workbar_area = Some(Rect::new(0, 20, 80, 1));
2399 assert!(!super::handle_workbar_mouse(&mut app, left_click(10, 5)));
2400 assert!(app.view_stack.is_empty());
2401 assert!(super::handle_workbar_mouse(&mut app, left_click(10, 20)));
2402 assert_eq!(
2403 app.view_stack.top_kind(),
2404 Some(crate::tui::views::ModalKind::WorkflowsManager)
2405 );
2406 }
2407
2408 #[test]
2409 fn composer_click_maps_tabs_as_painted() {
2410 // A tab paints no cells, so clicking the visible char after one
2411 // must resolve past it instead of stopping on the tab itself.
2412 let mut app = create_test_app();
2413 let area = Rect::new(0, 0, 80, 10);
2414 app.input = "a\tb".to_string();
2415 assert_eq!(super::mouse_pos_to_char_index(&app, 1, 0, area), Some(2));
2416 app.input = "\ta".to_string();
2417 assert_eq!(super::mouse_pos_to_char_index(&app, 0, 0, area), Some(1));
2418 }
2419
2420 fn hover_row(row_y: u16, action: Option<&str>) -> SidebarHoverRow {
2421 SidebarHoverRow {
2422 row_y,
2423 display_text: "row".to_string(),
2424 full_text: "row".to_string(),
2425 detail: None,
2426 is_truncated: false,
2427 click_action: action.map(|action| SidebarRowAction::Command(action.to_string())),
2428 stop_action: None,
2429 stop_zone_start_col: None,
2430 stop_zone_end_col: None,
2431 }
2432 }
2433
2434 fn hover_row_with_stop(row_y: u16, action: &str, stop_action: &str) -> SidebarHoverRow {
2435 SidebarHoverRow {
2436 row_y,
2437 display_text: "job row [x]".to_string(),
2438 full_text: "job row [x]".to_string(),
2439 detail: None,
2440 is_truncated: false,
2441 click_action: Some(SidebarRowAction::Command(action.to_string())),
2442 stop_action: Some(SidebarRowAction::Command(stop_action.to_string())),
2443 stop_zone_start_col: Some(68),
2444 stop_zone_end_col: Some(71),
2445 }
2446 }
2447
2448 fn action_command(action: Option<SidebarRowAction>) -> Option<String> {
2449 match action? {
2450 SidebarRowAction::Command(command) => Some(command),
2451 _ => None,
2452 }
2453 }
2454
2455 fn left_click(column: u16, row: u16) -> MouseEvent {
2456 MouseEvent {
2457 kind: MouseEventKind::Down(MouseButton::Left),
2458 column,
2459 row,
2460 modifiers: KeyModifiers::NONE,
2461 }
2462 }
2463
2464 fn right_click(column: u16, row: u16) -> MouseEvent {
2465 MouseEvent {
2466 kind: MouseEventKind::Down(MouseButton::Right),
2467 column,
2468 row,
2469 modifiers: KeyModifiers::NONE,
2470 }
2471 }
2472
2473 fn mouse_move(column: u16, row: u16) -> MouseEvent {
2474 MouseEvent {
2475 kind: MouseEventKind::Moved,
2476 column,
2477 row,
2478 modifiers: KeyModifiers::NONE,
2479 }
2480 }
2481
2482 #[test]
2483 fn the_launch_card_does_not_swallow_the_rest_of_the_screen() {
2484 // Founder live-test: "the clickability and the mouse pointing thing
2485 // isn't working". While the opening screen was a separate surface it
2486 // was right for it to consume every mouse event and return; the
2487 // moment it became content on the ordinary screen that gate made
2488 // scrolling, the composer and the work surface unreachable. A click
2489 // that misses the card's rows must fall through.
2490 let mut app = create_test_app();
2491 app.launch.visible = true;
2492 app.launch.row_hitboxes = vec![(
2493 crate::tui::app::LaunchRowId::NewSession,
2494 Rect::new(2, 5, 30, 1),
2495 )];
2496 app.viewport.last_transcript_area = Some(Rect::new(0, 0, 80, 20));
2497 app.viewport.pending_scroll_delta = 0;
2498
2499 // A wheel tick on the launch screen still scrolls.
2500 handle_mouse_event(
2501 &mut app,
2502 MouseEvent {
2503 kind: MouseEventKind::ScrollDown,
2504 column: 10,
2505 row: 10,
2506 modifiers: KeyModifiers::NONE,
2507 },
2508 );
2509 assert_ne!(
2510 app.viewport.pending_scroll_delta, 0,
2511 "the wheel must reach the transcript on the opening screen"
2512 );
2513
2514 // A click away from the card's rows starts no launch action.
2515 app.pending_launch_action = None;
2516 handle_mouse_event(&mut app, left_click(60, 15));
2517 assert_eq!(
2518 app.pending_launch_action, None,
2519 "a click off the card must not be read as a card action"
2520 );
2521
2522 // A click on a row still runs it.
2523 handle_mouse_event(&mut app, left_click(4, 5));
2524 assert_eq!(
2525 app.pending_launch_action,
2526 Some(crate::tui::underwater::LaunchAction::NewSession),
2527 "the card's own rows still work"
2528 );
2529 }
2530
2531 #[test]
2532 fn clicking_a_recent_row_opens_the_resume_confirmation_popup() {
2533 // Founder live-test: "you just click it and boom you're there ... you
2534 // don't realize it's happening", then, on the first fix: "the
2535 // resuming confirmation needs to be a popup not something in the
2536 // composer that's even more confusing". Resuming replaces the whole
2537 // session context, so the click opens a popup that names the session
2538 // and asks; nothing resumes until that is confirmed.
2539 let mut app = create_test_app();
2540 app.launch.visible = true;
2541 app.launch.recent = vec![crate::tui::app::LaunchRecentSession {
2542 id: "sess-1".to_string(),
2543 title: "refactor the parser".to_string(),
2544 updated_at: chrono::Utc::now(),
2545 message_count: 12,
2546 }];
2547 app.launch.row_hitboxes = vec![(
2548 crate::tui::app::LaunchRowId::Recent("sess-1".to_string()),
2549 Rect::new(2, 5, 30, 1),
2550 )];
2551 app.pending_launch_action = None;
2552
2553 handle_mouse_event(&mut app, left_click(4, 5));
2554 assert_eq!(
2555 app.pending_launch_action, None,
2556 "the click must not resume anything on its own"
2557 );
2558 assert_eq!(
2559 app.view_stack.top_kind(),
2560 Some(crate::tui::views::ModalKind::LaunchResumeConfirm),
2561 "it opens the confirmation popup instead"
2562 );
2563 assert!(
2564 app.launch.status.is_none(),
2565 "and nothing is written over the composer dock"
2566 );
2567 }
2568
2569 #[test]
2570 fn a_new_session_row_still_takes_one_click() {
2571 // Only resuming discards context, so New session keeps its single
2572 // click; adding a confirm step there would be friction for nothing.
2573 let mut app = create_test_app();
2574 app.launch.visible = true;
2575 app.launch.row_hitboxes = vec![(
2576 crate::tui::app::LaunchRowId::NewSession,
2577 Rect::new(2, 5, 30, 1),
2578 )];
2579 app.pending_launch_action = None;
2580
2581 handle_mouse_event(&mut app, left_click(4, 5));
2582 assert_eq!(
2583 app.pending_launch_action,
2584 Some(crate::tui::underwater::LaunchAction::NewSession),
2585 "New session runs on the first click"
2586 );
2587 }
2588
2589 #[test]
2590 fn idle_pointer_enter_and_leave_request_hover_redraws() {
2591 let _guard = crate::tui::hover_layer::HOVER_TEST_LOCK.lock().unwrap();
2592 crate::tui::hover_layer::clear_pointer();
2593 crate::tui::hover_layer::begin_frame();
2594 crate::tui::hover_layer::register_rect(
2595 crate::tui::hover_hit::HoverTargetKind::TruncatedText,
2596 Rect::new(10, 5, 20, 1),
2597 "full clipped row",
2598 false,
2599 );
2600
2601 let mut app = create_test_app();
2602 app.launch.visible = false;
2603 app.needs_redraw = false;
2604 handle_mouse_event(&mut app, mouse_move(12, 5));
2605 assert!(
2606 app.needs_redraw,
2607 "entering a target must repaint while idle"
2608 );
2609 assert_eq!(
2610 crate::tui::hover_layer::current_hover().map(|hit| hit.kind),
2611 Some(crate::tui::hover_hit::HoverTargetKind::TruncatedText)
2612 );
2613
2614 app.needs_redraw = false;
2615 handle_mouse_event(&mut app, mouse_move(40, 5));
2616 assert!(app.needs_redraw, "leaving a target must clear its popover");
2617 assert!(crate::tui::hover_layer::current_hover().is_none());
2618 crate::tui::hover_layer::clear_pointer();
2619 }
2620
2621 #[test]
2622 fn slash_autocomplete_click_selects_and_second_click_applies() {
2623 let mut app = create_test_app();
2624 app.launch.visible = false;
2625 app.work_surface.last_area = None;
2626 app.input = "/he".to_string();
2627 app.cursor_position = app.input.chars().count();
2628 app.slash_menu_hidden = false;
2629 app.slash_menu_selected = 0;
2630 // Simulate two painted rows from ComposerWidget.
2631 app.viewport.last_composer_area = Some(Rect::new(0, 18, 80, 6));
2632 *app.viewport.last_slash_menu_hitboxes.borrow_mut() =
2633 vec![(0, Rect::new(1, 20, 78, 1)), (1, Rect::new(1, 21, 78, 1))];
2634
2635 assert!(
2636 handle_composer_mouse(&mut app, left_click(5, 21)),
2637 "slash row click must be consumed by the composer"
2638 );
2639 assert_eq!(
2640 app.slash_menu_selected, 1,
2641 "click on another row highlights it"
2642 );
2643 let before = app.input.clone();
2644 assert_eq!(
2645 before, "/he",
2646 "select-only click must not rewrite the composer"
2647 );
2648
2649 assert!(handle_composer_mouse(&mut app, left_click(5, 21)));
2650 assert_ne!(app.input, before, "click on the highlighted row applies it");
2651 assert!(
2652 app.input.starts_with('/'),
2653 "applied slash entry must replace the composer: {:?}",
2654 app.input
2655 );
2656 }
2657
2658 #[test]
2659 fn slash_autocomplete_wheel_moves_selection() {
2660 let mut app = create_test_app();
2661 app.launch.visible = false;
2662 app.work_surface.last_area = None;
2663 app.input = "/he".to_string();
2664 app.cursor_position = app.input.chars().count();
2665 app.slash_menu_hidden = false;
2666 app.slash_menu_selected = 0;
2667 app.viewport.last_composer_area = Some(Rect::new(0, 18, 80, 6));
2668 *app.viewport.last_slash_menu_hitboxes.borrow_mut() =
2669 vec![(0, Rect::new(1, 20, 78, 1)), (1, Rect::new(1, 21, 78, 1))];
2670 let entries = crate::tui::slash_menu::visible_slash_menu_entries(&app, 128);
2671 assert!(entries.len() >= 2, "prefix must offer multiple entries");
2672
2673 assert!(handle_composer_mouse(
2674 &mut app,
2675 MouseEvent {
2676 kind: MouseEventKind::ScrollDown,
2677 column: 5,
2678 row: 20,
2679 modifiers: KeyModifiers::NONE,
2680 },
2681 ));
2682 assert_eq!(app.slash_menu_selected, 1);
2683
2684 assert!(handle_composer_mouse(
2685 &mut app,
2686 MouseEvent {
2687 kind: MouseEventKind::ScrollUp,
2688 column: 5,
2689 row: 20,
2690 modifiers: KeyModifiers::NONE,
2691 },
2692 ));
2693 assert_eq!(app.slash_menu_selected, 0);
2694 }
2695
2696 #[test]
2697 fn active_composer_send_click_queues_the_keyboard_submit_chord() {
2698 let mut app = create_test_app();
2699 app.launch.visible = false;
2700 app.composer_border = true;
2701 app.input = "ship it".to_string();
2702 app.cursor_position = app.input.chars().count();
2703 let area = Rect::new(0, 20, 80, 4);
2704 app.viewport.last_composer_area = Some(area);
2705 // Match the frame's submit-aware input plane: x=74 stays blank,
2706 // then the shared `[↵]` target begins at x=75.
2707 app.viewport.last_composer_content = Some(Rect::new(1, 21, 73, 2));
2708 let submit = crate::tui::widgets::active_composer_submit_rect(&app, area)
2709 .expect("enclosed composer submit");
2710
2711 handle_mouse_event(&mut app, left_click(submit.x, submit.y));
2712 assert_eq!(
2713 app.pending_composer_submit,
2714 Some(crate::tui::app::ComposerSubmitChord::Enter)
2715 );
2716 assert_eq!(app.input, "ship it");
2717 assert_eq!(app.cursor_position, app.input.chars().count());
2718
2719 app.pending_composer_submit = None;
2720 handle_mouse_event(&mut app, left_click(area.x + 4, area.y + 1));
2721 assert_eq!(app.pending_composer_submit, None);
2722
2723 app.input.clear();
2724 app.cursor_position = 0;
2725 handle_mouse_event(&mut app, left_click(submit.x, submit.y));
2726 assert_eq!(app.pending_composer_submit, None);
2727 assert!(app.input.is_empty());
2728 }
2729
2730 #[test]
2731 fn context_meter_click_uses_the_same_inspector_as_the_keyboard_shortcut() {
2732 let mut app = create_test_app();
2733 app.launch.visible = false;
2734 app.viewport
2735 .interaction_targets
2736 .register(InteractionTarget {
2737 id: InteractionTargetId::HEADER_CONTEXT,
2738 area: Rect::new(52, 0, 20, 1),
2739 focus: InteractionFocus::Direct,
2740 keyboard_action: Some(InteractionAction::InspectContext),
2741 mouse_action: Some(InteractionAction::InspectContext),
2742 inspect_detail: InspectDetail::ContextBudget(ContextBudgetSnapshot {
2743 used_tokens: 3_000,
2744 max_tokens: 10_000,
2745 percent_basis_points: 3_000,
2746 }),
2747 });
2748
2749 handle_mouse_event(&mut app, left_click(60, 0));
2750
2751 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextInspector));
2752 assert!(
2753 crate::tui::shell_key_routing::is_context_inspector_shortcut(&KeyEvent::new(
2754 KeyCode::Char('c'),
2755 KeyModifiers::ALT
2756 ))
2757 );
2758 }
2759
2760 #[test]
2761 fn topbar_route_click_emits_provider_picker_request() {
2762 let mut app = create_test_app();
2763 // The launch screen shares the same header, so this specifically
2764 // protects against its old catch-all mouse route swallowing the
2765 // topbar affordance before it reached the event handler.
2766 app.launch.visible = true;
2767 app.viewport
2768 .interaction_targets
2769 .register(InteractionTarget {
2770 id: InteractionTargetId::HEADER_ROUTE,
2771 area: Rect::new(20, 0, 24, 1),
2772 focus: InteractionFocus::Direct,
2773 keyboard_action: Some(InteractionAction::OpenProviderPicker),
2774 mouse_action: Some(InteractionAction::OpenProviderPicker),
2775 inspect_detail: InspectDetail::Route,
2776 });
2777
2778 let events = handle_mouse_event(&mut app, left_click(24, 0));
2779
2780 assert!(matches!(
2781 events.as_slice(),
2782 [ViewEvent::TopbarRoutePickerRequested]
2783 ));
2784 assert!(app.view_stack.is_empty());
2785 }
2786
2787 #[test]
2788 fn context_menu_keeps_paste_first_outside_sidebar() {
2789 let mut app = create_test_app();
2790 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
2791
2792 let entries = build_context_menu_entries(&app, right_click(10, 4));
2793
2794 assert!(matches!(
2795 entries.first().map(|entry| &entry.action),
2796 Some(ContextMenuAction::Paste)
2797 ));
2798 }
2799
2800 #[test]
2801 fn sidebar_context_menu_omits_paste_without_row_action() {
2802 let mut app = create_test_app();
2803 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
2804 app.sidebar_hover.sections.push(SidebarHoverSection {
2805 content_area: Rect::new(60, 4, 20, 6),
2806 lines: vec!["header".to_string()],
2807 rows: vec![hover_row(4, None)],
2808 });
2809
2810 let entries = build_context_menu_entries(&app, right_click(65, 4));
2811
2812 assert!(
2813 !entries
2814 .iter()
2815 .any(|entry| matches!(entry.action, ContextMenuAction::Paste)),
2816 "sidebar menu should not offer paste: {entries:?}"
2817 );
2818 }
2819
2820 #[test]
2821 fn sidebar_context_menu_runs_clickable_row_action() {
2822 let mut app = create_test_app();
2823 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
2824 app.sidebar_hover.sections.push(SidebarHoverSection {
2825 content_area: Rect::new(60, 4, 20, 6),
2826 lines: vec!["job row".to_string()],
2827 rows: vec![hover_row(4, Some("/jobs show shell_x"))],
2828 });
2829
2830 let entries = build_context_menu_entries(&app, right_click(65, 4));
2831
2832 // The row's own command, named, and run as the same typed row action
2833 // a left click runs — not a free-form command string.
2834 let first = entries.first().expect("sidebar row should have menu");
2835 assert_eq!(first.label, "Run /jobs show shell_x");
2836 assert_eq!(
2837 first.action,
2838 ContextMenuAction::Row(SidebarRowAction::Command("/jobs show shell_x".to_string()))
2839 );
2840 assert!(
2841 !entries
2842 .iter()
2843 .any(|entry| matches!(entry.action, ContextMenuAction::Paste)),
2844 "clickable sidebar menu should not offer paste: {entries:?}"
2845 );
2846 }
2847
2848 #[test]
2849 fn sidebar_click_resolves_row_actions_inside_section() {
2850 let mut app = create_test_app();
2851 app.sidebar_hover.sections.push(SidebarHoverSection {
2852 content_area: Rect::new(60, 4, 20, 6),
2853 lines: vec![
2854 "header".to_string(),
2855 "job row".to_string(),
2856 "job detail".to_string(),
2857 "agent row".to_string(),
2858 ],
2859 rows: vec![
2860 hover_row(4, None),
2861 hover_row(5, Some("/jobs show shell_x")),
2862 hover_row(6, Some("/jobs cancel shell_x")),
2863 SidebarHoverRow {
2864 row_y: 7,
2865 display_text: "agent row".to_string(),
2866 full_text: "agent row".to_string(),
2867 detail: None,
2868 is_truncated: false,
2869 click_action: Some(SidebarRowAction::OpenAgentDetail {
2870 agent_id: "agent_123".to_string(),
2871 }),
2872 stop_action: None,
2873 stop_zone_start_col: None,
2874 stop_zone_end_col: None,
2875 },
2876 ],
2877 });
2878
2879 assert_eq!(
2880 action_command(sidebar_click_action(&app, left_click(65, 5))).as_deref(),
2881 Some("/jobs show shell_x"),
2882 "job label row resolves to its show action"
2883 );
2884 assert_eq!(
2885 action_command(sidebar_click_action(&app, left_click(79, 6))).as_deref(),
2886 Some("/jobs cancel shell_x"),
2887 "job detail row resolves to its cancel action"
2888 );
2889 assert!(matches!(
2890 sidebar_click_action(&app, left_click(60, 7)),
2891 Some(SidebarRowAction::OpenAgentDetail { agent_id })
2892 if agent_id == "agent_123"
2893 ));
2894 assert_eq!(
2895 sidebar_click_action(&app, left_click(65, 4)),
2896 None,
2897 "header row has no action"
2898 );
2899 }
2900
2901 #[test]
2902 fn sidebar_click_routes_inline_stop_zone_before_row_action() {
2903 let mut app = create_test_app();
2904 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 4));
2905 app.sidebar_hover.sections.push(SidebarHoverSection {
2906 content_area: Rect::new(60, 4, 20, 4),
2907 lines: vec!["job row [x]".to_string()],
2908 rows: vec![hover_row_with_stop(
2909 4,
2910 "/jobs show shell_x",
2911 "/jobs cancel shell_x",
2912 )],
2913 });
2914
2915 assert_eq!(
2916 action_command(sidebar_click_action(&app, left_click(62, 4))).as_deref(),
2917 Some("/jobs show shell_x"),
2918 "clicking the label opens the job"
2919 );
2920 assert_eq!(
2921 action_command(sidebar_click_action(&app, left_click(69, 4))).as_deref(),
2922 Some("/jobs cancel shell_x"),
2923 "clicking [x] cancels the job"
2924 );
2925 }
2926
2927 #[test]
2928 fn sidebar_click_routes_agent_inline_stop_zone_before_peek_action() {
2929 let mut app = create_test_app();
2930 app.work_surface.last_area = Some(Rect::new(60, 4, 24, 4));
2931 app.sidebar_hover.sections.push(SidebarHoverSection {
2932 content_area: Rect::new(60, 4, 24, 4),
2933 lines: vec!["[~] worker Agent 1 [x]".to_string()],
2934 rows: vec![SidebarHoverRow {
2935 row_y: 4,
2936 display_text: "[~] Agent 1 is working [x]".to_string(),
2937 full_text: "[~] Agent 1 is working [x]".to_string(),
2938 detail: None,
2939 is_truncated: false,
2940 click_action: Some(SidebarRowAction::OpenAgentDetail {
2941 agent_id: "agent_123".to_string(),
2942 }),
2943 stop_action: Some(SidebarRowAction::CancelAgent {
2944 agent_id: "agent_123".to_string(),
2945 }),
2946 stop_zone_start_col: Some(68),
2947 stop_zone_end_col: Some(71),
2948 }],
2949 });
2950
2951 assert!(matches!(
2952 sidebar_click_action(&app, left_click(62, 4)),
2953 Some(SidebarRowAction::OpenAgentDetail { agent_id })
2954 if agent_id == "agent_123"
2955 ));
2956 assert!(matches!(
2957 sidebar_click_action(&app, left_click(69, 4)),
2958 Some(SidebarRowAction::CancelAgent { agent_id }) if agent_id == "agent_123"
2959 ));
2960 }
2961
2962 #[test]
2963 fn sidebar_context_menu_offers_copy_of_hovered_row() {
2964 let mut app = create_test_app();
2965 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
2966 app.sidebar_hover.sections.push(SidebarHoverSection {
2967 content_area: Rect::new(60, 4, 20, 6),
2968 lines: vec!["agent row".to_string()],
2969 rows: vec![SidebarHoverRow {
2970 row_y: 4,
2971 display_text: "[~] worker doc-che…".to_string(),
2972 full_text: "[~] worker doc-checker".to_string(),
2973 detail: Some("id: agent_123 · 2 step(s)".to_string()),
2974 is_truncated: true,
2975 click_action: None,
2976 stop_action: None,
2977 stop_zone_start_col: None,
2978 stop_zone_end_col: None,
2979 }],
2980 });
2981
2982 let entries = build_context_menu_entries(&app, right_click(65, 4));
2983
2984 let copy = entries
2985 .iter()
2986 .find(|entry| matches!(entry.action, ContextMenuAction::CopyText { .. }))
2987 .expect("sidebar row should offer Copy");
2988 assert_eq!(copy.label, "Copy row");
2989 assert!(matches!(
2990 &copy.action,
2991 ContextMenuAction::CopyText { text }
2992 if text == "[~] worker doc-checker\nid: agent_123 · 2 step(s)"
2993 ));
2994 }
2995
2996 #[test]
2997 fn sidebar_click_outside_section_resolves_to_none() {
2998 let mut app = create_test_app();
2999 app.sidebar_hover.sections.push(SidebarHoverSection {
3000 content_area: Rect::new(60, 4, 20, 6),
3001 lines: vec!["job row".to_string()],
3002 rows: vec![hover_row(4, Some("/jobs show shell_x"))],
3003 });
3004
3005 // Left of the sidebar (transcript area).
3006 assert_eq!(sidebar_click_action(&app, left_click(10, 4)), None);
3007 // Below the section's content area.
3008 assert_eq!(sidebar_click_action(&app, left_click(65, 30)), None);
3009 // Inside the section but on an empty row without metadata.
3010 assert_eq!(sidebar_click_action(&app, left_click(65, 8)), None);
3011 }
3012
3013 fn work_row_section(app: &mut App, action: SidebarRowAction) {
3014 app.work_surface.last_area = Some(Rect::new(60, 4, 20, 6));
3015 app.sidebar_hover.sections.push(SidebarHoverSection {
3016 content_area: Rect::new(60, 4, 20, 6),
3017 lines: vec!["row".to_string()],
3018 rows: vec![SidebarHoverRow {
3019 row_y: 4,
3020 display_text: "row".to_string(),
3021 full_text: "row text".to_string(),
3022 detail: None,
3023 is_truncated: false,
3024 click_action: Some(action),
3025 stop_action: None,
3026 stop_zone_start_col: None,
3027 stop_zone_end_col: None,
3028 }],
3029 });
3030 }
3031
3032 /// N14: focusing an agent shows its chat and addresses the composer to
3033 /// it, so Focus, Message and Open transcript are one action. The menu
3034 /// offers it once, and every other entry does something different.
3035 #[test]
3036 fn agent_row_menu_offers_focus_once() {
3037 let mut app = create_test_app();
3038 work_row_section(
3039 &mut app,
3040 SidebarRowAction::OpenAgentTranscript {
3041 agent_id: "agent_1".to_string(),
3042 },
3043 );
3044
3045 let entries = build_context_menu_entries(&app, right_click(65, 4));
3046 let labels: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect();
3047 assert_eq!(
3048 labels,
3049 ["Focus agent", "Open details", "Copy id", "Copy row"]
3050 );
3051 assert!(entries[0].primary);
3052 assert_eq!(
3053 entries[0].action,
3054 ContextMenuAction::Row(SidebarRowAction::OpenAgentTranscript {
3055 agent_id: "agent_1".to_string()
3056 })
3057 );
3058 for (index, entry) in entries.iter().enumerate() {
3059 assert!(
3060 entries[index + 1..]
3061 .iter()
3062 .all(|e| e.action != entry.action),
3063 "two rows do the same thing: {labels:?}"
3064 );
3065 }
3066 }
3067
3068 /// A work item with a stop carries it last, behind the menu's confirm,
3069 /// as the same typed action the inspector's stop runs.
3070 #[test]
3071 fn work_item_menu_puts_a_confirmed_stop_last() {
3072 let mut app = create_test_app();
3073 work_row_section(
3074 &mut app,
3075 SidebarRowAction::InspectWork {
3076 title: "job".to_string(),
3077 body: "body".to_string(),
3078 stop_action: Some(Box::new(SidebarRowAction::Command(
3079 "/jobs cancel shell_x".to_string(),
3080 ))),
3081 },
3082 );
3083
3084 let entries = build_context_menu_entries(&app, right_click(65, 4));
3085 assert_eq!(entries[0].label, "Open details");
3086 let stop = entries.last().expect("stop entry");
3087 assert_eq!(stop.label, "Stop…");
3088 assert!(stop.confirm_label.is_some());
3089 assert_eq!(
3090 stop.action,
3091 ContextMenuAction::Row(SidebarRowAction::Command(
3092 "/jobs cancel shell_x".to_string()
3093 ))
3094 );
3095 }
3096
3097 /// T8: "Run" used to run through a dedicated arm while a dead arm's
3098 /// status claimed the command was "staged in composer". A row action now
3099 /// returns exactly the events a left click on the row produces.
3100 #[test]
3101 fn row_action_returns_what_a_left_click_runs() {
3102 let mut app = create_test_app();
3103 let outcome = super::apply_context_menu_action(
3104 &mut app,
3105 ContextMenuAction::Row(SidebarRowAction::Command("/cost".to_string())),
3106 );
3107 let super::ContextMenuOutcome::Events(events) = outcome else {
3108 panic!("row actions hand their events to the host: {outcome:?}");
3109 };
3110 assert!(matches!(
3111 events.as_slice(),
3112 [ViewEvent::CommandPaletteSelected {
3113 action: crate::tui::views::CommandPaletteAction::ExecuteCommand { command },
3114 }] if command == "/cost"
3115 ));
3116 assert!(app.input.is_empty(), "nothing is staged in the composer");
3117
3118 let outcome = super::apply_context_menu_action(
3119 &mut app,
3120 ContextMenuAction::Row(SidebarRowAction::CancelAgent {
3121 agent_id: "agent_1".to_string(),
3122 }),
3123 );
3124 assert!(matches!(
3125 outcome,
3126 super::ContextMenuOutcome::Events(ref events)
3127 if matches!(events.as_slice(), [ViewEvent::SidebarAgentCancel { agent_id }] if agent_id == "agent_1")
3128 ));
3129 }
3130
3131 #[test]
3132 fn open_file_hands_the_editor_to_the_host() {
3133 let mut app = create_test_app();
3134 let path = PathBuf::from("/ws/src/a.rs");
3135 let outcome = super::apply_context_menu_action(
3136 &mut app,
3137 ContextMenuAction::OpenFileAtLine {
3138 path: path.clone(),
3139 line: 12,
3140 },
3141 );
3142 assert!(matches!(
3143 outcome,
3144 super::ContextMenuOutcome::OpenInEditor { path: ref p, line: 12 } if *p == path
3145 ));
3146 }
3147
3148 #[test]
3149 fn hide_and_restore_cells_report_what_changed() {
3150 let mut app = create_test_app();
3151 super::apply_context_menu_action(&mut app, ContextMenuAction::HideCell { cell_index: 3 });
3152 assert!(app.collapsed_cells.contains(&3));
3153 assert_eq!(app.status_message.as_deref(), Some("Cell hidden"));
3154 super::apply_context_menu_action(&mut app, ContextMenuAction::ShowAllHidden);
3155 assert!(app.collapsed_cells.is_empty());
3156 assert_eq!(
3157 app.status_message.as_deref(),
3158 Some("1 hidden cell(s) restored")
3159 );
3160 }
3161
3162 #[test]
3163 fn extension_action_without_the_panel_says_so() {
3164 let mut app = create_test_app();
3165 let outcome = super::apply_context_menu_action(
3166 &mut app,
3167 ContextMenuAction::Extension {
3168 item_id: "gone".to_string(),
3169 verb: crate::tui::views::ExtensionMenuVerb::Remove,
3170 },
3171 );
3172 assert!(matches!(outcome, super::ContextMenuOutcome::Done));
3173 assert_eq!(
3174 app.status_message.as_deref(),
3175 Some("That extension is no longer listed")
3176 );
3177 }
3178
3179 fn app_with_answer(workspace: &std::path::Path, content: &str) -> App {
3180 let mut app = create_test_app();
3181 app.workspace = workspace.to_path_buf();
3182 app.history = vec![crate::tui::history::HistoryCell::Assistant {
3183 content: content.to_string(),
3184 streaming: false,
3185 }];
3186 app.resync_history_revisions();
3187 app.viewport.transcript_cache.ensure(
3188 &app.history,
3189 &app.history_revisions,
3190 80,
3191 app.transcript_render_options(),
3192 );
3193 app.viewport.last_transcript_area = Some(Rect::new(0, 0, 80, 8));
3194 app.viewport.last_transcript_top = 0;
3195 app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();
3196 app
3197 }
3198
3199 fn line_of(app: &App, needle: &str) -> u16 {
3200 let index = app
3201 .viewport
3202 .transcript_cache
3203 .lines()
3204 .iter()
3205 .position(|line| crate::tui::ui_text::line_to_plain(line).contains(needle))
3206 .expect("rendered line");
3207 u16::try_from(index).unwrap()
3208 }
3209
3210 /// T4: Open in editor used to follow any absolute path in model output,
3211 /// and `..` escaped through `workspace.join`. It is offered only for a
3212 /// file inside the workspace, and the clicked line wins over the cell.
3213 #[test]
3214 fn open_in_editor_is_offered_only_inside_the_workspace() {
3215 let root = tempdir().expect("tempdir");
3216 let workspace = root.path().join("ws");
3217 std::fs::create_dir_all(workspace.join("src")).unwrap();
3218 std::fs::write(workspace.join("src/a.rs"), "fn a() {}\n").unwrap();
3219 std::fs::write(workspace.join("src/b.rs"), "fn b() {}\n").unwrap();
3220 std::fs::write(root.path().join("secret.rs"), "outside\n").unwrap();
3221 let outside = root.path().join("secret.rs");
3222
3223 let app = app_with_answer(
3224 &workspace,
3225 &format!(
3226 "first `src/a.rs:3`\n\nthen --> src/b.rs:7:2\n\nand {}:1",
3227 outside.display()
3228 ),
3229 );
3230 let open = |app: &App, row: u16| {
3231 build_context_menu_entries(app, right_click(4, row))
3232 .into_iter()
3233 .find_map(|entry| match entry.action {
3234 ContextMenuAction::OpenFileAtLine { path, line } => Some((path, line)),
3235 _ => None,
3236 })
3237 };
3238 assert_eq!(
3239 open(&app, line_of(&app, "src/b.rs")),
3240 Some((workspace.join("src/b.rs"), 7)),
3241 "the clicked line wins"
3242 );
3243 assert_eq!(
3244 open(&app, line_of(&app, "secret.rs")),
3245 Some((workspace.join("src/a.rs"), 3)),
3246 "an outside path on the clicked line falls back to the cell's own"
3247 );
3248
3249 let app = app_with_answer(
3250 &workspace,
3251 &format!("{}:1 and ../secret.rs:2", outside.display()),
3252 );
3253 assert_eq!(open(&app, line_of(&app, "secret.rs")), None);
3254 }
3255
3256 /// App chrome (palette, inspector, help) belongs to empty space; on a
3257 /// message it only pushed the message's own actions down.
3258 #[test]
3259 fn chrome_entries_only_on_empty_space() {
3260 let root = tempdir().expect("tempdir");
3261 let app = app_with_answer(root.path(), "alpha beta");
3262 let has_palette = |entries: &[crate::tui::context_menu::ContextMenuEntry]| {
3263 entries
3264 .iter()
3265 .any(|e| e.action == ContextMenuAction::OpenCommandPalette)
3266 };
3267 assert!(!has_palette(&build_context_menu_entries(
3268 &app,
3269 right_click(4, line_of(&app, "alpha"))
3270 )));
3271 let empty = create_test_app();
3272 let entries = build_context_menu_entries(&empty, right_click(4, 4));
3273 assert!(has_palette(&entries));
3274 assert_eq!(entries[0].action, ContextMenuAction::Paste);
3275 }
3276
3277 /// T6: Copy took the composer selection while Clear cleared only the
3278 /// transcript's and still said "Selection cleared", and Open ignored the
3279 /// composer. All three now act on the same selection.
3280 #[test]
3281 fn copy_open_and_clear_act_on_the_composer_selection() {
3282 let root = tempdir().expect("tempdir");
3283 let mut app = app_with_answer(root.path(), "alpha beta");
3284 // A transcript selection too: the composer's must still win.
3285 app.viewport.transcript_selection.anchor =
3286 Some(crate::tui::selection::TranscriptSelectionPoint {
3287 line_index: 0,
3288 column: 0,
3289 });
3290 app.viewport.transcript_selection.head =
3291 Some(crate::tui::selection::TranscriptSelectionPoint {
3292 line_index: 0,
3293 column: 6,
3294 });
3295 let select = |app: &mut App| {
3296 app.input = "hello world".to_string();
3297 app.selection_anchor = Some(0);
3298 app.cursor_position = 5;
3299 };
3300
3301 select(&mut app);
3302 super::apply_context_menu_action(&mut app, ContextMenuAction::CopySelection);
3303 assert_eq!(app.clipboard.last_written_text(), Some("hello"));
3304 assert_eq!(app.status_message.as_deref(), Some("Selection copied"));
3305
3306 select(&mut app);
3307 super::apply_context_menu_action(&mut app, ContextMenuAction::OpenSelection);
3308 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));
3309 app.view_stack.pop();
3310
3311 super::apply_context_menu_action(&mut app, ContextMenuAction::ClearSelection);
3312 assert!(
3313 app.selected_text().is_empty(),
3314 "the composer selection cleared"
3315 );
3316 assert_eq!(app.status_message.as_deref(), Some("Selection cleared"));
3317 assert!(
3318 app.viewport.transcript_selection.is_active(),
3319 "Clear acts on the selection Copy would take, not both"
3320 );
3321
3322 super::clear_transcript_selection(&mut app);
3323 super::apply_context_menu_action(&mut app, ContextMenuAction::ClearSelection);
3324 assert_eq!(
3325 app.status_message.as_deref(),
3326 Some("No selection to clear"),
3327 "nothing cleared is not reported as cleared"
3328 );
3329 }
3330
3331 /// The menu resolved the file when it opened; a link swapped in before
3332 /// the click (to a real key file outside the workspace, or to `/`) must
3333 /// be refused at launch. The key is one the test creates, so the check
3334 /// does not depend on what the host has in `~/.ssh`.
3335 #[cfg(unix)]
3336 #[test]
3337 fn editor_target_rechecks_links_swapped_in_after_the_menu_opened() {
3338 let dir = tempfile::tempdir().unwrap();
3339 let workspace = &dir.path().join("ws");
3340 let key = dir.path().join("home/.ssh/id_ed25519");
3341 std::fs::create_dir_all(key.parent().unwrap()).unwrap();
3342 std::fs::write(&key, "PRIVATE KEY\n").unwrap();
3343 std::fs::create_dir_all(workspace.join("src")).unwrap();
3344 let file = workspace.join("src/a.rs");
3345 std::fs::write(&file, "fn a() {}\n").unwrap();
3346 assert!(super::editor_target(workspace, &file).is_some());
3347
3348 // The file becomes a link to the key.
3349 std::fs::remove_file(&file).unwrap();
3350 std::os::unix::fs::symlink(&key, &file).unwrap();
3351 assert!(file.is_file(), "the link resolves to a real file");
3352 assert_eq!(super::editor_target(workspace, &file), None);
3353
3354 // The directory becomes a link to /.
3355 std::fs::remove_file(&file).unwrap();
3356 std::fs::remove_dir(workspace.join("src")).unwrap();
3357 std::os::unix::fs::symlink("/", workspace.join("src")).unwrap();
3358 assert_eq!(
3359 super::editor_target(workspace, &workspace.join("src/etc/hosts")),
3360 None
3361 );
3362 assert_eq!(
3363 super::editor_target(workspace, std::path::Path::new("/etc/hosts")),
3364 None
3365 );
3366
3367 // The refusal the user sees names the file and why.
3368 let refusal = codewhale_localization::tr(
3369 codewhale_localization::Locale::En,
3370 codewhale_localization::MessageId::CtxMenuEditorRefused,
3371 )
3372 .replace("{path}", "src/a.rs");
3373 assert_eq!(
3374 refusal,
3375 "Did not open src/a.rs: it is no longer a file inside the workspace"
3376 );
3377 }
3378
3379 /// Ctrl+X deletes the selection only when a native clipboard confirmed
3380 /// the copy. A terminal (OSC 52 / tmux) copy keeps the text and says why;
3381 /// a failed copy keeps it and says the cut failed.
3382 #[test]
3383 fn cut_deletes_only_after_a_native_copy() {
3384 fn select_all(app: &mut App, text: &str) {
3385 app.input = text.to_string();
3386 app.selection_anchor = Some(0);
3387 app.cursor_position = text.chars().count();
3388 }
3389 fn last_toast(app: &App) -> Option<&str> {
3390 app.status_toasts.back().map(|toast| toast.text.as_str())
3391 }
3392
3393 let mut app = create_test_app();
3394 select_all(&mut app, "hello");
3395 super::cut_selection(&mut app);
3396 assert_eq!(app.clipboard.last_written_text(), Some("hello"));
3397 assert_eq!(app.input, "", "a native copy cuts");
3398 assert_eq!(last_toast(&app), Some("Cut to clipboard"));
3399
3400 app.clipboard = crate::tui::clipboard::ClipboardHandler::terminal_only_for_test();
3401 select_all(&mut app, "kept");
3402 super::cut_selection(&mut app);
3403 assert_eq!(
3404 app.input, "kept",
3405 "an unconfirmed terminal copy keeps the text"
3406 );
3407 assert_eq!(
3408 last_toast(&app),
3409 Some(
3410 "Sent to the terminal clipboard; the text stays because terminals do not confirm copies"
3411 )
3412 );
3413
3414 app.clipboard = crate::tui::clipboard::ClipboardHandler::unavailable_for_test(false);
3415 select_all(&mut app, "also kept");
3416 super::cut_selection(&mut app);
3417 assert_eq!(app.input, "also kept", "a failed copy keeps the text");
3418 assert_eq!(last_toast(&app), Some("Cut failed"));
3419 }
3420
3421 /// A right-click on a row's inline stop zone opens the row's own menu;
3422 /// the stop is offered last behind the confirm, never as the primary
3423 /// entry that one click would run.
3424 #[test]
3425 fn right_click_on_a_stop_zone_keeps_the_stop_confirmed() {
3426 let mut app = create_test_app();
3427 work_row_section(
3428 &mut app,
3429 SidebarRowAction::Command("/jobs show shell_x".to_string()),
3430 );
3431 let row = &mut app.sidebar_hover.sections[0].rows[0];
3432 row.stop_action = Some(SidebarRowAction::Command(
3433 "/jobs cancel shell_x".to_string(),
3434 ));
3435 row.stop_zone_start_col = Some(76);
3436 row.stop_zone_end_col = Some(79);
3437
3438 let entries = build_context_menu_entries(&app, right_click(77, 4));
3439 let primary = entries.iter().find(|entry| entry.primary).expect("primary");
3440 assert_eq!(
3441 primary.action,
3442 ContextMenuAction::Row(SidebarRowAction::Command("/jobs show shell_x".to_string()))
3443 );
3444 assert!(primary.confirm_label.is_none());
3445 let stop = entries.last().expect("stop entry");
3446 assert_eq!(stop.label, "Stop…");
3447 assert_eq!(
3448 stop.action,
3449 ContextMenuAction::Row(SidebarRowAction::Command(
3450 "/jobs cancel shell_x".to_string()
3451 ))
3452 );
3453 assert!(stop.confirm_label.is_some(), "the stop needs the confirm");
3454 }
3455
3456 /// N9: an OSC 52 / tmux write is never acknowledged, so its receipt says
3457 /// the text was sent to the terminal, not that it was copied.
3458 #[test]
3459 fn copy_receipts_name_the_transport() {
3460 let mut app = create_test_app();
3461 super::apply_context_menu_action(
3462 &mut app,
3463 ContextMenuAction::CopyText {
3464 text: "agent_1".to_string(),
3465 },
3466 );
3467 assert_eq!(app.clipboard.last_written_text(), Some("agent_1"));
3468 assert_eq!(
3469 app.status_message.as_deref(),
3470 Some("Copied to the clipboard")
3471 );
3472
3473 app.clipboard = crate::tui::clipboard::ClipboardHandler::terminal_only_for_test();
3474 super::apply_context_menu_action(
3475 &mut app,
3476 ContextMenuAction::CopyText {
3477 text: "agent_1".to_string(),
3478 },
3479 );
3480 assert_eq!(
3481 app.status_message.as_deref(),
3482 Some("Sent to the terminal clipboard (terminals do not confirm it)")
3483 );
3484
3485 // The terminal lane holds one write at a time; let the first land.
3486 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3487 while app.clipboard.poll_write_completion().is_none() {
3488 assert!(
3489 std::time::Instant::now() < deadline,
3490 "terminal write landed"
3491 );
3492 std::thread::sleep(std::time::Duration::from_millis(1));
3493 }
3494 app.input = "hello".to_string();
3495 app.selection_anchor = Some(0);
3496 app.cursor_position = 5;
3497 super::apply_context_menu_action(&mut app, ContextMenuAction::CopySelection);
3498 assert_eq!(
3499 app.status_message.as_deref(),
3500 Some("Sent to the terminal clipboard (terminals do not confirm it)")
3501 );
3502
3503 app.clipboard = crate::tui::clipboard::ClipboardHandler::unavailable_for_test(false);
3504 super::apply_context_menu_action(
3505 &mut app,
3506 ContextMenuAction::CopyText {
3507 text: "agent_1".to_string(),
3508 },
3509 );
3510 assert!(
3511 app.status_message
3512 .as_deref()
3513 .is_some_and(|status| status.starts_with("Copy failed")),
3514 "{:?}",
3515 app.status_message
3516 );
3517 }
3518
3519 /// T11: Paste with nothing to paste used to do nothing and say nothing.
3520 #[test]
3521 fn paste_with_nothing_to_paste_says_so() {
3522 let mut app = create_test_app();
3523 app.clipboard = crate::tui::clipboard::ClipboardHandler::unavailable_for_test(false);
3524 app.input = "draft".to_string();
3525 super::apply_context_menu_action(&mut app, ContextMenuAction::Paste);
3526 assert_eq!(app.input, "draft");
3527 assert_eq!(
3528 app.status_message.as_deref(),
3529 Some("Nothing to paste: the clipboard is empty or could not be read")
3530 );
3531 }
3532
3533 #[test]
3534 fn worker_transcript_formats_visible_activity_without_thinking() {
3535 let transcript = agent_transcript_text(&json!({
3536 "message_count": 2,
3537 "messages": [
3538 {"role": "user", "content": [{"type": "text", "text": "Survey Harnesses", "cache_control": null}]},
3539 {"role": "assistant", "content": [
3540 {"type": "thinking", "thinking": "private chain of thought", "signature": null},
3541 {"type": "tool_use", "id": "call_1", "name": "list_dir", "input": {"path": "/tmp"}, "caller": null},
3542 {"type": "text", "text": "I found the workspace.", "cache_control": null}
3543 ]}
3544 ]
3545 }));
3546
3547 assert!(transcript.contains("── user ──\nSurvey Harnesses"));
3548 assert!(transcript.contains("→ list_dir"));
3549 assert!(transcript.contains("I found the workspace."));
3550 assert!(!transcript.contains("private chain of thought"));
3551 }
3552
3553 #[test]
3554 fn worker_open_reads_first_and_last_turns_from_complete_artifact() {
3555 let tmp = tempdir().expect("tempdir");
3556 let agent_id = "agent_large_chat";
3557 let early = format!("EARLY-OPEN-MARKER\n{}", "a".repeat(1_100_000));
3558 let messages = vec![
3559 Message {
3560 role: Role::User,
3561 content: vec![ContentBlock::Text {
3562 text: early,
3563 cache_control: None,
3564 }],
3565 },
3566 Message {
3567 role: Role::Assistant,
3568 content: vec![ContentBlock::Text {
3569 text: "LAST-OPEN-MARKER".to_string(),
3570 cache_control: None,
3571 }],
3572 },
3573 ];
3574 let artifact = crate::tools::subagent::write_subagent_transcript_artifact_for_test(
3575 tmp.path(),
3576 agent_id,
3577 &messages,
3578 )
3579 .expect("write complete worker transcript");
3580 assert!(
3581 std::fs::metadata(artifact)
3582 .expect("artifact metadata")
3583 .len()
3584 > 1024 * 1024,
3585 "regression requires a transcript larger than the resident handle budget"
3586 );
3587
3588 let mut app = create_test_app();
3589 app.workspace = tmp.path().to_path_buf();
3590 {
3591 let mut store = app
3592 .runtime_services
3593 .handle_store
3594 .try_lock()
3595 .expect("handle store");
3596 let _ = store.insert_json(
3597 format!("agent:{agent_id}"),
3598 "full_transcript",
3599 json!({
3600 "kind": "subagent_full_transcript",
3601 "message_count": 2,
3602 "omitted_messages": 1,
3603 "messages_complete": false,
3604 "messages": [messages[1].clone()],
3605 }),
3606 );
3607 }
3608
3609 crate::tui::agent_focus::focus_agent(&mut app, agent_id);
3610 let focus = app.agent_focus.as_ref().expect("Open focuses the worker");
3611 let body = focus
3612 .cells
3613 .iter()
3614 .flat_map(|cell| cell.transcript_lines(120))
3615 .map(|line| line.to_string())
3616 .collect::<Vec<_>>()
3617 .join("\n");
3618 assert!(body.contains("EARLY-OPEN-MARKER"), "{body}");
3619 assert!(body.contains("LAST-OPEN-MARKER"), "{body}");
3620 assert_eq!(
3621 focus.omitted_messages, 0,
3622 "Open must use the complete artifact, not the compacted resident tail"
3623 );
3624 }
3625
3626 #[cfg(test)]
3627 mod composer_selection_tests {
3628 use super::super::*;
3629
3630 #[test]
3631 fn word_bounds_select_words_and_respect_cjk() {
3632 // Chars: fix(0-2) sp(3) the(4-6) sp(7) 深(8) 海(9) sp(10) test.rs(11-17)
3633 let text = "fix the 深海 test.rs";
3634 assert_eq!(composer_word_bounds(text, 2), (0, 3)); // 'fix'
3635 assert_eq!(composer_word_bounds(text, 5), (4, 7)); // 'the'
3636 assert_eq!(composer_word_bounds(text, 9), (8, 10)); // '深海' (space at 10)
3637 assert_eq!(composer_word_bounds(text, 11), (11, 15)); // 'test' (stops at '.')
3638 assert_eq!(composer_word_bounds(text, 15), (15, 16)); // '.'
3639 assert_eq!(composer_word_bounds(text, 16), (16, 18)); // 'rs'
3640 }
3641
3642 #[test]
3643 fn click_bounds_are_char_indices_on_multibyte_text() {
3644 // The click maps to a char index and the result becomes the
3645 // char-indexed cursor/anchor. Byte math here panicked on a
3646 // triple-click inside CJK text and skewed double-click spans.
3647 let text = "你好\n世界 ok";
3648 assert_eq!(composer_line_bounds(text, 1), (0, 2));
3649 assert_eq!(composer_line_bounds(text, 4), (3, 8));
3650 assert_eq!(composer_word_bounds(text, 3), (3, 5)); // '世界'
3651 assert_eq!(composer_word_bounds(text, 6), (6, 8)); // 'ok'
3652 let mixed = "fix 深海 test";
3653 assert_eq!(composer_word_bounds(mixed, 8), (7, 11)); // 'test'
3654 }
3655
3656 #[test]
3657 fn word_bounds_at_punctuation_returns_the_single_char() {
3658 let text = "a, b";
3659 assert_eq!(composer_word_bounds(text, 1), (1, 2)); // ','
3660 }
3661
3662 #[test]
3663 fn line_bounds_exclude_the_newline() {
3664 let text = "first\nsecond third\nfourth";
3665 assert_eq!(composer_line_bounds(text, 2), (0, 5));
3666 assert_eq!(composer_line_bounds(text, 9), (6, 18));
3667 assert_eq!(composer_line_bounds(text, 20), (19, 25));
3668 assert_eq!(composer_line_bounds(text, 0), (0, 5));
3669 }
3670
3671 #[test]
3672 fn click_classification_resets_outside_the_window_or_slop() {
3673 let mut trace = None;
3674 assert_eq!(
3675 classify_composer_click(&mut trace, 10, 4),
3676 ComposerClickGesture::Caret
3677 );
3678 assert_eq!(
3679 classify_composer_click(&mut trace, 10, 4),
3680 ComposerClickGesture::Word
3681 );
3682 assert_eq!(
3683 classify_composer_click(&mut trace, 10, 4),
3684 ComposerClickGesture::Line
3685 );
3686 // A click far away resets the chain back to a caret.
3687 assert_eq!(
3688 classify_composer_click(&mut trace, 10, 40),
3689 ComposerClickGesture::Caret
3690 );
3691 assert_eq!(
3692 classify_composer_click(&mut trace, 10, 40),
3693 ComposerClickGesture::Word
3694 );
3695 }
3696 }
3697 }
3698
3699 #[cfg(test)]
3700 mod primary_tests;
3701
3701 lines RUST