grok-build-upstream-mirror/crates/codegen/xai-grok-pager/src/app/mouse.rs
grokkybara[bot] 5da6962e4a Synced from monorepo
Synced from monorepo

Changes:
- Workspace server: surface preview-proxy metrics through the hub metric pump
- Shell: reclaim a session’s retained state in one entry
- Shell: reclaim a session’s resident state in one entry
- Pager: withhold key event types from Alacritty builds that double keys
- Tools: cancel a session’s subagents when it closes
- Pager: keep the whole plan in scrollback and separate reasoning from output in minimal mode
- Pager: probe terminal version over DA2 and include it with feedback
- SuperGrok Plus: identity, CLI, and analytics tier surfaces
- Shell: inherit the session process scope into subagents
- Pager: build @-file-search matcher lazily on first use
- Tools: fix description and output contradictions in tool definitions
- Workspace: degrade @-file-search instead of aborting on thread exhaustion
- Tools: reap a session’s LSP servers when it closes
- Tools: fix contradictions and defects in tool descriptions, schemas, and harness pools
- MCP: reap stdio MCP children on session close
- Shell: reuse spawn-time skill discovery for session telemetry
- Tools: stop leaking shell-wrapper positional params into sourced scripts (fixes activate_conda under persistent/static shell)
- Shell: self-heal corrupt session-search SQLite cache
- Workspace: cap workspace-server tokio workers on many-core hosts
- Shell: reap a session’s child processes when it closes
- Crash handler: capture SIGABRT so panic-aborts leave crash reports
- CLI chat proxy: team-scoped Grok Code managed-config admin routes
- MCP: add CLI enable/disable for MCP servers
- Shell: cap tokio worker threads for startup thread demand
- Workspace: harden git_commit and add git_sync_base operation
- Circuit breaker: add feature-gated gRPC retry policy

Source-Revision: 2a818575225183d8ca915f5632a09b8067b5156a
2026-07-28 22:50:19 +00:00

1721 lines
85 KiB
Rust

//! Mouse input handling for [`AgentView`]: the `handle_mouse` event handler
//! (click-to-focus, hit-testing of cached click rects, per-pane click
//! dispatch) and the scrollbar click helper.
//!
//! Extracted from `agent_view.rs` as a sibling `impl AgentView` block (same
//! pattern as `queue_edit.rs`).
//!
//! Hit-tests here assume the cached rects come from the last rendered frame.
use super::actions::Action;
use super::agent_view::{
AgentPane, AgentView, CONTEXT_CLICK_DEBOUNCE_MS, CtaPhase, MULTI_CLICK_TIMEOUT_MS,
PromptInputMode, PromptMode, TextClickState, app_should_open_link_on_click,
has_native_link_hover, is_link_modifier_held, is_text_selection_on_double_click,
};
use super::app_view::InputOutcome;
use crate::scrollback::block::BlockContent;
use crate::views::btw_overlay::BTW_OVERLAY_ENTRY_IDX;
use crate::views::prompt_widget::PromptEvent;
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use std::time::Instant;
impl AgentView {
/// Time-paired multi-click check for the prompt textarea. Pairing is
/// time-only (no coordinates); a mispaired action is one undo step.
/// Records the click for the next pairing.
pub(super) fn prompt_click_is_double(&mut self) -> bool {
let now = std::time::Instant::now();
let is_double = self
.last_prompt_click_ms
.is_some_and(|last| now.duration_since(last).as_millis() < MULTI_CLICK_TIMEOUT_MS);
self.last_prompt_click_ms = Some(now);
is_double
}
/// Handle mouse events: click-to-focus, forward to prompt textarea.
///
/// Scroll events are handled at app level (not here).
pub(super) fn handle_mouse(&mut self, mouse: &MouseEvent) -> InputOutcome {
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
self.left_mouse_down = true;
if self.hit_todo_close.contains(mouse.column, mouse.row) {
self.todo.overlay.escape();
self.todo.on_state_change();
if self.active_pane == AgentPane::Todo {
self.set_active_pane(AgentPane::Scrollback, false);
}
return InputOutcome::Changed;
}
if self.hit_queue_close.contains(mouse.column, mouse.row) {
self.queue.overlay.escape();
self.queue.on_state_change();
if self.active_pane == AgentPane::Queue {
self.set_active_pane(AgentPane::Scrollback, false);
}
return InputOutcome::Changed;
}
if self.hit_queue_badge.contains(mouse.column, mouse.row) {
self.toggle_queue_pane();
return InputOutcome::Changed;
}
if self.hit_bg_status.contains(mouse.column, mouse.row) {
self.tasks.overlay.toggle();
self.tasks.on_state_change();
if self.tasks.overlay.focused {
self.set_active_pane(AgentPane::Tasks, false);
} else if self.active_pane == AgentPane::Tasks {
self.set_active_pane(AgentPane::Scrollback, false);
}
return InputOutcome::Changed;
}
if self.hit_goal_status.contains(mouse.column, mouse.row) {
if !self.workflow_runs.is_empty() {
self.show_workflows = !self.show_workflows;
if self.show_workflows {
self.workflows_view.reset();
self.show_goal_detail = false;
}
} else if self.goal_state.is_some() {
self.show_goal_detail = !self.show_goal_detail;
}
return InputOutcome::Changed;
}
if self.hit_context.contains(mouse.column, mouse.row) {
let now = Instant::now();
let too_soon = self.last_context_click_at.is_some_and(|t| {
now.duration_since(t).as_millis() < CONTEXT_CLICK_DEBOUNCE_MS
});
if too_soon {
return InputOutcome::Unchanged;
}
self.last_context_click_at = Some(now);
return InputOutcome::Action(Action::ShowContextInfo);
}
if self.hit_plan_button.contains(mouse.column, mouse.row) {
if self.plan_approval_view.is_some() {
self.reopen_plan_approval();
} else {
self.show_plan_preview();
}
return InputOutcome::Changed;
}
if self
.hit_plan_approval_status
.contains(mouse.column, mouse.row)
{
if self.plan_approval_view.is_some() {
self.reopen_plan_approval();
} else {
self.show_plan_preview();
}
return InputOutcome::Changed;
}
if self.hit_catalog_close.contains(mouse.column, mouse.row) {
self.catalog.overlay.escape();
self.catalog.on_state_change();
if self.active_pane == AgentPane::Catalog {
self.set_active_pane(AgentPane::Scrollback, false);
}
return InputOutcome::Changed;
}
if self.hit_bg_close.contains(mouse.column, mouse.row) {
self.tasks.overlay.escape();
self.tasks.on_state_change();
if self.active_pane == AgentPane::Tasks {
self.set_active_pane(AgentPane::Scrollback, false);
}
return InputOutcome::Changed;
}
if self.hit_bg_button.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
return InputOutcome::Action(Action::DemoteToBackground);
}
if self.hit_cancel_button.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
self.cancel_trigger_hint = Some(crate::app::actions::CancelTrigger::Mouse);
return InputOutcome::Action(Action::CancelTurn);
}
if self
.privacy_banner
.hit_opt_in
.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
return InputOutcome::Action(Action::PrivacyBannerOptIn);
}
if self
.privacy_banner
.hit_opt_out
.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
return InputOutcome::Action(Action::PrivacyBannerOptOut);
}
if self
.privacy_banner
.hit_terms
.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
return InputOutcome::Action(Action::OpenUrl(
crate::views::privacy_banner::PRIVACY_BANNER_TERMS_URL.to_string(),
));
}
if self
.privacy_banner
.hit_policy
.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
return InputOutcome::Action(Action::OpenUrl(
crate::views::privacy_banner::PRIVACY_BANNER_POLICY_URL.to_string(),
));
}
if self.hit_watching_cue.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
let was_visible = self.tasks.overlay.visible;
self.tasks.overlay.toggle();
self.tasks.on_state_change();
if self.tasks.overlay.focused {
self.set_active_pane(AgentPane::Tasks, false);
} else if self.active_pane == AgentPane::Tasks {
self.set_active_pane(AgentPane::Scrollback, false);
}
if !was_visible && !self.watching_cue_toast_shown {
self.watching_cue_toast_shown = true;
self.show_toast("Tip: Ctrl+G toggles the tasks pane");
}
return InputOutcome::Changed;
}
if self.hit_announcement_hide.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
return InputOutcome::Action(Action::AnnouncementsHide);
}
if self.hit_announcement_cta.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
return InputOutcome::Action(Action::AnnouncementsOpenCta(
xai_grok_telemetry::events::AnnouncementCtaSurface::Banner,
));
}
if self
.plugin_cta
.hit_dismiss
.contains(mouse.column, mouse.row)
&& let CtaPhase::Matched { name, .. } | CtaPhase::Error { name, .. } =
&self.plugin_cta.phase
{
let plugin_id = name.clone();
if let Err(e) = xai_grok_shell::config::add_dismissed_plugin_cta(&plugin_id) {
tracing::warn!(error = %e, "couldn't persist plugin CTA dismissal");
}
self.plugin_cta.dismissed.insert(plugin_id.clone());
xai_grok_telemetry::session_ctx::log_event(
xai_grok_telemetry::events::PluginCtaDismissed {
plugin_name: plugin_id,
},
);
self.plugin_cta.phase = CtaPhase::Hidden;
self.plugin_cta.hit_connect.clear();
self.plugin_cta.hit_dismiss.clear();
return InputOutcome::Changed;
}
if self
.plugin_cta
.hit_connect
.contains(mouse.column, mouse.row)
&& matches!(
self.plugin_cta.phase,
CtaPhase::Matched { .. } | CtaPhase::Error { .. }
)
{
self.connect_matched_plugin();
return InputOutcome::Changed;
}
if let Some(idx) = self.follow_up_chip_at(mouse.column, mouse.row)
&& let Some(text) = self
.follow_ups
.as_ref()
.and_then(|f| f.suggestions.get(idx))
.cloned()
{
return InputOutcome::Action(Action::SubmitFollowUp(text));
}
if self.hit_voice_stop_button.contains(mouse.column, mouse.row) {
return InputOutcome::Action(Action::VoiceToggle);
}
if self.hit_upgrade_cta.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
return InputOutcome::Action(Action::AnnouncementsOpenCta(
xai_grok_telemetry::events::AnnouncementCtaSurface::Header,
));
}
if self.hit_cwd.contains(mouse.column, mouse.row) {
let path = self.session.cwd.display().to_string();
self.copy_to_clipboard(&path);
return InputOutcome::Changed;
}
if self.hit_badge.contains(mouse.column, mouse.row) {
self.todo.overlay.toggle();
self.todo.on_state_change();
if self.todo.overlay.focused {
self.set_active_pane(AgentPane::Todo, false);
} else if self.active_pane == AgentPane::Todo {
self.set_active_pane(AgentPane::Scrollback, false);
}
return InputOutcome::Changed;
}
if self.hit_follow_indicator.contains(mouse.column, mouse.row) {
self.scrollback.goto_bottom();
return InputOutcome::Changed;
}
if let Some(hd_area) = self.history_dropdown_area
&& hd_area.contains((mouse.column, mouse.row).into())
&& self.prompt.history_search.is_active()
{
let vis_row = (mouse.row - hd_area.y) as usize;
let sel = self.prompt.history_search.selected;
let visible_rows = hd_area.height as usize;
let scroll_offset = if sel >= visible_rows {
sel - (visible_rows - 1)
} else {
0
};
let row_idx = scroll_offset + vis_row;
self.prompt.history_search.set_hovered(Some(row_idx));
if self.prompt.history_search.select_hovered()
&& let Some(text) = self
.prompt
.history_search
.selected_text()
.map(str::to_owned)
{
self.prompt.history_search.deactivate();
if self.prompt_input_mode != PromptInputMode::Feedback
&& self.prompt_input_mode != PromptInputMode::Remember
&& let Some(cmd) = text.strip_prefix("! ")
{
self.prompt_input_mode = PromptInputMode::Bash;
self.prompt.set_text(cmd);
} else if self.prompt_input_mode == PromptInputMode::Bash {
self.prompt_input_mode = PromptInputMode::Normal;
self.prompt.set_text(&text);
} else {
self.prompt.set_text(&text);
}
let len = self.prompt.textarea.text().len();
self.prompt.textarea.set_cursor(len);
self.prompt.file_search.clear_context();
}
self.set_active_pane(AgentPane::Prompt, false);
return InputOutcome::Changed;
}
if let Some(dd_area) = self.dropdown_items_area
&& dd_area.contains((mouse.column, mouse.row).into())
{
let has_scrollbar =
self.prompt.file_search.result_count() > dd_area.height as usize;
let on_scrollbar = has_scrollbar
&& mouse.column >= dd_area.x + dd_area.width.saturating_sub(2);
if on_scrollbar {
let click_frac =
(mouse.row - dd_area.y) as f64 / dd_area.height.max(1) as f64;
let target =
(click_frac * self.prompt.file_search.result_count() as f64) as usize;
let max = self.prompt.file_search.result_count().saturating_sub(1);
self.prompt.file_search.move_selection(
target.min(max) as isize - self.prompt.file_search.selected() as isize,
);
} else {
let row_idx = (mouse.row - dd_area.y) as usize
+ self.prompt.file_search.scroll_offset();
self.prompt.file_search.set_hovered(Some(row_idx));
if self.prompt.file_search.select_hovered() {
self.prompt.accept_file_search_result();
}
}
self.set_active_pane(AgentPane::Prompt, false);
return InputOutcome::Changed;
}
if let Some(dd_area) = self.slash_dropdown_items_area
&& dd_area.contains((mouse.column, mouse.row).into())
{
let snap = self.prompt.slash_snapshot();
let has_scrollbar = self.slash_dropdown_hit.has_scrollbar;
let on_scrollbar = has_scrollbar
&& mouse.column >= dd_area.x + dd_area.width.saturating_sub(2);
if on_scrollbar {
let click_frac =
(mouse.row - dd_area.y) as f64 / dd_area.height.max(1) as f64;
let target = (click_frac * snap.matches.len() as f64) as usize;
let max = snap.matches.len().saturating_sub(1);
let delta = target.min(max) as isize - snap.selected as isize;
self.prompt.slash_move_selection(delta);
self.prompt.slash_preview_current_selection();
} else if let Some(&item_idx) = self
.slash_dropdown_hit
.row_items
.get((mouse.row - dd_area.y) as usize)
{
self.prompt.set_slash_hovered(Some(item_idx));
if self.prompt.select_slash_hovered() {
self.prompt.slash_commit_preview();
self.prompt.accept_slash_completion(&self.session.models);
}
}
self.set_active_pane(AgentPane::Prompt, false);
return InputOutcome::Changed;
}
if let Some(dd_area) = self.completion_dropdown_items_area
&& dd_area.contains((mouse.column, mouse.row).into())
{
let dd = &self.prompt.suggestions.dropdown;
let has_scrollbar = dd.items.len() > dd_area.height as usize;
let on_scrollbar = has_scrollbar
&& mouse.column >= dd_area.x + dd_area.width.saturating_sub(2);
if on_scrollbar {
let click_frac =
(mouse.row - dd_area.y) as f64 / dd_area.height.max(1) as f64;
let total = self.prompt.suggestions.dropdown.items.len();
let target = (click_frac * total as f64) as usize;
let max = total.saturating_sub(1);
let delta = target.min(max) as isize
- self.prompt.suggestions.dropdown.selected as isize;
self.prompt.completion_dropdown_move(delta);
} else {
let offset = crate::views::completion_dropdown::scroll_offset(
&self.prompt.suggestions.dropdown,
);
let row_idx = (mouse.row - dd_area.y) as usize + offset;
self.prompt.set_completion_hovered(Some(row_idx));
if self.prompt.select_completion_hovered() {
self.accept_completion_dropdown_item();
}
}
self.set_active_pane(AgentPane::Prompt, false);
return InputOutcome::Changed;
}
if self.hit_scrollbar.contains(mouse.column, mouse.row) {
self.set_active_pane(AgentPane::Scrollback, false);
self.scrollbar_dragging = true;
self.apply_scrollbar_click(mouse.row);
return InputOutcome::Changed;
}
if let Some(rail) = self.timeline_rail.as_ref()
&& rail.rect.contains((mouse.column, mouse.row).into())
{
let target = rail
.hit(mouse.column, mouse.row)
.and_then(|hit| crate::views::timeline::chevron_target(rail, hit));
if let Some(turn_idx) = target {
self.set_active_pane(AgentPane::Scrollback, false);
self.scrollback.jump_to_turn(turn_idx);
return InputOutcome::Changed;
}
return InputOutcome::Unchanged;
}
if self.hit_sb_copy.contains(mouse.column, mouse.row) {
return InputOutcome::Action(Action::CopyBlockContent);
}
if self.hit_sb_view.contains(mouse.column, mouse.row) {
return InputOutcome::Action(Action::OpenBlockViewer);
}
if self.last_btw_area.area() > 0
&& self
.last_btw_area
.contains((mouse.column, mouse.row).into())
&& !self.hit_btw_close.contains(mouse.column, mouse.row)
{
self.set_active_pane(AgentPane::Prompt, false);
self.btw_focused = true;
if !has_native_link_hover()
&& is_link_modifier_held(mouse.modifiers)
&& !self.pos_occluded(mouse.column, mouse.row)
&& let Some(link) = self.visible_link_map.link_at(mouse.column, mouse.row)
{
self.pending_link_click = app_should_open_link_on_click(link)
.then(|| (mouse.column, mouse.row, link.target.clone()));
self.pending_scrollback_click = None;
return InputOutcome::Changed;
}
self.pending_link_click = None;
if self.begin_pending_btw_text_drag(mouse) {
return InputOutcome::Changed;
}
return InputOutcome::Unchanged;
}
match self.pane_areas.hit_test(mouse.column, mouse.row) {
Some(AgentPane::Todo) => {
self.set_active_pane(AgentPane::Todo, false);
self.todo.handle_mouse(
mouse.kind,
mouse.column,
mouse.row,
self.pane_areas.todo,
);
InputOutcome::Changed
}
Some(AgentPane::Queue) => {
if let Some(id) = self.queue.delete_click(mouse.column, mouse.row) {
let row = self.queue.row_ref(id);
let is_server = matches!(
row.as_ref().map(|r| r.origin),
Some(crate::views::queue_pane::QueueRowOrigin::Server)
);
if is_server {
if let (Some(_sid), Some(row)) =
(self.session.session_id.as_ref(), row)
&& let Some(server_id) = row.server_id
{
self.shared_queue.retain(|e| e.id != server_id);
if self.visible_queue_is_empty() {
self.hide_queue_pane();
}
return InputOutcome::Action(Action::QueueRemoveShared {
id: server_id,
expected_version: row.version,
});
}
return InputOutcome::Changed;
}
let was_drain_blocked = self.drain_blocked();
self.remove_local_queue_row(id);
if was_drain_blocked {
return InputOutcome::Action(Action::DrainQueue);
}
return InputOutcome::Changed;
}
if let Some(id) = self.queue.send_now_click(mouse.column, mouse.row)
&& self.session.state.is_turn_running()
&& let InputOutcome::Action(action) = self.force_interject_queue_row(id)
{
return InputOutcome::Action(action);
}
if let Some(id) = self.queue.edit_click(mouse.column, mouse.row)
&& (!matches!(self.prompt_mode, PromptMode::EditingQueued { .. })
|| self.set_active_pane(AgentPane::Queue, false))
{
let row = self.queue.row_ref(id);
let is_server = matches!(
row.as_ref().map(|r| r.origin),
Some(crate::views::queue_pane::QueueRowOrigin::Server)
);
self.enter_queue_edit(id, is_server, row);
return InputOutcome::Changed;
}
self.set_active_pane(AgentPane::Queue, false);
self.queue.handle_mouse(
mouse.kind,
mouse.column,
mouse.row,
self.pane_areas.queue,
);
InputOutcome::Changed
}
Some(AgentPane::Prompt) => {
let was_collapsed = self.active_pane != AgentPane::Prompt
&& self.scrollback.appearance().prompt.collapse_unfocused;
self.set_active_pane(AgentPane::Prompt, false);
self.btw_focused = false;
if !was_collapsed {
if matches!(self.prompt.handle_mouse(mouse), PromptEvent::Edited) {
self.prompt.refresh_slash(&self.session.models);
if let Some(eff) = self.notify_suggestion_text_changed() {
self.pending_effects.push(eff);
}
}
if self.prompt_click_is_double() {
if self.prompt.file_ref_near_cursor()
&& let Some((path, initial_range)) =
self.prompt.file_ref_element_at_cursor()
{
self.prompt.textarea.begin_undo_group();
self.open_line_viewer(
&std::path::PathBuf::from(path),
initial_range,
);
} else if self.prompt.expand_paste_element_at_cursor() {
self.prompt.refresh_slash(&self.session.models);
}
}
}
InputOutcome::Changed
}
Some(AgentPane::Tasks) => {
use crate::views::tasks_pane::TaskEntryId;
self.set_active_pane(AgentPane::Tasks, false);
for (entry_id, rect) in &self.tasks.kill_button_rects {
if rect.contains((mouse.column, mouse.row).into()) {
match entry_id {
TaskEntryId::BgTask(tid) => {
return InputOutcome::Action(Action::KillBgTask(
tid.clone(),
));
}
TaskEntryId::Agent(sid) => {
return InputOutcome::Action(Action::KillSubagent(
sid.clone(),
));
}
TaskEntryId::Scheduled(tid) => {
return InputOutcome::Action(Action::CancelScheduledTask(
tid.clone(),
));
}
TaskEntryId::Workflow(name) => {
return InputOutcome::Action(
Action::SendSlashCommandPreservingDraft(format!(
"/workflow stop {name}"
)),
);
}
}
}
}
for (entry_id, rect) in &self.tasks.view_button_rects {
if rect.contains((mouse.column, mouse.row).into()) {
match entry_id {
TaskEntryId::BgTask(tid) => {
let already_open = self
.block_viewer
.as_ref()
.and_then(|v| v.bg_task_id.as_deref())
== Some(tid);
if already_open {
self.block_viewer = None;
return InputOutcome::Changed;
}
if let Some(task) = self.session.bg_tasks.get(tid) {
let entry_id =
task.scrollback_entry_id.unwrap_or_else(|| {
crate::scrollback::entry::EntryId::new(0)
});
let is_running = task.status
== crate::app::agent::BgTaskStatus::Running;
self.block_viewer = Some(
crate::views::block_viewer::BlockViewerPane::for_bg_task(
entry_id,
tid,
&task.stdout,
is_running,
),
);
self.set_active_pane(AgentPane::Scrollback, true);
return InputOutcome::Changed;
}
}
TaskEntryId::Agent(sid) => {
if let Some(child_sid) = self
.subagent_sessions
.iter()
.find(|(_, info)| {
info.subagent_id.as_ref() == sid.as_str()
})
.map(|(k, _)| k.clone())
&& self.subagent_views.contains_key(&child_sid)
{
self.open_subagent_fullscreen(child_sid);
return InputOutcome::Changed;
}
}
TaskEntryId::Scheduled(tid) => {
if let Some(sid) = self
.session
.scheduled_tasks
.get(tid)
.and_then(|info| info.last_subagent_id.clone())
&& let Some(child_sid) = self
.subagent_sessions
.iter()
.find(|(_, info)| {
info.subagent_id.as_ref() == sid.as_str()
})
.map(|(k, _)| k.clone())
&& self.subagent_views.contains_key(&child_sid)
{
self.open_subagent_fullscreen(child_sid);
return InputOutcome::Changed;
}
}
TaskEntryId::Workflow(_) => {}
}
}
}
self.tasks.handle_mouse(
mouse.kind,
mouse.column,
mouse.row,
self.pane_areas.tasks,
);
if let Some(group) = self.tasks.selected_header_group() {
self.tasks.toggle_group(group);
return InputOutcome::Changed;
}
let now = Instant::now();
if let Some(last) = self.last_bg_click
&& now.duration_since(last).as_millis() < MULTI_CLICK_TIMEOUT_MS
{
if let Some(task_id) =
self.tasks.selected_task_id().map(|s| s.to_string())
&& let Some(task) = self.session.bg_tasks.get(&task_id)
{
let entry_id = task
.scrollback_entry_id
.unwrap_or_else(|| crate::scrollback::entry::EntryId::new(0));
let is_running =
task.status == crate::app::agent::BgTaskStatus::Running;
self.block_viewer =
Some(crate::views::block_viewer::BlockViewerPane::for_bg_task(
entry_id,
&task_id,
&task.stdout,
is_running,
));
self.set_active_pane(AgentPane::Scrollback, true);
self.last_bg_click = None;
return InputOutcome::Changed;
}
if let Some(child_sid) = self.tasks.selected_child_session_id()
&& self.subagent_views.contains_key(child_sid)
{
self.open_subagent_fullscreen(child_sid.to_string());
self.last_bg_click = None;
return InputOutcome::Changed;
}
if let Some(crate::views::tasks_pane::TaskEntry::Workflow {
name,
..
}) = self.tasks.selected_entry()
{
let name = name.clone();
self.open_workflow_detail(&name);
self.last_bg_click = None;
return InputOutcome::Changed;
}
}
self.last_bg_click = Some(now);
InputOutcome::Changed
}
Some(AgentPane::Catalog) => {
self.set_active_pane(AgentPane::Catalog, false);
self.catalog.handle_mouse(
mouse.kind,
mouse.column,
mouse.row,
self.pane_areas.catalog,
);
InputOutcome::Changed
}
Some(AgentPane::Scrollback) => {
self.set_active_pane(AgentPane::Scrollback, false);
if self.block_viewer.is_some() {
self.handle_block_viewer_mouse(mouse);
return InputOutcome::Changed;
}
self.persistent_text_selection = None;
self.table_selection_geometry = None;
self.selection_created_at = None;
if !has_native_link_hover()
&& is_link_modifier_held(mouse.modifiers)
&& !self.pos_occluded(mouse.column, mouse.row)
&& let Some(link) =
self.visible_link_map.link_at(mouse.column, mouse.row)
{
self.pending_link_click = app_should_open_link_on_click(link)
.then(|| (mouse.column, mouse.row, link.target.clone()));
self.pending_scrollback_click = None;
return InputOutcome::Changed;
}
self.pending_link_click = None;
self.last_question_click = None;
self.last_permission_click = None;
self.pending_scrollback_click = Some((mouse.column, mouse.row));
tracing::debug!(
event = "scrollback_mouse_down",
col = mouse.column,
row = mouse.row,
area = ?self.pane_areas.scrollback,
content_area = ?self.last_scrollback_selection_model.content_area,
ranges = self.last_scrollback_selection_model.ranges.len(),
blocks = self.last_scrollback_selection_model.visible_blocks.len(),
hovered_entry = ?self.hovered_entry,
"scrollback mouse down"
);
if self.begin_pending_text_drag(mouse) {
return InputOutcome::Changed;
}
self.begin_pending_block_drag(mouse);
self.deferred_text_press = Some((mouse.column, mouse.row));
InputOutcome::Changed
}
None => {
let scrollback = self.pane_areas.scrollback;
let prompt = self.pane_areas.prompt;
let above_prompt_strip = self.block_viewer.is_none()
&& scrollback.area() > 0
&& prompt.area() > 0
&& mouse.row >= scrollback.y.saturating_add(scrollback.height)
&& mouse.row < prompt.y;
if above_prompt_strip {
self.persistent_text_selection = None;
self.table_selection_geometry = None;
self.selection_created_at = None;
self.deferred_text_press = Some((mouse.column, mouse.row));
InputOutcome::Changed
} else {
InputOutcome::Unchanged
}
}
}
}
MouseEventKind::Drag(MouseButton::Left) => {
self.pending_link_click = None;
tracing::debug!(
event = "scrollback_mouse_drag",
col = mouse.column,
row = mouse.row,
pending = ?self.pending_text_drag,
active = ?self.drag_selection,
"scrollback mouse drag"
);
self.handle_scrollback_drag_motion(mouse)
}
MouseEventKind::Up(MouseButton::Left) => {
self.left_mouse_down = false;
tracing::debug!(
event = "scrollback_mouse_up",
col = mouse.column,
row = mouse.row,
pending = ?self.pending_text_drag,
active = ?self.drag_selection,
"scrollback mouse up"
);
if self.scrollbar_dragging {
self.scrollbar_dragging = false;
}
self.deferred_text_press = None;
if self.drag_selection.is_some() {
return if self.finish_text_drag() {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
};
}
if self.block_drag_selection.is_some() {
return if self.finish_block_drag() {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
};
}
let had_pending_text_drag = self.pending_text_drag.take().is_some();
let _had_pending_block_drag = self.pending_block_drag.take().is_some();
if let Some((lc, lr, target)) = self.pending_link_click.take()
&& mouse.column == lc
&& mouse.row == lr
{
return InputOutcome::Action(Action::OpenLink(target));
}
if self.active_pane == AgentPane::Scrollback {
if let Some((click_col, click_row)) = self.pending_scrollback_click.take() {
let now = Instant::now();
if is_text_selection_on_double_click() {
let exact_text_hit = self
.last_scrollback_selection_model
.hit_test_text_exact(click_col, click_row);
if let Some(hit) = exact_text_hit {
let text_click_count = self.count_text_click(now, &hit);
let handled = match text_click_count {
1 => {
self.persistent_text_selection = None;
self.table_selection_geometry = None;
self.selection_created_at = None;
if hit.entry_idx != BTW_OVERLAY_ENTRY_IDX {
self.scrollback.set_selected(Some(hit.entry_idx));
}
true
}
2 => {
self.select_word_at(&hit);
true
}
3 => {
if !self.select_cell_at(&hit) {
self.select_line_at(&hit);
}
true
}
_ => false,
};
if handled {
self.last_text_click = Some(TextClickState {
time: now,
entry_idx: hit.entry_idx,
range_id: hit.range_id,
block_line_idx: hit.block_line_idx,
col_within_range: hit.col_within_range,
click_count: text_click_count,
});
self.last_click = None;
return InputOutcome::Changed;
}
self.last_text_click = None;
}
}
self.last_text_click = None;
if let Some(outcome) = self.handle_inline_media_click(click_col, click_row)
{
return outcome;
}
let hit_idx = self
.scrollback
.entry_index_at_screen_row(click_row, self.pane_areas.scrollback);
if let Some(idx) = hit_idx {
let credit_click = self
.scrollback
.entry(idx)
.and_then(|entry| {
if let crate::scrollback::block::RenderBlock::CreditLimit(
ref blk,
) = entry.block
{
use crate::scrollback::blocks::CreditLimitCardAction;
let choice = match blk.action {
CreditLimitCardAction::PurchaseCredits => {
xai_grok_telemetry::events::CreditLimitChoice::PurchaseCredits
}
CreditLimitCardAction::EnablePayg
| CreditLimitCardAction::IncreasePaygLimit => {
xai_grok_telemetry::events::CreditLimitChoice::PayAsYouGo
}
};
Some((blk.url.clone(), choice))
} else {
None
}
});
if let Some((url, choice)) = credit_click
&& let Some((area, _, _)) = self
.scrollback
.entry_screen_area(idx, self.pane_areas.scrollback)
{
let url_row = area.y + area.height.saturating_sub(2);
if click_row >= url_row {
self.scrollback.set_selected(Some(idx));
xai_grok_telemetry::session_ctx::log_event(xai_grok_telemetry::events::CreditLimitUpsellClicked {
surface: xai_grok_telemetry::events::CreditLimitUpsellSurface::InlineCard,
choice,
});
self.open_url_or_show(&url);
self.last_click = None;
return InputOutcome::Changed;
}
}
let selectable = self
.scrollback
.get(idx)
.is_some_and(|e| e.block.is_selectable());
if selectable {
let header_row_click = self
.scrollback
.get_cached_entry_layouts()
.and_then(|l| l.get(idx))
.is_some_and(|i| {
i.verb_group_header && i.group_collapse_header
})
&& self
.scrollback
.entry_screen_area(idx, self.pane_areas.scrollback)
.is_some_and(|(a, _, _)| click_row == a.y);
let (last_click, show_word_select_tip) =
self.handle_scrollback_click(now, idx, header_row_click);
self.last_click = last_click;
if show_word_select_tip {
return InputOutcome::Action(Action::ShowWordSelectTip);
}
return InputOutcome::Changed;
}
} else if let Some((last_time, last_idx, last_count)) = self.last_click
&& now.duration_since(last_time).as_millis() < MULTI_CLICK_TIMEOUT_MS
&& last_count >= 2
{
let (last_click, show_word_select_tip) =
self.handle_scrollback_click(now, last_idx, false);
self.last_click = last_click;
if show_word_select_tip {
return InputOutcome::Action(Action::ShowWordSelectTip);
}
return InputOutcome::Changed;
}
if had_pending_text_drag {
tracing::debug!(
event = "pending_text_drag_released_without_click_target",
col = click_col,
row = click_row,
"scrollback pending text drag released without valid click target"
);
}
}
} else {
self.pending_scrollback_click = None;
}
if self.active_pane == AgentPane::Todo {
self.todo.handle_mouse(
mouse.kind,
mouse.column,
mouse.row,
self.pane_areas.todo,
);
}
if self.active_pane == AgentPane::Prompt {
let event = self.prompt.handle_mouse(mouse);
if matches!(event, PromptEvent::Edited)
&& let Some(eff) = self.notify_suggestion_text_changed()
{
self.pending_effects.push(eff);
}
InputOutcome::Changed
} else {
InputOutcome::Unchanged
}
}
MouseEventKind::Moved => {
tracing::debug!(
event = "scrollback_mouse_moved",
col = mouse.column,
row = mouse.row,
pending = ?self.pending_text_drag,
active = ?self.drag_selection,
left_mouse_down = self.left_mouse_down,
"scrollback mouse moved"
);
if self.left_mouse_down
&& (self.pending_text_drag.is_some()
|| self.drag_selection.is_some()
|| self.pending_block_drag.is_some()
|| self.block_drag_selection.is_some()
|| self.deferred_text_press.is_some())
{
self.pending_link_click = None;
let outcome = self.handle_scrollback_drag_motion(mouse);
if !matches!(outcome, InputOutcome::Unchanged) {
return outcome;
}
}
let suppress_scrollback_hover = self.pending_text_drag.is_some()
|| self.drag_selection.is_some()
|| self.pending_block_drag.is_some()
|| self.block_drag_selection.is_some()
|| self.deferred_text_press.is_some();
let hit = self.pane_areas.hit_test(mouse.column, mouse.row);
let on_rail = self
.timeline_rail
.as_ref()
.is_some_and(|r| r.rect.contains((mouse.column, mouse.row).into()));
let new_timeline_hover = self
.timeline_rail
.as_ref()
.and_then(|r| r.hit(mouse.column, mouse.row));
let new_hover = if on_rail || suppress_scrollback_hover {
None
} else {
hit.and_then(|pane| match pane {
AgentPane::Scrollback => self
.scrollback
.entry_index_at_screen_row(mouse.row, self.pane_areas.scrollback)
.filter(|&idx| {
self.scrollback
.get(idx)
.is_some_and(|e| e.block.is_selectable())
}),
AgentPane::Todo
| AgentPane::Queue
| AgentPane::Prompt
| AgentPane::Tasks
| AgentPane::Catalog => None,
})
};
let new_prompt_hover = hit == Some(AgentPane::Prompt)
&& self.scrollback.appearance().prompt.mouse_hover;
let old_mouse_pos = self.last_mouse_pos;
self.last_mouse_pos = (mouse.column, mouse.row);
if old_mouse_pos != self.last_mouse_pos {
self.last_mouse_moved_at = Some(Instant::now());
}
let mut changed =
new_hover != self.hovered_entry || new_prompt_hover != self.hovered_prompt;
if new_hover.is_some()
&& old_mouse_pos != self.last_mouse_pos
&& let Some(idx) = new_hover
&& let Some(entry) = self.scrollback.get(idx)
&& (entry.block.is_user_prompt()
|| matches!(
entry.block,
crate::scrollback::block::RenderBlock::AgentMessage(_)
| crate::scrollback::block::RenderBlock::Btw(_)
)
|| entry.hook_data.as_ref().is_some_and(|hd| hd.has_content()))
{
changed = true;
}
self.hovered_entry = new_hover;
self.hovered_prompt = new_prompt_hover;
if new_timeline_hover != self.timeline_hover {
self.timeline_hover = new_timeline_hover;
self.sync_timeline_hover_preview();
changed = true;
}
changed |= self
.set_hovered_follow_up_chip(self.follow_up_chip_at(mouse.column, mouse.row));
changed |= self.hit_badge.update_hover(mouse.column, mouse.row);
changed |= self.hit_context.update_hover(mouse.column, mouse.row);
changed |= self.hit_credits.update_hover(mouse.column, mouse.row);
changed |= self.hit_todo_close.update_hover(mouse.column, mouse.row);
changed |= self.hit_queue_close.update_hover(mouse.column, mouse.row);
changed |= self.hit_queue_badge.update_hover(mouse.column, mouse.row);
if matches!(
self.pane_areas.hit_test(mouse.column, mouse.row),
Some(AgentPane::Queue)
) {
changed |= self.queue.update_delete_hover(mouse.column, mouse.row);
changed |= self.queue.update_send_now_hover(mouse.column, mouse.row);
changed |= self.queue.update_edit_hover(mouse.column, mouse.row);
changed |= self.queue.update_row_hover(mouse.column, mouse.row);
} else {
changed |= self.queue.clear_delete_hover();
changed |= self.queue.clear_send_now_hover();
changed |= self.queue.clear_edit_hover();
changed |= self.queue.clear_row_hover();
}
changed |= self.hit_plan_button.update_hover(mouse.column, mouse.row);
changed |= self
.hit_plan_approval_status
.update_hover(mouse.column, mouse.row);
changed |= self
.hit_follow_indicator
.update_hover(mouse.column, mouse.row);
changed |= self.hit_cancel_button.update_hover(mouse.column, mouse.row);
changed |= self.hit_bg_button.update_hover(mouse.column, mouse.row);
changed |= self.hit_watching_cue.update_hover(mouse.column, mouse.row);
changed |= self
.hit_announcement_hide
.update_hover(mouse.column, mouse.row);
changed |= self
.hit_announcement_cta
.update_hover(mouse.column, mouse.row);
changed |= self
.privacy_banner
.hit_opt_in
.update_hover(mouse.column, mouse.row);
changed |= self
.privacy_banner
.hit_opt_out
.update_hover(mouse.column, mouse.row);
changed |= self
.privacy_banner
.hit_terms
.update_hover(mouse.column, mouse.row);
changed |= self
.privacy_banner
.hit_policy
.update_hover(mouse.column, mouse.row);
changed |= self
.plugin_cta
.hit_connect
.update_hover(mouse.column, mouse.row);
changed |= self
.plugin_cta
.hit_dismiss
.update_hover(mouse.column, mouse.row);
changed |= self
.hit_voice_stop_button
.update_hover(mouse.column, mouse.row);
changed |= self.hit_bg_status.update_hover(mouse.column, mouse.row);
changed |= self.hit_goal_status.update_hover(mouse.column, mouse.row);
changed |= self.hit_bg_close.update_hover(mouse.column, mouse.row);
changed |= self.hit_catalog_close.update_hover(mouse.column, mouse.row);
changed |= self.hit_cwd.update_hover(mouse.column, mouse.row);
changed |= self.hit_upgrade_cta.update_hover(mouse.column, mouse.row);
{
let new_kill = self
.tasks
.kill_button_rects
.iter()
.find(|(_, rect)| rect.contains((mouse.column, mouse.row).into()))
.map(|(tid, _)| tid.clone());
if new_kill != self.tasks.hovered_kill {
self.tasks.hovered_kill = new_kill;
changed = true;
}
let new_view = self
.tasks
.view_button_rects
.iter()
.find(|(_, rect)| rect.contains((mouse.column, mouse.row).into()))
.map(|(tid, _)| tid.clone());
if new_view != self.tasks.hovered_view {
self.tasks.hovered_view = new_view;
changed = true;
}
}
changed |= self.hit_sb_copy.update_hover(mouse.column, mouse.row);
changed |= self.hit_sb_view.update_hover(mouse.column, mouse.row);
if let Some(hd_area) = self.history_dropdown_area {
let hs_count = self.prompt.history_search.result_count();
let has_sb = hs_count > hd_area.height as usize;
let on_sb =
has_sb && mouse.column >= hd_area.x + hd_area.width.saturating_sub(2);
let new_hover = if hd_area.contains((mouse.column, mouse.row).into()) && !on_sb
{
let vis_row = (mouse.row - hd_area.y) as usize;
let sel = self.prompt.history_search.selected;
let visible_rows = hd_area.height as usize;
let scroll_offset = if sel >= visible_rows {
sel - (visible_rows - 1)
} else {
0
};
Some(scroll_offset + vis_row)
} else {
None
};
changed |= self.prompt.history_search.set_hovered(new_hover);
} else {
changed |= self.prompt.history_search.set_hovered(None);
}
if let Some(dd_area) = self.dropdown_items_area {
let has_scrollbar =
self.prompt.file_search.result_count() > dd_area.height as usize;
let on_scrollbar = has_scrollbar
&& mouse.column >= dd_area.x + dd_area.width.saturating_sub(2);
let new_dd_hover =
if dd_area.contains((mouse.column, mouse.row).into()) && !on_scrollbar {
Some(
(mouse.row - dd_area.y) as usize
+ self.prompt.file_search.scroll_offset(),
)
} else {
None
};
changed |= self.prompt.file_search.set_hovered(new_dd_hover);
} else {
changed |= self.prompt.file_search.set_hovered(None);
}
if let Some(dd_area) = self.slash_dropdown_items_area {
let has_scrollbar = self.slash_dropdown_hit.has_scrollbar;
let on_scrollbar = has_scrollbar
&& mouse.column >= dd_area.x + dd_area.width.saturating_sub(2);
let new_hover =
if dd_area.contains((mouse.column, mouse.row).into()) && !on_scrollbar {
self.slash_dropdown_hit
.row_items
.get((mouse.row - dd_area.y) as usize)
.copied()
} else {
None
};
changed |= self.prompt.set_slash_hovered(new_hover);
} else {
changed |= self.prompt.set_slash_hovered(None);
}
if let Some(dd_area) = self.completion_dropdown_items_area {
let dd = &self.prompt.suggestions.dropdown;
let has_scrollbar = dd.items.len() > dd_area.height as usize;
let on_scrollbar = has_scrollbar
&& mouse.column >= dd_area.x + dd_area.width.saturating_sub(2);
let new_hover =
if dd_area.contains((mouse.column, mouse.row).into()) && !on_scrollbar {
let offset = crate::views::completion_dropdown::scroll_offset(dd);
Some((mouse.row - dd_area.y) as usize + offset)
} else {
None
};
changed |= self.prompt.set_completion_hovered(new_hover);
} else {
changed |= self.prompt.set_completion_hovered(None);
}
changed |= self.update_hovered_link(is_link_modifier_held(mouse.modifiers));
if changed {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
}
}
_ => InputOutcome::Unchanged,
}
}
/// Apply a scrollbar click/drag at the given screen row.
///
/// Uses [`scrollbar_click_to_offset`] (same math as the thumb renderer)
/// so the resulting scroll position is the exact inverse of where the
/// thumb would be drawn for that offset.
pub(super) fn apply_scrollbar_click(&mut self, screen_y: u16) -> bool {
use crate::render::scrollbar::{ScrollbarClickResult, scrollbar_click_to_offset};
let Some(sb) = self.hit_scrollbar.rect else {
return false;
};
if sb.height == 0 {
return false;
}
let cell_index = screen_y.saturating_sub(sb.y);
let (_, viewport_height, total_height) = self.scrollback.scroll_info();
let scale = if total_height > u16::MAX as usize {
(total_height / u16::MAX as usize) + 1
} else {
1
};
let scaled_total = (total_height / scale) as u16;
let result =
scrollbar_click_to_offset(cell_index, sb.height, scaled_total, viewport_height);
match result {
ScrollbarClickResult::Top => {
self.scrollback.goto_top();
}
ScrollbarClickResult::Bottom => {
self.scrollback.goto_bottom();
}
ScrollbarClickResult::Offset(offset) => {
self.scrollback
.set_scroll_offset(offset.saturating_mul(scale));
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::agent::AgentState;
use crate::app::agent_view::PromptMode;
use crate::app::agent_view::test_fixtures::{
make_running_agent, running_agent_local_only, test_pasted_image,
};
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
/// Render the queue, locate the action-button cell where `hit` resolves
/// to `selected_id`, and dispatch a left-click on it; returns the outcome.
fn click_queue_button(
agent: &mut AgentView,
selected_id: u64,
hit: impl Fn(&AgentView, u16, u16) -> Option<u64>,
) -> InputOutcome {
agent.queue.list_state.select_by_id(selected_id);
let area = Rect::new(0, 0, 80, 6);
let mut buf = Buffer::empty(area);
let layout_cfg = crate::appearance::LayoutConfig::default();
let running = agent.session.state.is_turn_running();
agent
.queue
.render(area, &mut buf, true, &layout_cfg, None, running);
agent.pane_areas.queue = area;
let mut found = None;
'find: for row in area.y..area.y + area.height {
for col in area.x..area.x + area.width {
if hit(agent, col, row) == Some(selected_id) {
found = Some((col, row));
break 'find;
}
}
}
let (col, row) = found.expect("queue button must render for the selected row");
agent.handle_mouse(&MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::empty(),
})
}
/// Left-click the row's `[Interject]` (send-now) button.
fn click_send_now(agent: &mut AgentView, selected_id: u64) -> InputOutcome {
click_queue_button(agent, selected_id, |a, c, r| a.queue.send_now_click(c, r))
}
/// Left-click the row's `[cancel]` (delete) button.
fn click_delete(agent: &mut AgentView, selected_id: u64) -> InputOutcome {
click_queue_button(agent, selected_id, |a, c, r| a.queue.delete_click(c, r))
}
/// Left-click the row's `[edit]` button.
fn click_edit(agent: &mut AgentView, selected_id: u64) -> InputOutcome {
click_queue_button(agent, selected_id, |a, c, r| a.queue.edit_click(c, r))
}
/// Mouse "Send now" (interject) on the last local row keeps the pane open
/// when a server row remains — the third sibling site of the same fix.
#[test]
fn mouse_send_now_last_local_row_keeps_pane_open_when_server_remains() {
let mut agent = make_running_agent();
agent.active_pane = AgentPane::Queue;
agent.session.pending_prompts[0]
.images
.push(test_pasted_image());
let ids = agent.queue.entry_ids();
let outcome = click_send_now(&mut agent, ids[1]);
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, images }) => {
assert_eq!(text, "local one");
assert_eq!(images.len(), 1, "row image must ride the send-now");
}
other => panic!("expected SendPromptNow action, got {other:?}"),
}
assert!(agent.session.pending_prompts.is_empty());
assert_eq!(agent.shared_queue.len(), 1);
assert!(agent.queue.overlay.visible);
assert!(agent.queue.overlay.focused);
assert_eq!(agent.active_pane, AgentPane::Queue);
}
/// Hide via the mouse "Send now" path (site 3): with no server rows left,
/// interjecting the last local row empties the merged view → hide.
#[test]
fn mouse_send_now_last_local_row_hides_pane_when_shared_queue_empty() {
let mut agent = running_agent_local_only();
let ids = agent.queue.entry_ids();
assert_eq!(ids.len(), 1);
let outcome = click_send_now(&mut agent, ids[0]);
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, .. }) => {
assert_eq!(text, "local one")
}
other => panic!("expected SendPromptNow action, got {other:?}"),
}
assert!(agent.session.pending_prompts.is_empty());
assert!(!agent.queue.overlay.visible);
assert!(!agent.queue.overlay.focused);
assert_eq!(agent.active_pane, AgentPane::Scrollback);
}
/// Send-now `[Interject]` on the lone local row while it is being
/// DIRTY-edited: the removal must discard the edit via the canonical
/// helper — the old inline removal stranded `EditingQueued` and armed
/// the invisible modal.
#[test]
fn mouse_send_now_edited_lone_local_row_discards_edit_without_orphaned_modal() {
let mut agent = running_agent_local_only();
let ids = agent.queue.entry_ids();
agent.stashed_prompt = Some(crate::views::prompt_widget::StashedPrompt {
text: "draft".into(),
cursor: 0,
images: Vec::new(),
chip_elements: Vec::new(),
image_counter: 0,
image_undo_stash: Vec::new(),
});
agent.prompt_mode = PromptMode::EditingQueued {
id: ids[0],
original: "local one".into(),
server_id: None,
kind: crate::app::agent::QueueEntryKind::Prompt,
};
agent.prompt.set_text("local one EDITED");
let outcome = click_send_now(&mut agent, ids[0]);
match outcome {
InputOutcome::Action(Action::SendPromptNow { text, .. }) => {
assert_eq!(text, "local one")
}
other => panic!("expected SendPromptNow action, got {other:?}"),
}
assert!(agent.session.pending_prompts.is_empty());
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
assert!(
agent.active_modal.is_none(),
"orphaned EditConfirm would brick input"
);
assert_eq!(agent.prompt.text(), "draft");
assert!(!agent.queue.overlay.visible);
}
/// Mouse `[cancel]` of the FRONT local row being edited while idle:
/// discarding the edit releases the drain block, so the click must kick
/// `DrainQueue` like the modal Delete arm (the row behind must not sit
/// stuck until an unrelated trigger).
#[test]
fn mouse_delete_edited_front_row_while_idle_kicks_drain() {
let mut agent = running_agent_local_only();
agent.session.state = AgentState::Idle;
agent.session.enqueue_prompt("local two".to_string());
agent.queue.sync_from_merged(
&agent.session.pending_prompts,
&agent.shared_queue,
None,
None,
&agent.send_now_painted_blocks,
);
let ids = agent.queue.entry_ids();
agent.stashed_prompt = Some(crate::views::prompt_widget::StashedPrompt {
text: "draft".into(),
cursor: 0,
images: Vec::new(),
chip_elements: Vec::new(),
image_counter: 0,
image_undo_stash: Vec::new(),
});
agent.prompt_mode = PromptMode::EditingQueued {
id: ids[0],
original: "local one".into(),
server_id: None,
kind: crate::app::agent::QueueEntryKind::Prompt,
};
agent.prompt.set_text("local one EDITED");
let outcome = click_delete(&mut agent, ids[0]);
assert!(
matches!(outcome, InputOutcome::Action(Action::DrainQueue)),
"deleting the edited front row must kick the drain, got {outcome:?}"
);
assert_eq!(agent.session.pending_prompts.len(), 1);
assert_eq!(agent.session.pending_prompts[0].text, "local two");
assert!(matches!(agent.prompt_mode, PromptMode::Normal));
assert!(agent.active_modal.is_none());
assert_eq!(agent.prompt.text(), "draft");
}
/// Mouse `[edit]` on a queued row enters the same queued-edit flow as the
/// keyboard `e` (`QueueEvent::EditSelected` → `enter_queue_edit`): the
/// composer loads the row text and the prompt pane takes focus in
/// `EditingQueued` mode, leaving the row itself queued.
#[test]
fn mouse_edit_click_enters_queued_edit_mode() {
let mut agent = running_agent_local_only();
let ids = agent.queue.entry_ids();
let outcome = click_edit(&mut agent, ids[0]);
assert!(
matches!(outcome, InputOutcome::Changed),
"edit click redraws without dispatching an action, got {outcome:?}"
);
match &agent.prompt_mode {
PromptMode::EditingQueued {
id,
original,
server_id,
..
} => {
assert_eq!(*id, ids[0]);
assert_eq!(original, "local one");
assert!(
server_id.is_none(),
"local row must not take the server edit path"
);
}
other => panic!("expected EditingQueued, got {other:?}"),
}
assert_eq!(agent.prompt.text(), "local one");
assert_eq!(agent.active_pane, AgentPane::Prompt);
assert_eq!(agent.session.pending_prompts.len(), 1);
}
/// Clicking another row's `[edit]` while a DIRTY queued edit is active
/// must not re-enter `enter_queue_edit` — that would bypass the
/// dirty-edit lock and overwrite `stashed_prompt`, so Esc would restore
/// the edit text instead of the user's original draft. The click falls
/// through to the pane switch, which the lock blocks.
#[test]
fn mouse_edit_click_during_dirty_edit_preserves_first_edit_and_stash() {
let mut agent = make_running_agent();
let ids = agent.queue.entry_ids();
agent.stashed_prompt = Some(crate::views::prompt_widget::StashedPrompt {
text: "draft".into(),
cursor: 0,
images: Vec::new(),
chip_elements: Vec::new(),
image_counter: 0,
image_undo_stash: Vec::new(),
});
agent.prompt_mode = PromptMode::EditingQueued {
id: ids[1],
original: "local one".into(),
server_id: None,
kind: crate::app::agent::QueueEntryKind::Prompt,
};
agent.prompt.set_text("local one EDITED");
agent.active_pane = AgentPane::Prompt;
let outcome = click_edit(&mut agent, ids[0]);
assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}");
match &agent.prompt_mode {
PromptMode::EditingQueued { id, original, .. } => {
assert_eq!(*id, ids[1], "the first edit's target row must survive");
assert_eq!(original, "local one");
}
other => panic!("expected the first edit to stay active, got {other:?}"),
}
assert_eq!(agent.prompt.text(), "local one EDITED");
assert_eq!(
agent.stashed_prompt.as_ref().map(|s| s.text.as_str()),
Some("draft"),
"the pre-edit draft must survive for Esc-restore"
);
assert!(
agent.pending_effects.is_empty(),
"no hold effect may be emitted for the clicked row"
);
}
/// Same guard for a dirty SERVER-row edit: clicking another row's
/// `[edit]` must not replace the edit (which would strand the first
/// row's combine hold) nor emit a second `QueueHoldEdit`.
#[test]
fn mouse_edit_click_during_dirty_server_edit_keeps_hold_target() {
let mut agent = make_running_agent();
let ids = agent.queue.entry_ids();
agent.prompt_mode = PromptMode::EditingQueued {
id: ids[0],
original: "server one".into(),
server_id: Some("p1".into()),
kind: crate::app::agent::QueueEntryKind::Prompt,
};
agent.prompt.set_text("server one EDITED");
agent.active_pane = AgentPane::Prompt;
let outcome = click_edit(&mut agent, ids[1]);
assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}");
match &agent.prompt_mode {
PromptMode::EditingQueued { id, server_id, .. } => {
assert_eq!(*id, ids[0], "the held server row must stay the edit target");
assert_eq!(server_id.as_deref(), Some("p1"));
}
other => panic!("expected the server edit to stay active, got {other:?}"),
}
assert_eq!(agent.prompt.text(), "server one EDITED");
assert!(
agent.pending_effects.is_empty(),
"no second QueueHoldEdit may be emitted while one row is held"
);
}
/// Clicking another row's `[edit]` while a CLEAN (unchanged) edit is
/// active must open the clicked row's edit on the SAME click: the
/// canonical pane switch exits the clean edit — releasing its server
/// combine hold — and the arm then enters the clicked row instead of
/// letting the click die on the pane switch.
#[test]
fn mouse_edit_click_during_clean_edit_switches_to_clicked_row() {
let mut agent = make_running_agent();
let ids = agent.queue.entry_ids();
agent.stashed_prompt = Some(crate::views::prompt_widget::StashedPrompt {
text: "draft".into(),
cursor: 0,
images: Vec::new(),
chip_elements: Vec::new(),
image_counter: 0,
image_undo_stash: Vec::new(),
});
agent.prompt_mode = PromptMode::EditingQueued {
id: ids[0],
original: "server one".into(),
server_id: Some("p1".into()),
kind: crate::app::agent::QueueEntryKind::Prompt,
};
agent.prompt.set_text("server one");
agent.active_pane = AgentPane::Prompt;
let outcome = click_edit(&mut agent, ids[1]);
assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}");
match &agent.prompt_mode {
PromptMode::EditingQueued {
id,
original,
server_id,
..
} => {
assert_eq!(*id, ids[1], "one click must open the clicked row's edit");
assert_eq!(original, "local one");
assert!(server_id.is_none());
}
other => panic!("expected EditingQueued for the clicked row, got {other:?}"),
}
assert_eq!(agent.prompt.text(), "local one");
assert_eq!(agent.active_pane, AgentPane::Prompt);
assert!(
agent.pending_effects.iter().any(|e| matches!(
e,
crate::app::actions::Effect::QueueReleaseEdit { id, .. } if id == "p1"
)),
"clean exit must release the held server row, effects = {:?}",
agent.pending_effects
);
assert_eq!(
agent.stashed_prompt.as_ref().map(|s| s.text.as_str()),
Some("draft")
);
}
/// Clicking `[edit]` on a server row still awaiting its enqueue
/// confirmation (an optimistic echo) is ignored: the shell has no row to
/// hold yet, so the `hold_edit` would no-op and the later-confirmed row
/// could be absorbed mid-edit.
#[test]
fn mouse_edit_click_on_optimistic_server_row_is_ignored() {
let mut agent = make_running_agent();
agent.optimistic_queue_ids.insert("p1".into());
let ids = agent.queue.entry_ids();
let outcome = click_edit(&mut agent, ids[0]);
assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}");
assert!(
matches!(agent.prompt_mode, PromptMode::Normal),
"an unconfirmed echo must not be editable"
);
assert_eq!(agent.prompt.text(), "");
assert!(
agent.pending_effects.is_empty(),
"no QueueHoldEdit may be emitted for a row the shell doesn't have"
);
}
/// A synthetic left-click on a rendered follow-up chip yields the LITERAL
/// `SubmitFollowUp` action (never a slash-command path).
#[test]
fn follow_up_chip_click_yields_literal_submit_action() {
use crate::app::agent_view::test_fixtures::make_agent;
let mut agent = make_agent();
agent.apply_follow_ups("resp-1".into(), vec!["/always-approve".into()]);
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
let theme = crate::theme::Theme::current();
let suggestions = agent.follow_ups.as_ref().unwrap().suggestions.clone();
agent.follow_up_chips =
crate::views::agent::render_follow_ups(area, &mut buf, &theme, &suggestions, None);
let r = agent.follow_up_chips[0];
let outcome = agent.handle_mouse(&MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: r.x + 1,
row: r.y,
modifiers: KeyModifiers::empty(),
});
match outcome {
InputOutcome::Action(Action::SubmitFollowUp(text)) => {
assert_eq!(text, "/always-approve", "even a /-prefixed chip is literal");
}
other => panic!("expected SubmitFollowUp, got {other:?}"),
}
}
/// Double-click on a `[Pasted: N lines]` chip expands it into plain
/// editable text; the first click only places the cursor on the chip.
#[test]
fn double_click_on_paste_chip_expands_it() {
use crate::app::agent_view::test_fixtures::make_agent;
use crate::views::prompt_widget::PromptStyle;
let mut agent = make_agent();
agent.active_pane = AgentPane::Prompt;
let text = "line1\nline2\nline3\nline4";
agent.prompt.handle_paste(text);
assert_eq!(agent.prompt.textarea.elements().len(), 1);
let area = Rect::new(0, 0, 40, 4);
agent.pane_areas.prompt = area;
let mut buf = Buffer::empty(area);
let style = PromptStyle {
chrome: false,
vpad_top: 0,
..Default::default()
};
agent.prompt.draw(&mut buf, area, None, &style, None, None);
let ta = agent.prompt.textarea_area();
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: ta.x + 1,
row: ta.y,
modifiers: KeyModifiers::empty(),
};
agent.handle_mouse(&click);
assert_eq!(
agent.prompt.textarea.elements().len(),
1,
"single click must not expand the chip"
);
agent.handle_mouse(&click);
assert!(
agent.prompt.textarea.elements().is_empty(),
"double-click must expand the chip"
);
assert_eq!(agent.prompt.textarea.text(), text);
}
}