Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/agent_view/selection.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

2899 lines
116 KiB
Rust

//! Scrollback text/block selection: click counting, word/line select,
//! drag latches, drag autoscroll ticks, and selection-highlight timers.
use super::{
AgentPane, AgentView, DEFAULT_SELECTION_HIGHLIGHT_DURATION_MS, MULTI_CLICK_TIMEOUT_MS,
};
use crate::app::app_view::InputOutcome;
use crate::scrollback::table_geometry::{CellRef, TableGeometry};
use crate::scrollback::text_selection::{
ActiveBlockDrag, ActiveTextDrag, AutoScrollDirection, PendingBlockDrag, PendingTextDrag,
PersistentTextSelection, RangeHit, ResolvedSelectionModel, SelectionEndpoint, SelectionKind,
SelectionOrigin, TableSelectionGeometry, apply_selection_boundary,
block_drag_threshold_exceeded, compute_autoscroll, configured_word_separators,
drag_threshold_exceeded, reconstruct_full_selection_text_with_boundaries,
reconstruct_selection_text, reconstruct_selection_text_with_boundaries,
reconstruct_table_selection_text, resolve_table_drag_kind, url_range_at_col,
word_boundaries_at_col,
};
use crate::views::btw_overlay::BTW_OVERLAY_ENTRY_IDX;
use crossterm::event::MouseEvent;
use std::time::{Duration, Instant};
/// Two fold/nav double-clicks on assistant text within this window count as a
/// repeated selection attempt and fire the word-select tip. Must be well over
/// [`MULTI_CLICK_TIMEOUT_MS`] so it measures separate gestures, and short
/// enough that the second gesture plausibly continues the first intent.
const WORD_SELECT_REPEAT_WINDOW: Duration = Duration::from_secs(10);
fn semantic_selection_at(
model: &ResolvedSelectionModel,
hit: &RangeHit,
separators: &str,
) -> Option<(std::ops::Range<u16>, String)> {
let line = model.line_for_hit(hit)?;
let range = url_range_at_col(&line.text, hit.col_within_range)
.unwrap_or_else(|| word_boundaries_at_col(&line.text, hit.col_within_range, separators));
if range.is_empty() {
return None;
}
let text = crate::scrollback::types::slice_display_cols(&line.text, range.start, range.end);
Some((range, text))
}
impl AgentView {
/// Tick the selection highlight timer. Returns true if the selection
/// was auto-dismissed (needs redraw). When `keep_text_selection` is on
/// (cache), never timer-dismisses — Esc / click / nav still clear it.
pub fn tick_selection_highlight(&mut self) -> bool {
if crate::appearance::cache::load_keep_text_selection().holds() {
return false;
}
if let Some(created) = self.selection_created_at
&& created.elapsed().as_millis() as u64 >= DEFAULT_SELECTION_HIGHLIGHT_DURATION_MS
{
self.persistent_text_selection = None;
self.table_selection_geometry = None;
self.selection_created_at = None;
return true;
}
false
}
/// Side-car table geometry, if it was resolved for the given selection.
pub(in crate::app) fn table_geometry_for_selection(
&self,
entry_idx: usize,
range_id: u16,
) -> Option<&TableGeometry> {
self.table_selection_geometry
.as_ref()
.and_then(|t| t.for_selection(entry_idx, range_id))
}
/// Run `f` with a text source over the anchor entry's full block output
/// (so off-screen fragments are included). Lines outside the hit's
/// selection range yield `None`, which detection treats as a boundary.
///
/// `width_override` is the drag-start width snapshot, for callers whose
/// entry may have scrolled fully out of `visible_blocks` by now; other
/// callers pass `None` and use the current frame's geometry.
fn with_entry_output_text_source<R>(
&self,
entry_idx: usize,
range_id: u16,
width_override: Option<u16>,
f: impl FnOnce(&dyn Fn(usize) -> Option<String>) -> R,
) -> Option<R> {
let scrollback = if let Some(ref child_id) = self.active_subagent
&& let Some(child) = self.subagent_views.get(child_id)
{
&child.scrollback
} else {
&self.scrollback
};
let visible_start = scrollback.visible_entry_range().start;
let abs_idx = entry_idx + visible_start;
// The per-block content width the geometry/hits were captured
// against; see the width note in `reconstruct_drag_copy`.
let content_width = width_override.or_else(|| {
self.last_scrollback_selection_model
.visible_block_content_width(entry_idx)
})?;
let entry = scrollback.get(abs_idx)?;
let appearance = scrollback.appearance();
let effective = entry.effective_output(content_width, appearance, false, scrollback.cwd());
let lines = &effective.output().lines;
let source = |i: usize| -> Option<String> {
let line = lines.get(i)?;
if line.selection_range != Some(range_id) {
return None;
}
Some(crate::scrollback::types::derive_selection_text(line))
};
Some(f(&source))
}
/// Detect the table grid under a drag anchor (btw drags stay linear).
/// Pre-gated on the anchor line having a box-drawing glyph: prose drags
/// must not touch `effective_output`, which thrashes the render cache.
fn compute_drag_table_geometry(&self, anchor: &RangeHit) -> Option<TableGeometry> {
if anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX {
return None;
}
if let Some(line) = self.last_scrollback_selection_model.line_for_hit(anchor)
&& !line.text.contains(['│', '┌', '├', '└'])
{
return None;
}
self.with_entry_output_text_source(anchor.entry_idx, anchor.range_id, None, |src| {
TableGeometry::detect(src, anchor.block_line_idx)
})
.flatten()
}
/// Re-resolve the drag's [`SelectionKind`] after an endpoint change;
/// `prev` is the latch that keeps boundary touches from flipping it.
fn resolve_drag_kind(
&self,
anchor: &RangeHit,
head: &RangeHit,
prev: SelectionKind,
) -> SelectionKind {
let geom = self.table_geometry_for_selection(anchor.entry_idx, anchor.range_id);
resolve_table_drag_kind(geom, anchor, head, prev)
}
/// Whether drag autoscroll is currently active and needs ticking.
pub fn has_drag_autoscroll(&self) -> bool {
self.drag_autoscroll.is_some()
}
/// Advance drag autoscroll by one tick.
///
/// Scrolls the active scrollback (main or subagent) by `speed` rows in
/// the autoscroll direction. After scrolling, recomputes the drag head
/// from the stored mouse position using the current (soon-stale)
/// selection model; [`Self::reclamp_drag_head_post_render`] re-snaps the
/// head once the next render rebuilds the model.
///
/// For block drag, implements long-block snap: if the current head block
/// is no longer visible after scrolling, advances the head to the next
/// visible block in the scroll direction.
pub fn tick_drag_autoscroll(&mut self) -> bool {
let Some(autoscroll) = self.drag_autoscroll else {
return false;
};
// Scroll the active scrollback.
let scrollback = if let Some(ref child_id) = self.active_subagent {
if let Some(child) = self.subagent_views.get_mut(child_id) {
&mut child.scrollback
} else {
&mut self.scrollback
}
} else {
&mut self.scrollback
};
match autoscroll.direction {
AutoScrollDirection::Up => scrollback.scroll_up(autoscroll.speed),
AutoScrollDirection::Down => scrollback.scroll_down(autoscroll.speed),
}
// Recompute text drag head from stored mouse position, against the
// pre-scroll (stale) model: a within-tick estimate that keeps the
// overlay responsive; the post-render reclamp re-resolves it against
// the fresh model every frame.
// Skip for btw-anchored drags — they don't autoscroll the scrollback.
if let Some((col, row)) = self.last_drag_mouse
&& let Some(drag) = self.drag_selection
&& drag.anchor.entry_idx != BTW_OVERLAY_ENTRY_IDX
{
let head = self
.last_scrollback_selection_model
.hit_test_nearest_in_range(drag.anchor, col, row)
.unwrap_or(drag.head);
let kind = self.resolve_drag_kind(&drag.anchor, &head, drag.kind);
if let Some(ref mut drag) = self.drag_selection {
drag.head = head;
drag.kind = kind;
}
}
// Long-block snap for block drag: if the current head block scrolled
// out of the visible set, advance to the next/previous visible block.
if let Some(ref mut drag) = self.block_drag_selection {
let head_visible = self
.last_scrollback_selection_model
.visible_blocks
.iter()
.any(|b| b.entry_idx == drag.head_entry_idx);
if !head_visible {
match autoscroll.direction {
AutoScrollDirection::Down => {
if let Some(next) = self
.last_scrollback_selection_model
.visible_blocks
.iter()
.find(|b| b.entry_idx > drag.head_entry_idx)
{
drag.head_entry_idx = next.entry_idx;
}
}
AutoScrollDirection::Up => {
if let Some(prev) = self
.last_scrollback_selection_model
.visible_blocks
.iter()
.rev()
.find(|b| b.entry_idx < drag.head_entry_idx)
{
drag.head_entry_idx = prev.entry_idx;
}
}
}
}
}
true
}
/// Snap an active drag head to the freshly rebuilt selection model.
///
/// Input handlers and autoscroll ticks hit-test against the previous
/// frame's model, which scrolling/streaming/resizing make stale.
/// `draw` calls this right after each model rebuild and before the
/// corresponding overlay paints, so the head re-resolves from the held
/// pointer position with at most one frame of lag. `btw_rebuilt` names
/// the surface that was just rebuilt: a drag anchored on the other
/// surface is left alone (its model is still last frame's). Keeps the
/// previous head when the anchor's range has no lines in the fresh model.
pub(in crate::app) fn reclamp_drag_head_post_render(&mut self, btw_rebuilt: bool) {
let Some((col, row)) = self.last_drag_mouse else {
return;
};
let Some(drag) = self.drag_selection else {
return;
};
if (drag.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX) != btw_rebuilt {
return;
}
let model = self.selection_model_for_hit(&drag.anchor);
let Some(head) = model.hit_test_nearest_in_range(drag.anchor, col, row) else {
return;
};
if head == drag.head {
return;
}
let kind = self.resolve_drag_kind(&drag.anchor, &head, drag.kind);
if let Some(ref mut drag) = self.drag_selection {
drag.head = head;
drag.kind = kind;
}
}
/// True when any scrollback drag/press latch is set (incl. a bare left press or scrollbar drag).
/// [`Self::clear_stuck_scrollback_drag`] also resets the derived autoscroll / last-drag-mouse state.
pub(super) fn scrollback_drag_latched(&self) -> bool {
self.left_mouse_down
|| self.scrollbar_dragging
|| 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()
}
/// Discard any in-progress scrollback drag + mouse-down latch (recovery path).
pub(super) fn clear_stuck_scrollback_drag(&mut self) {
self.left_mouse_down = false;
self.scrollbar_dragging = false;
self.pending_text_drag = None;
self.drag_selection = None;
self.pending_block_drag = None;
self.block_drag_selection = None;
self.deferred_text_press = None;
self.drag_autoscroll = None;
self.last_drag_mouse = None;
}
/// Update [`Self::plan_prompt_mouse_drag`] for a left-button mouse event
/// during plan feedback and report whether the event should be forwarded
/// to the feedback prompt (for cursor placement / text selection).
///
/// `in_prompt` is whether the pointer currently sits in the prompt rect.
/// A left press arms the drag when it lands in the prompt; subsequent
/// `Drag`/`Up` events keep being routed to the prompt even after the
/// pointer leaves the rect (TextArea tracks drag state internally and
/// handles drag-beyond-edge), and the release disarms it. Shared by both
/// plan-feedback mouse paths (line-viewer-open and empty-plan) so the
/// drag semantics stay in sync.
pub(super) fn route_plan_prompt_mouse_drag(
&mut self,
mouse: &crossterm::event::MouseEvent,
in_prompt: bool,
) -> bool {
use crossterm::event::{MouseButton, MouseEventKind};
let was_dragging = self.plan_prompt_mouse_drag;
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
self.plan_prompt_mouse_drag = in_prompt;
}
MouseEventKind::Up(MouseButton::Left) => {
self.plan_prompt_mouse_drag = false;
}
_ => {}
}
let continue_drag = was_dragging
&& matches!(
mouse.kind,
MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
);
in_prompt || continue_drag
}
pub(in crate::app) fn begin_pending_text_drag(&mut self, mouse: &MouseEvent) -> bool {
self.begin_pending_text_drag_on(mouse, false)
}
pub(in crate::app) fn begin_pending_btw_text_drag(&mut self, mouse: &MouseEvent) -> bool {
self.begin_pending_text_drag_on(mouse, true)
}
fn begin_pending_text_drag_on(&mut self, mouse: &MouseEvent, btw: bool) -> bool {
let model = if btw {
&self.last_btw_selection_model
} else {
&self.last_scrollback_selection_model
};
let hit = model.hit_test_selectable_range(mouse.column, mouse.row);
tracing::debug!(
event = "begin_pending_text_drag",
col = mouse.column,
row = mouse.row,
btw,
hit = ?hit,
"drag start hit-test"
);
let Some(hit) = hit else {
return false;
};
// Snapshot the anchor block's render width now (the btw model carries
// its own geometry): copy must survive the block scrolling fully out
// of `visible_blocks` before mouse-up.
let anchor_content_width = model.visible_block_content_width(hit.entry_idx);
self.pending_text_drag = Some(PendingTextDrag {
anchor: hit,
start_col: mouse.column,
start_row: mouse.row,
anchor_content_width,
});
self.drag_selection = None;
true
}
fn drag_model(&self) -> &ResolvedSelectionModel {
let is_btw = self
.pending_text_drag
.is_some_and(|p| p.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX)
|| self
.drag_selection
.as_ref()
.is_some_and(|d| d.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX);
if is_btw {
&self.last_btw_selection_model
} else {
&self.last_scrollback_selection_model
}
}
fn update_text_drag(&mut self, mouse: &MouseEvent) -> bool {
let Some(pending) = self.pending_text_drag else {
return false;
};
let threshold = drag_threshold_exceeded(&pending, mouse.column, mouse.row);
tracing::debug!(
event = "update_text_drag",
col = mouse.column,
row = mouse.row,
anchor = ?pending.anchor,
threshold,
"pending drag update"
);
if !threshold {
return false;
}
let model = self.drag_model();
let head = model
.hit_test_nearest_in_range(pending.anchor, mouse.column, mouse.row)
.unwrap_or(pending.anchor);
tracing::debug!(
event = "promote_text_drag",
head = ?head,
"pending drag promoted"
);
self.arm_text_drag(mouse, pending.anchor, head, pending.anchor_content_width);
true
}
/// Shared arming tail for every text drag — threshold promotion
/// ([`Self::update_text_drag`]) and deferred conversion
/// ([`Self::convert_deferred_text_press`]) both end here so their
/// bookkeeping cannot drift. Geometry resolves once per drag, at arming;
/// btw mouse-downs don't clear a held persistent selection, so a btw
/// drag must not wipe its side-car.
fn arm_text_drag(
&mut self,
mouse: &MouseEvent,
anchor: RangeHit,
head: RangeHit,
anchor_content_width: Option<u16>,
) {
if anchor.entry_idx != BTW_OVERLAY_ENTRY_IDX {
self.table_selection_geometry =
self.compute_drag_table_geometry(&anchor)
.map(|geometry| TableSelectionGeometry {
entry_idx: anchor.entry_idx,
range_id: anchor.range_id,
geometry,
});
}
let kind = self.resolve_drag_kind(&anchor, &head, SelectionKind::Linear);
self.drag_selection = Some(ActiveTextDrag {
anchor,
head,
kind,
anchor_content_width,
});
// Store the pointer at arming too, or an arm-then-hold-still drag
// would stay invisible to the post-render reclamp while content
// scrolls or streams underneath it.
self.last_drag_mouse = Some((mouse.column, mouse.row));
}
/// One-way deferred-anchor conversion. A scrollback press with no
/// selectable text under it arms [`AgentView::deferred_text_press`]
/// alongside the normal block-drag latch; the FIRST drag position that
/// hits selectable text (the same hit test the press ran) becomes both
/// anchor and head of an [`ActiveTextDrag`], and any block-drag state in
/// flight is cancelled — once text, the gesture stays text. A gesture
/// that never enters text leaves the block drag to run unchanged.
fn convert_deferred_text_press(&mut self, mouse: &MouseEvent) -> bool {
let Some((press_col, press_row)) = self.deferred_text_press else {
return false;
};
let Some(hit) = self
.last_scrollback_selection_model
.hit_test_selectable_range(mouse.column, mouse.row)
else {
return false;
};
tracing::debug!(
event = "convert_deferred_text_press",
press_col,
press_row,
col = mouse.column,
row = mouse.row,
hit = ?hit,
"deferred press entered text"
);
self.deferred_text_press = None;
self.pending_block_drag = None;
self.block_drag_selection = None;
// The width snapshot is taken at anchor placement (here, not at the
// press): copy must survive the block scrolling out before mouse-up.
let anchor_content_width = self
.last_scrollback_selection_model
.visible_block_content_width(hit.entry_idx);
self.arm_text_drag(mouse, hit, hit, anchor_content_width);
true
}
fn update_active_text_drag(&mut self, mouse: &MouseEvent) -> bool {
let Some(drag) = self.drag_selection else {
return false;
};
let is_btw = drag.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX;
let head = self
.selection_model_for_hit(&drag.anchor)
.hit_test_nearest_in_range(drag.anchor, mouse.column, mouse.row)
.unwrap_or(drag.head);
let kind = self.resolve_drag_kind(&drag.anchor, &head, drag.kind);
let changed = head != drag.head || kind != drag.kind;
if let Some(ref mut drag) = self.drag_selection {
drag.head = head;
drag.kind = kind;
}
self.last_drag_mouse = Some((mouse.column, mouse.row));
// Btw drags don't scroll the scrollback pane.
self.drag_autoscroll = if is_btw {
None
} else {
compute_autoscroll(mouse.row, self.pane_areas.scrollback)
};
changed
}
pub(in crate::app) fn handle_scrollback_drag_motion(
&mut self,
mouse: &MouseEvent,
) -> InputOutcome {
if self.scrollbar_dragging {
self.apply_scrollbar_click(mouse.row);
return InputOutcome::Changed;
}
if self.drag_selection.is_some() {
return if self.update_active_text_drag(mouse) {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
};
}
// Anchor-less press: convert to a text drag the moment the pointer
// enters selectable text; a miss falls through to the block-drag
// branches unchanged.
if self.convert_deferred_text_press(mouse) {
return InputOutcome::Changed;
}
if self.block_drag_selection.is_some() {
return if self.update_active_block_drag(mouse) {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
};
}
if self.pending_text_drag.is_some() {
return if self.update_text_drag(mouse) {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
};
}
if self.pending_block_drag.is_some() {
return if self.promote_pending_block_drag(mouse) {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
};
}
// An armed anchor-less press owns the rest of the gesture: strip
// presses deliberately keep focus where it was, so a conversion miss
// must not leak motion into the todo/viewer/prompt handlers below
// (a prompt-focused strip drag would otherwise edit the prompt).
if self.deferred_text_press.is_some() {
return InputOutcome::Unchanged;
}
if self.active_pane == AgentPane::Todo
&& self
.todo
.handle_mouse(mouse.kind, mouse.column, mouse.row, self.pane_areas.todo)
{
return InputOutcome::Changed;
}
if self.block_viewer.is_some() && self.active_pane == AgentPane::Scrollback {
self.handle_block_viewer_mouse(mouse);
return InputOutcome::Changed;
}
if self.active_pane == AgentPane::Prompt {
self.prompt.handle_mouse(mouse);
InputOutcome::Changed
} else {
InputOutcome::Unchanged
}
}
/// Clipboard text for a finished drag, tagged with the kind that
/// produced it — `Linear` when a table copy fell through — so the
/// persisted highlight mirrors what reached the clipboard.
fn reconstruct_drag_copy(&self, drag: &ActiveTextDrag) -> Option<(String, SelectionKind)> {
if drag.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX {
// Same width rule as scrollback anchors: the drag-start snapshot
// matches the wrap the drag's block_line_idx values came from,
// with the current panel width as fallback.
let content_width = drag
.anchor_content_width
.map(usize::from)
.unwrap_or_else(|| self.last_btw_area.width.saturating_sub(4) as usize);
if let Some(ref btw) = self.btw_state {
let full_model = btw.full_selection_model(content_width);
if let Some(text) = reconstruct_selection_text(&full_model, drag) {
return Some((text, SelectionKind::Linear));
}
}
return reconstruct_selection_text(&self.last_btw_selection_model, drag)
.map(|text| (text, SelectionKind::Linear));
}
if drag.kind != SelectionKind::Linear
&& let Some(geom) =
self.table_geometry_for_selection(drag.anchor.entry_idx, drag.anchor.range_id)
&& let Some(text) = self
.with_entry_output_text_source(
drag.anchor.entry_idx,
drag.anchor.range_id,
// Snapshot keeps the table copy alive after the block
// autoscrolls fully out of the viewport.
drag.anchor_content_width,
|src| {
// Geometry was frozen at promote; a streaming re-wrap
// since then shifts every block_line_idx, so re-detect
// and require an exact match before slicing.
if TableGeometry::detect(src, drag.anchor.block_line_idx).as_ref()
!= Some(geom)
{
return None;
}
reconstruct_table_selection_text(geom, drag, src)
},
)
.flatten()
{
return Some((text, drag.kind));
}
let scrollback = if let Some(ref child_id) = self.active_subagent
&& let Some(child) = self.subagent_views.get(child_id)
{
&child.scrollback
} else {
&self.scrollback
};
let visible_start = scrollback.visible_entry_range().start;
let abs_idx = drag.anchor.entry_idx + visible_start;
// Width must come from the same VisibleBlockGeometry the drag's
// block_line_idx values were captured against; the pane-wide width
// re-wraps timestamp-reserving blocks and shifts every index.
// Prefer the drag-start snapshot: by mouse-up the anchor block may
// have autoscrolled fully out of `visible_blocks`.
let entry_content_width = drag.anchor_content_width.or_else(|| {
self.last_scrollback_selection_model
.visible_block_content_width(drag.anchor.entry_idx)
});
if let Some(entry) = scrollback.get(abs_idx)
&& let Some(content_width) = entry_content_width
{
let appearance = scrollback.appearance();
entry.ensure_cached(content_width, appearance, false, scrollback.cwd());
let rendered = entry.cached_rendered_output_ref();
if let Some(text) = reconstruct_full_selection_text_with_boundaries(
&rendered.output.lines,
&rendered.boundaries,
drag,
) {
return Some((text, SelectionKind::Linear));
}
}
reconstruct_selection_text_with_boundaries(
&self.last_scrollback_selection_model,
&self.last_scrollback_selection_boundaries,
drag,
)
.map(|text| (text, SelectionKind::Linear))
}
/// Persist a finished drag as the highlight. `kind` is the copied
/// shape, not `drag.kind`: a degraded table drag persists `Linear` and
/// drops its orphaned side-car.
fn persist_drag_selection(&mut self, drag: &ActiveTextDrag, kind: SelectionKind) {
if kind == SelectionKind::Linear && drag.kind != SelectionKind::Linear {
self.table_selection_geometry = None;
}
self.persistent_text_selection = Some(PersistentTextSelection {
entry_idx: drag.anchor.entry_idx,
range_id: drag.anchor.range_id,
anchor: SelectionEndpoint {
block_line_idx: drag.anchor.block_line_idx,
col_within_range: drag.anchor.col_within_range,
},
head: SelectionEndpoint {
block_line_idx: drag.head.block_line_idx,
col_within_range: drag.head.col_within_range,
},
origin: SelectionOrigin::Drag,
kind,
});
self.selection_created_at = Some(Instant::now());
}
pub(in crate::app) fn finish_text_drag(&mut self) -> bool {
let drag = self.drag_selection;
let copied = drag.and_then(|d| self.reconstruct_drag_copy(&d));
self.pending_text_drag = None;
self.drag_selection = None;
self.drag_autoscroll = None;
self.last_drag_mouse = None;
if let Some((text, kind)) = copied
&& !text.is_empty()
{
// Capture drag geometry as a persistent selection only when the
// clipboard copy succeeds. Setting it unconditionally would leave
// a highlight with nothing in the clipboard if reconstruction fails.
if let Some(d) = drag {
self.persist_drag_selection(&d, kind);
}
self.copy_to_clipboard(&text);
return true;
}
false
}
pub(in crate::app) fn begin_pending_block_drag(&mut self, mouse: &MouseEvent) -> bool {
let block = self
.last_scrollback_selection_model
.hit_test_visible_block(mouse.column, mouse.row);
let Some(block) = block else {
return false;
};
if !block.drag_startable {
return false;
}
tracing::debug!(
event = "begin_pending_block_drag",
entry_idx = block.entry_idx,
col = mouse.column,
row = mouse.row,
"scrollback block drag start"
);
self.pending_block_drag = Some(PendingBlockDrag {
anchor_entry_idx: block.entry_idx,
start_col: mouse.column,
start_row: mouse.row,
});
self.drag_selection = None;
self.block_drag_selection = None;
true
}
fn promote_pending_block_drag(&mut self, mouse: &MouseEvent) -> bool {
let Some(pending) = self.pending_block_drag else {
return false;
};
if !block_drag_threshold_exceeded(&pending, mouse.column, mouse.row) {
return false;
}
let head_entry_idx = self
.last_scrollback_selection_model
.hit_test_visible_block(mouse.column, mouse.row)
.map(|b| b.entry_idx)
.unwrap_or(pending.anchor_entry_idx);
tracing::debug!(
event = "promote_block_drag",
anchor = pending.anchor_entry_idx,
head = head_entry_idx,
"scrollback block drag promoted"
);
self.pending_block_drag = None;
self.block_drag_selection = Some(ActiveBlockDrag {
anchor_entry_idx: pending.anchor_entry_idx,
head_entry_idx,
});
true
}
fn update_active_block_drag(&mut self, mouse: &MouseEvent) -> bool {
let Some(ref mut drag) = self.block_drag_selection else {
return false;
};
let new_head = self
.last_scrollback_selection_model
.hit_test_visible_block(mouse.column, mouse.row)
.map(|b| b.entry_idx)
.unwrap_or(drag.head_entry_idx);
let changed = new_head != drag.head_entry_idx;
drag.head_entry_idx = new_head;
self.last_drag_mouse = Some((mouse.column, mouse.row));
self.drag_autoscroll = compute_autoscroll(mouse.row, self.pane_areas.scrollback);
changed
}
pub(in crate::app) fn finish_block_drag(&mut self) -> bool {
let drag = self.block_drag_selection.take();
self.pending_block_drag = None;
self.drag_autoscroll = None;
self.last_drag_mouse = None;
let Some(drag) = drag else {
return false;
};
let start = std::cmp::min(drag.anchor_entry_idx, drag.head_entry_idx);
let end = std::cmp::max(drag.anchor_entry_idx, drag.head_entry_idx);
let appearance = self.scrollback.appearance().clone();
let mut parts: Vec<String> = Vec::new();
for idx in start..=end {
let Some(entry) = self.scrollback.entry(idx) else {
continue;
};
if !entry.block.is_drag_block_selectable() {
continue;
}
if self.scrollback.entry_content_hidden_by_group(idx) {
continue;
}
// BgTask: copy stdout from session state when available.
if let crate::scrollback::block::RenderBlock::BgTask(ref block) = entry.block {
let stdout = self
.session
.bg_tasks
.get(&block.task_id)
.map(|t| &t.stdout)
.filter(|s| !s.is_empty());
if let Some(stdout) = stdout {
parts.push(stdout.clone());
continue;
}
}
// Find the content width from the resolved model's visible block geometry.
let content_width = self
.last_scrollback_selection_model
.visible_block_content_width(idx)
.unwrap_or(80);
let ctx = crate::scrollback::types::BlockContext {
mode: entry.display_mode,
is_running: entry.is_running,
width: content_width,
raw: entry.raw,
max_lines: None,
appearance: appearance.clone(),
is_selected: self.scrollback.selected() == Some(idx),
cwd: Some(self.session.cwd.clone()),
};
if let Some(text) = entry.block.copy_visible_text_in_state(&ctx)
&& !text.is_empty()
{
parts.push(text);
}
}
let text = parts.join("\n\n");
tracing::debug!(
event = "finish_block_drag",
anchor = drag.anchor_entry_idx,
head = drag.head_entry_idx,
blocks_copied = parts.len(),
"scrollback block drag finished"
);
if !text.is_empty() {
self.copy_to_clipboard(&text);
return true;
}
false
}
/// Handle a click on a scrollback entry with multi-click detection.
///
/// - Single click: select entry
/// - Double-click non-prompt: toggle fold in place
/// - Double-click prompt: toggle fold + scroll to top
/// - Triple-click non-prompt: toggle fold + scroll to top
///
/// Returns `(last_click_state, show_word_select_tip)`. The tip flag is set
/// on a REPEATED double-click on assistant text (second gesture within
/// [`WORD_SELECT_REPEAT_WINDOW`]) while Text selection is still fold/nav
/// (not `word_select`), so dispatch can teach `/settings → Text
/// selection`. A lone double-click, or one on fold-affordance surfaces
/// (headers, prompts, tool rows), never tips.
pub(in crate::app) fn handle_scrollback_click(
&mut self,
now: Instant,
idx: usize,
header_row_click: bool,
) -> (Option<(Instant, usize, u8)>, bool) {
let click_count = if let Some((last_time, last_idx, prev_count)) = self.last_click
&& last_idx == idx
&& now.duration_since(last_time).as_millis() < MULTI_CLICK_TIMEOUT_MS
{
prev_count + 1
} else {
1
};
let entry_block = self.scrollback.entry(idx).map(|e| &e.block);
let is_bg_task = entry_block
.is_some_and(|b| matches!(b, crate::scrollback::block::RenderBlock::BgTask(_)));
let is_subagent = entry_block
.is_some_and(|b| matches!(b, crate::scrollback::block::RenderBlock::Subagent(_)));
// Word-select tip probe (see WORD_SELECT_REPEAT_WINDOW): assistant
// messages only — headers / prompts / tool rows are fold-nav surfaces
// where double-click is the designed gesture, and bg-task / subagent
// double-clicks open a viewer that owns input.
let word_select_probe = click_count == 2
&& entry_block.is_some_and(|b| b.is_agent_message())
&& !super::is_text_selection_on_double_click();
let show_word_select_tip = word_select_probe
&& self
.last_word_select_probe
.is_some_and(|t| now.duration_since(t) <= WORD_SELECT_REPEAT_WINDOW);
if word_select_probe {
self.last_word_select_probe = Some(now);
}
self.scrollback.set_selected(Some(idx));
// Expanded verb-group slot, header row (`header_row_click`): the slot
// acts as member 0 everywhere else, so the group affordance lives
// here — double-click on the header row collapses the group; a
// single click just selects. Member rows fall through to the normal
// foldable path below.
if header_row_click {
if click_count == 2 {
self.scrollback.collapse_group_if_expanded();
return (None, show_word_select_tip);
}
if click_count >= 3 {
return (None, false);
}
}
// Double-click on a group header → expand/collapse the group.
// Both expand ("N more") and collapse ("▾ N tool calls") headers
// are standalone entries with their own index.
let is_group_header = self.scrollback.is_selected_group_header();
if is_group_header {
if click_count == 2 {
self.scrollback.toggle_group_expansion();
return (None, show_word_select_tip);
}
if click_count >= 3 {
return (None, false);
}
}
let foldable = self.scrollback.get(idx).is_some_and(|e| e.is_foldable());
let is_prompt = self
.scrollback
.entry(idx)
.is_some_and(|e| e.block.is_user_prompt());
// Single-click on plan mode tool call → show plan preview/toast.
let is_plan_tool = !is_group_header
&& self
.scrollback
.entry(idx)
.is_some_and(|e| e.block.is_plan_mode_tool());
// Credit-limit URL click is handled upstream (before this method)
// so only the URL line is clickable, not the whole block.
// Double-click on bg-task / subagent blocks (matched above) opens a
// viewer instead of folding.
match click_count {
1 if is_plan_tool => {
self.show_plan_preview();
}
2 if is_bg_task => {
// Double-click bg task: open block viewer (same as Enter).
if let Some(entry) = self.scrollback.entry(idx)
&& let crate::scrollback::block::RenderBlock::BgTask(ref bt) = entry.block
&& let Some(task) = self.session.bg_tasks.get(&bt.task_id)
{
let eid = 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(
eid,
&bt.task_id,
&task.stdout,
is_running,
));
}
}
2 if is_subagent => {
// Double-click subagent: open subagent view (same as Enter)
if let Some(entry) = self.scrollback.entry(idx)
&& let crate::scrollback::block::RenderBlock::Subagent(ref sb) = entry.block
{
let child_sid = sb.child_session_id.clone();
if self.subagent_views.contains_key(&child_sid) {
self.open_subagent_fullscreen(child_sid);
}
}
}
2 if is_prompt => {
// Edit in place; bash/cron keep the old fold behavior.
if !self.enter_inline_edit(idx) {
if foldable {
self.scrollback.toggle_fold_selected();
}
self.scrollback.scroll_to_entry_top(idx);
}
}
2 => {
if foldable {
self.scrollback.toggle_fold_selected();
}
}
3.. if !is_prompt => {
if foldable {
self.scrollback.toggle_fold_selected();
}
self.scrollback.scroll_to_entry_top(idx);
}
_ => {}
}
let last_click = if click_count >= 3 {
None
} else {
Some((now, idx, click_count))
};
(last_click, show_word_select_tip)
}
/// Return the correct selection model for a hit, accounting for the
/// /btw overlay panel which has its own model.
fn selection_model_for_hit(&self, hit: &RangeHit) -> &ResolvedSelectionModel {
if hit.entry_idx == BTW_OVERLAY_ENTRY_IDX {
&self.last_btw_selection_model
} else {
&self.last_scrollback_selection_model
}
}
/// Count text-level multi-clicks, incrementing when the new click lands
/// on the same (entry, range, line) within [`MULTI_CLICK_TIMEOUT_MS`].
pub(in crate::app) fn count_text_click(&self, now: Instant, hit: &RangeHit) -> u8 {
if let Some(ref prev) = self.last_text_click
&& prev.entry_idx == hit.entry_idx
&& prev.range_id == hit.range_id
&& prev.block_line_idx == hit.block_line_idx
&& now.duration_since(prev.time).as_millis() < MULTI_CLICK_TIMEOUT_MS
{
prev.click_count.saturating_add(1)
} else {
1
}
}
/// Select the word (or URL) under the cursor described by `hit`.
pub(in crate::app) fn select_word_at(&mut self, hit: &RangeHit) {
let model = self.selection_model_for_hit(hit);
let separators = configured_word_separators();
let Some((selection_range, clipboard_text)) = semantic_selection_at(model, hit, separators)
else {
return;
};
self.persistent_text_selection = Some(PersistentTextSelection {
entry_idx: hit.entry_idx,
range_id: hit.range_id,
anchor: SelectionEndpoint {
block_line_idx: hit.block_line_idx,
col_within_range: selection_range.start,
},
head: SelectionEndpoint {
block_line_idx: hit.block_line_idx,
col_within_range: selection_range.end.saturating_sub(1),
},
origin: SelectionOrigin::DoubleClick,
kind: SelectionKind::Linear,
});
self.selection_created_at = Some(Instant::now());
if !clipboard_text.is_empty() {
self.copy_to_clipboard_debounced(&clipboard_text);
}
if hit.entry_idx != BTW_OVERLAY_ENTRY_IDX {
self.scrollback.set_selected(Some(hit.entry_idx));
}
}
fn selection_text_for_full_line(&self, hit: &RangeHit) -> Option<String> {
let model = self.selection_model_for_hit(hit);
let line = model.line_for_hit(hit)?;
let boundary = if hit.entry_idx == BTW_OVERLAY_ENTRY_IDX {
None
} else {
self.last_scrollback_selection_boundaries
.boundary_for_hit(hit)
};
Some(apply_selection_boundary(
line.text.clone(),
boundary,
true,
true,
))
}
/// Select the full line at the cursor position described by `hit`.
pub(in crate::app) fn select_line_at(&mut self, hit: &RangeHit) {
let model = self.selection_model_for_hit(hit);
let Some(line) = model.line_for_hit(hit) else {
return;
};
let width = line
.selectable_cols
.end
.saturating_sub(line.selectable_cols.start);
if width == 0 {
return;
}
let Some(clipboard_text) = self.selection_text_for_full_line(hit) else {
return;
};
self.persistent_text_selection = Some(PersistentTextSelection {
entry_idx: hit.entry_idx,
range_id: hit.range_id,
anchor: SelectionEndpoint {
block_line_idx: hit.block_line_idx,
col_within_range: 0,
},
head: SelectionEndpoint {
block_line_idx: hit.block_line_idx,
col_within_range: width.saturating_sub(1),
},
origin: SelectionOrigin::TripleClick,
kind: SelectionKind::Linear,
});
self.selection_created_at = Some(Instant::now());
if !clipboard_text.is_empty() {
self.copy_to_clipboard_debounced(&clipboard_text);
}
if hit.entry_idx != BTW_OVERLAY_ENTRY_IDX {
self.scrollback.set_selected(Some(hit.entry_idx));
}
}
/// Select and copy the whole table cell at `hit`, wrapped fragments
/// included. `false` when there is no cell there (no grid, or a column
/// outside it) — the caller falls back to line selection.
pub(in crate::app) fn select_cell_at(&mut self, hit: &RangeHit) -> bool {
let Some(geometry) = self.compute_drag_table_geometry(hit) else {
return false;
};
// Triple-click on a border/divider row is the whole-table shortcut
// (drags from grid lines stay linear).
let Some(cell) = geometry.cell_at(hit.block_line_idx, hit.col_within_range) else {
return self.select_whole_table_at(hit, geometry);
};
let Some(clipboard_text) =
self.with_entry_output_text_source(hit.entry_idx, hit.range_id, None, |src| {
geometry.cell_text(cell, src)
})
else {
return false;
};
let lines = geometry.row_lines(cell.row);
let band = geometry.band(cell.col);
self.persistent_text_selection = Some(PersistentTextSelection {
entry_idx: hit.entry_idx,
range_id: hit.range_id,
anchor: SelectionEndpoint {
block_line_idx: lines.start,
col_within_range: band.start,
},
head: SelectionEndpoint {
block_line_idx: lines.end.saturating_sub(1),
col_within_range: band.end.saturating_sub(1),
},
origin: SelectionOrigin::TripleClick,
kind: SelectionKind::TableCell,
});
self.table_selection_geometry = Some(TableSelectionGeometry {
entry_idx: hit.entry_idx,
range_id: hit.range_id,
geometry,
});
self.selection_created_at = Some(Instant::now());
if !clipboard_text.is_empty() {
self.copy_to_clipboard_debounced(&clipboard_text);
}
self.scrollback.set_selected(Some(hit.entry_idx));
true
}
/// Select and copy the entire table containing `hit` — the grid-line
/// counterpart of [`Self::select_cell_at`].
fn select_whole_table_at(&mut self, hit: &RangeHit, geometry: TableGeometry) -> bool {
let anchor_cell = CellRef { row: 0, col: 0 };
let head_cell = CellRef {
row: geometry.n_rows() - 1,
col: geometry.n_cols() - 1,
};
let Some(clipboard_text) =
self.with_entry_output_text_source(hit.entry_idx, hit.range_id, None, |src| {
geometry.grid_tsv(anchor_cell, head_cell, src)
})
else {
return false;
};
let first = geometry.row_lines(0);
let last = geometry.row_lines(head_cell.row);
self.persistent_text_selection = Some(PersistentTextSelection {
entry_idx: hit.entry_idx,
range_id: hit.range_id,
anchor: SelectionEndpoint {
block_line_idx: first.start,
col_within_range: geometry.band(0).start,
},
head: SelectionEndpoint {
block_line_idx: last.end.saturating_sub(1),
col_within_range: geometry.band(head_cell.col).end.saturating_sub(1),
},
origin: SelectionOrigin::TripleClick,
kind: SelectionKind::TableGrid {
anchor: anchor_cell,
head: head_cell,
},
});
self.table_selection_geometry = Some(TableSelectionGeometry {
entry_idx: hit.entry_idx,
range_id: hit.range_id,
geometry,
});
self.selection_created_at = Some(Instant::now());
if !clipboard_text.is_empty() {
self.copy_to_clipboard_debounced(&clipboard_text);
}
self.scrollback.set_selected(Some(hit.entry_idx));
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actions::ActionRegistry;
use crate::app::agent_view::test_fixtures::make_agent;
use crate::scrollback::table_geometry::CellRef;
use crate::scrollback::text_selection::{ResolvedSelectableLine, VisibleBlockGeometry};
use crossterm::event::{Event, MouseButton, MouseEventKind};
use ratatui::layout::Rect;
const TABLE: &[&str] = &[
"┌─────────┬────────┐",
"│ Name │ Role │",
"├─────────┼────────┤",
"│ Alice │ Eng │",
"└─────────┴────────┘",
];
fn table_geometry() -> TableGeometry {
TableGeometry::detect(|i| TABLE.get(i).map(|s| s.to_string()), 1).expect("grid detected")
}
/// Agent whose TABLE lines are in the visible model but with no
/// `visible_blocks`/scrollback entry, forcing the table copy to fall
/// through to the visible-model linear reconstruction.
fn agent_with_visible_table_lines_only() -> AgentView {
let mut agent = make_agent();
let mut model = ResolvedSelectionModel::default();
for (i, text) in TABLE.iter().enumerate() {
model.push_line(ResolvedSelectableLine {
entry_idx: 0,
range_id: 0,
block_line_idx: i,
screen_y: i as u16,
screen_x: 0,
selectable_cols: 0..20,
text: (*text).to_string(),
joiner_to_previous: None,
});
}
agent.update_scrollback_selection_state(model, Default::default());
agent.table_selection_geometry = Some(TableSelectionGeometry {
entry_idx: 0,
range_id: 0,
geometry: table_geometry(),
});
agent
}
fn table_drag() -> ActiveTextDrag {
ActiveTextDrag {
anchor: RangeHit {
entry_idx: 0,
range_id: 0,
block_line_idx: 1,
col_within_range: 2,
},
head: RangeHit {
entry_idx: 0,
range_id: 0,
block_line_idx: 3,
col_within_range: 12,
},
kind: SelectionKind::TableGrid {
anchor: CellRef { row: 0, col: 0 },
head: CellRef { row: 1, col: 1 },
},
anchor_content_width: None,
}
}
#[test]
fn semantic_word_and_url_copy_ignore_hidden_edit_boundaries() {
let mut model = ResolvedSelectionModel::default();
let mut boundaries =
crate::scrollback::text_selection::ResolvedSelectionBoundaries::default();
for (entry_idx, text, hit_col, prefix, suffix, expected) in [
(0, "foo rest", 0, " ", "", "foo"),
(1, "rest https://kimi.com", 5, "", " ", "https://kimi.com"),
] {
let line = ResolvedSelectableLine {
entry_idx,
range_id: 0,
block_line_idx: 0,
screen_y: entry_idx as u16,
screen_x: 0,
selectable_cols: 0..text.len() as u16,
text: text.to_string(),
joiner_to_previous: None,
};
boundaries.push(
&line,
std::sync::Arc::new(crate::scrollback::types::SelectionBoundary::new(
prefix.to_string(),
suffix.to_string(),
)),
);
model.push_line(line);
let hit = RangeHit {
entry_idx,
range_id: 0,
block_line_idx: 0,
col_within_range: hit_col,
};
let (_, copied) = semantic_selection_at(
&model,
&hit,
crate::scrollback::text_selection::DEFAULT_WORD_SEPARATORS,
)
.expect("semantic range");
assert_eq!(copied, expected);
}
assert!(!boundaries.is_empty());
}
#[test]
fn installed_scrollback_companion_reaches_linear_and_full_line_copy() {
use crate::scrollback::block::RenderBlock;
use crate::scrollback::render::ScratchBuffer;
use crate::scrollback::scrollback_pane::ScrollbackPane;
use crate::scrollback::state::ScrollbackState;
use crate::scrollback::types::DisplayMode;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let mut state = ScrollbackState::new();
let edit = state.push_block(RenderBlock::edit(" foo.rs ", None));
state
.get_by_id_mut(edit)
.expect("Edit entry")
.set_display_mode(DisplayMode::Expanded);
let area = Rect::new(0, 0, 8, 20);
state.prepare_layout(area.width, area.height);
let mut buffer = Buffer::empty(area);
let mut scratch = ScratchBuffer::new();
let rendered = ScrollbackPane::new().render_with_scratch_and_selection_boundaries(
area,
&mut buffer,
&state,
&mut scratch,
);
assert!(!rendered.selection_boundaries.is_empty());
let model = rendered.output.selection_model;
let (entry_idx, range_id, first_line_idx, last_line_idx, last_width) = {
let range = model
.ranges
.iter()
.find(|range| !range.lines.is_empty())
.expect("visible Edit path range");
let first = range.lines.first().expect("first path row");
let last = range.lines.last().expect("last path row");
(
range.entry_idx,
range.range_id,
first.block_line_idx,
last.block_line_idx,
last.selectable_cols
.end
.saturating_sub(last.selectable_cols.start),
)
};
let mut agent = make_agent();
agent.update_scrollback_selection_state(model, rendered.selection_boundaries);
let drag = ActiveTextDrag {
anchor: RangeHit {
entry_idx,
range_id,
block_line_idx: first_line_idx,
col_within_range: 0,
},
head: RangeHit {
entry_idx,
range_id,
block_line_idx: last_line_idx,
col_within_range: last_width.saturating_sub(1),
},
kind: SelectionKind::Linear,
anchor_content_width: None,
};
assert_eq!(
agent.reconstruct_drag_copy(&drag),
Some((" foo.rs ".to_string(), SelectionKind::Linear))
);
let full_line_copies: Vec<String> = agent
.last_scrollback_selection_model
.range(entry_idx, range_id)
.expect("installed Edit range")
.lines
.iter()
.filter_map(|line| {
agent.selection_text_for_full_line(&RangeHit {
entry_idx: line.entry_idx,
range_id: line.range_id,
block_line_idx: line.block_line_idx,
col_within_range: 0,
})
})
.collect();
assert!(
full_line_copies.iter().any(|text| text.starts_with(" ")),
"prefix boundary did not reach full-line copy: {full_line_copies:?}"
);
assert!(
full_line_copies.iter().any(|text| text.ends_with(" ")),
"suffix boundary did not reach full-line copy: {full_line_copies:?}"
);
}
#[test]
fn active_child_copy_uses_child_scrollback_cwd() {
use crate::scrollback::block::RenderBlock;
use crate::scrollback::render::ScratchBuffer;
use crate::scrollback::scrollback_pane::ScrollbackPane;
use crate::scrollback::types::{DisplayMode, derive_selection_text};
use ratatui::buffer::Buffer;
let parent_cwd = std::path::PathBuf::from("/parent/worktree");
let child_cwd = std::path::PathBuf::from("/child/worktree");
let mut child = make_agent();
child.session.cwd = child_cwd.clone();
child.scrollback.set_cwd(Some(child_cwd.clone()));
let read = child.scrollback.push_block(RenderBlock::read(
child_cwd.join("src/lib.rs").to_string_lossy(),
None,
));
child
.scrollback
.get_by_id_mut(read)
.expect("Read entry")
.set_display_mode(DisplayMode::Expanded);
let area = Rect::new(0, 0, 40, 8);
child.scrollback.prepare_layout(area.width, area.height);
let mut buffer = Buffer::empty(area);
let mut scratch = ScratchBuffer::new();
let rendered = ScrollbackPane::new().render_with_scratch_and_selection_boundaries(
area,
&mut buffer,
&child.scrollback,
&mut scratch,
);
let line = rendered
.output
.selection_model
.ranges
.iter()
.flat_map(|range| &range.lines)
.find(|line| line.text == "src/lib.rs")
.expect("child-relative Read header")
.clone();
let content_width = rendered
.output
.selection_model
.visible_block_content_width(line.entry_idx)
.expect("visible child block width");
let mut parent = make_agent();
parent.session.cwd = parent_cwd;
parent.update_scrollback_selection_state(
rendered.output.selection_model,
rendered.selection_boundaries,
);
let child_id = "child".to_string();
parent
.subagent_views
.insert(child_id.clone(), Box::new(child));
parent.active_subagent = Some(child_id.clone());
let source_text = parent
.with_entry_output_text_source(
line.entry_idx,
line.range_id,
Some(content_width),
|source| source(line.block_line_idx),
)
.flatten();
assert_eq!(source_text.as_deref(), Some("src/lib.rs"));
{
let child = parent.subagent_views.get(&child_id).expect("active child");
let entry = child.scrollback.get(0).expect("child Read entry");
let cached = entry.cached_output_ref();
assert_eq!(
derive_selection_text(&cached.lines[line.block_line_idx]),
"src/lib.rs",
"copy helper must not rebuild the child cache against parent cwd"
);
}
let path_width = line
.selectable_cols
.end
.saturating_sub(line.selectable_cols.start);
let drag = ActiveTextDrag {
anchor: RangeHit {
entry_idx: line.entry_idx,
range_id: line.range_id,
block_line_idx: line.block_line_idx,
col_within_range: 0,
},
head: RangeHit {
entry_idx: line.entry_idx,
range_id: line.range_id,
block_line_idx: line.block_line_idx,
col_within_range: path_width.saturating_sub(1),
},
kind: SelectionKind::Linear,
anchor_content_width: Some(content_width),
};
assert_eq!(
parent.reconstruct_drag_copy(&drag),
Some(("src/lib.rs".to_string(), SelectionKind::Linear))
);
}
/// Regression: a table drag whose table copy can't run must return and
/// persist `Linear` (and drop the side-car) to match the linear text
/// that was copied.
#[test]
fn table_drag_falling_back_to_linear_copy_persists_linear_kind() {
let mut agent = agent_with_visible_table_lines_only();
let drag = table_drag();
let (text, kind) = agent
.reconstruct_drag_copy(&drag)
.expect("linear fallback must still produce text");
assert!(
text.contains('│'),
"fallback is the linear row sweep (border glyphs included): {text:?}"
);
assert_eq!(kind, SelectionKind::Linear);
agent.persist_drag_selection(&drag, kind);
let sel = agent.persistent_text_selection.expect("selection persists");
assert_eq!(
sel.kind,
SelectionKind::Linear,
"highlight must mirror the linear copy, not the drag's table shape"
);
assert!(
agent.table_selection_geometry.is_none(),
"side-car geometry of the abandoned cell selection must be dropped"
);
}
/// When the table-aware copy did produce the text, the drag's table kind
/// persists and the side-car geometry stays for the overlay to consume.
#[test]
fn table_copy_success_keeps_table_kind_and_side_car() {
let mut agent = agent_with_visible_table_lines_only();
let drag = table_drag();
agent.persist_drag_selection(&drag, drag.kind);
let sel = agent.persistent_text_selection.expect("selection persists");
assert_eq!(sel.kind, drag.kind);
assert!(agent.table_selection_geometry.is_some());
}
/// Run one double-click gesture (click + click, 100ms apart) at `t` on
/// `idx`, threading `last_click` the way the mouse caller does. Returns
/// the tip flag of the second click.
fn double_click_gesture(agent: &mut AgentView, t: Instant, idx: usize) -> bool {
let (last, tip1) = agent.handle_scrollback_click(t, idx, false);
assert!(!tip1, "a single click must never tip");
agent.last_click = last;
let (last, tip2) =
agent.handle_scrollback_click(t + Duration::from_millis(100), idx, false);
agent.last_click = last;
tip2
}
/// The word-select tip needs a REPEATED double-click on assistant text:
/// the first gesture is treated as intentional folding; only a second
/// gesture inside the repeat window tips. Fold-affordance surfaces
/// (tool rows etc.) never tip and never arm the probe.
#[test]
fn word_select_tip_requires_repeated_double_click_on_assistant_text() {
use crate::appearance::TextSelection;
crate::appearance::cache::set_keep_text_selection(TextSelection::Flash);
let mut agent = make_agent();
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::agent_message(
"assistant words to select",
));
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::tool_call(
"Tool", "info", true,
));
agent.scrollback.prepare_layout(80, 40);
let t0 = Instant::now();
assert!(
!double_click_gesture(&mut agent, t0, 0),
"first double-click gesture is intentional folding — no tip"
);
// Second, separate gesture (past the 300ms multi-click timeout,
// inside the 10s repeat window) → the repeated-attempt signal.
let t1 = t0 + Duration::from_secs(1);
assert!(
double_click_gesture(&mut agent, t1, 0),
"repeated double-click on assistant text must tip"
);
// Tool-call rows are fold affordances: repeated gestures never tip
// and must not arm the probe for a later assistant-text click.
agent.last_word_select_probe = None;
let t2 = t1 + Duration::from_secs(2);
assert!(!double_click_gesture(&mut agent, t2, 1));
let t3 = t2 + Duration::from_secs(1);
assert!(!double_click_gesture(&mut agent, t3, 1));
assert!(
agent.last_word_select_probe.is_none(),
"fold-affordance double-clicks must not arm the probe"
);
// A stale probe outside the window does not tip, but re-arms.
let t4 = t3 + Duration::from_secs(1);
agent.last_word_select_probe = Some(t4 - Duration::from_secs(11));
assert!(
!double_click_gesture(&mut agent, t4, 0),
"probe outside the repeat window must not tip"
);
let t5 = t4 + Duration::from_secs(1);
assert!(
double_click_gesture(&mut agent, t5, 0),
"the expired attempt still re-arms for the next gesture"
);
}
// -----------------------------------------------------------------------
// reclamp_drag_head_post_render tests
// -----------------------------------------------------------------------
fn mouse_down(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: crossterm::event::KeyModifiers::empty(),
}
}
fn mouse_drag(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: col,
row,
modifiers: crossterm::event::KeyModifiers::empty(),
}
}
/// A one-range model whose lines `first_bl..first_bl+count` sit on
/// consecutive rows starting at `first_y` — model B of a reclamp test is
/// the same range with shifted indices/rows (a scrolled frame).
fn stacked_lines_model(first_bl: usize, first_y: u16, count: usize) -> ResolvedSelectionModel {
let mut model = ResolvedSelectionModel::default();
for i in 0..count {
model.push_line(ResolvedSelectableLine {
entry_idx: 0,
range_id: 0,
block_line_idx: first_bl + i,
screen_y: first_y + i as u16,
screen_x: 0,
selectable_cols: 0..40,
text: format!("stacked line {}", first_bl + i),
joiner_to_previous: (first_bl + i > 0).then(|| "\n".to_string()),
});
}
model
}
/// Down→Drag→Drag through `handle_input` on `stacked_lines_model(0, 5, 3)`
/// so the drag is genuinely promoted and `last_drag_mouse` is (10, 7).
fn latch_drag_with_mouse_held(agent: &mut AgentView, reg: &ActionRegistry) {
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 24);
agent.active_pane = AgentPane::Scrollback;
agent.last_scrollback_selection_model = stacked_lines_model(0, 5, 3);
let _ = agent.handle_input(&Event::Mouse(mouse_down(2, 5)), reg);
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 6)), reg);
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 7)), reg);
let drag = agent.drag_selection.expect("drag promoted");
assert_eq!(drag.anchor.block_line_idx, 0, "setup: anchor on line 0");
assert_eq!(drag.head.block_line_idx, 2, "setup: head on line 2");
assert_eq!(agent.last_drag_mouse, Some((10, 7)), "setup: pointer held");
}
/// After a scroll the rebuilt model puts different lines under the held
/// pointer; the post-render reclamp must move the head there.
#[test]
fn reclamp_follows_held_pointer_onto_rebuilt_model() {
let mut agent = make_agent();
let reg = ActionRegistry::defaults();
latch_drag_with_mouse_held(&mut agent, &reg);
// Scrolled down by two rows: lines 2..=6 now occupy rows 5..=9.
agent.last_scrollback_selection_model = stacked_lines_model(2, 5, 5);
agent.reclamp_drag_head_post_render(false);
let drag = agent.drag_selection.expect("drag still active");
assert_eq!(
drag.head.block_line_idx, 4,
"head must re-resolve to the line now under the pointer row"
);
assert_eq!(drag.head.col_within_range, 10);
assert_eq!(drag.anchor.block_line_idx, 0, "anchor never reclamps");
}
/// Promotion stores the pointer, so a promote-then-hold-still drag is
/// reclamped as soon as the next frame rebuilds the model (e.g. a wheel
/// scroll between mouse-down and the promoting move).
#[test]
fn promotion_stores_pointer_for_reclamp() {
let mut agent = make_agent();
let reg = ActionRegistry::defaults();
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 24);
agent.active_pane = AgentPane::Scrollback;
agent.last_scrollback_selection_model = stacked_lines_model(0, 5, 3);
let _ = agent.handle_input(&Event::Mouse(mouse_down(2, 5)), &reg);
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 6)), &reg);
assert!(agent.drag_selection.is_some(), "setup: drag promoted");
assert_eq!(
agent.last_drag_mouse,
Some((10, 6)),
"promotion must store the pointer"
);
// No further motion: the rebuilt (scrolled) model alone must move
// the head to the line now under the held pointer.
agent.last_scrollback_selection_model = stacked_lines_model(2, 5, 5);
agent.reclamp_drag_head_post_render(false);
let drag = agent.drag_selection.expect("drag still active");
assert_eq!(drag.head.block_line_idx, 3);
}
/// Guard: without an active drag the reclamp must not fabricate one.
#[test]
fn reclamp_noop_without_active_drag() {
let mut agent = make_agent();
agent.last_scrollback_selection_model = stacked_lines_model(0, 5, 3);
agent.last_drag_mouse = Some((10, 6));
agent.reclamp_drag_head_post_render(false);
assert!(agent.drag_selection.is_none());
}
/// Guard: without a held-pointer position (cleared by finish/recovery
/// paths) there is nothing to re-resolve against, so the head stays put.
#[test]
fn reclamp_noop_without_last_drag_mouse() {
let mut agent = make_agent();
let anchor = RangeHit {
entry_idx: 0,
range_id: 0,
block_line_idx: 0,
col_within_range: 2,
};
agent.drag_selection = Some(ActiveTextDrag {
anchor,
head: RangeHit {
block_line_idx: 1,
..anchor
},
kind: SelectionKind::Linear,
anchor_content_width: None,
});
agent.last_drag_mouse = None;
agent.last_scrollback_selection_model = stacked_lines_model(0, 5, 3);
agent.reclamp_drag_head_post_render(false);
let drag = agent.drag_selection.expect("drag still active");
assert_eq!(
drag.head.block_line_idx, 1,
"head untouched without pointer"
);
}
/// A btw-anchored drag reclamps only on the btw rebuild and only against
/// the btw model: the scrollback rebuild is gated off, scrollback lines
/// under the pointer never capture its head, and a btw model miss keeps
/// the previous head.
#[test]
fn reclamp_btw_drag_gated_to_btw_rebuild_and_model() {
let mut agent = make_agent();
let btw_anchor = RangeHit {
entry_idx: crate::views::btw_overlay::BTW_OVERLAY_ENTRY_IDX,
range_id: 0,
block_line_idx: 0,
col_within_range: 0,
};
agent.drag_selection = Some(ActiveTextDrag {
anchor: btw_anchor,
head: btw_anchor,
kind: SelectionKind::Linear,
anchor_content_width: None,
});
agent.last_drag_mouse = Some((10, 6));
// Scrollback model has a hit under the pointer; btw model is empty.
agent.last_scrollback_selection_model = stacked_lines_model(0, 5, 3);
agent.last_btw_selection_model = ResolvedSelectionModel::default();
agent.reclamp_drag_head_post_render(false);
agent.reclamp_drag_head_post_render(true);
let drag = agent.drag_selection.expect("drag still active");
assert_eq!(drag.head, btw_anchor, "btw head must not follow scrollback");
// Once the btw model has lines, only the btw rebuild moves the head.
let mut btw_model = ResolvedSelectionModel::default();
btw_model.push_line(ResolvedSelectableLine {
entry_idx: BTW_OVERLAY_ENTRY_IDX,
range_id: 0,
block_line_idx: 3,
screen_y: 6,
screen_x: 0,
selectable_cols: 0..40,
text: "btw line".to_string(),
joiner_to_previous: None,
});
agent.last_btw_selection_model = btw_model;
agent.reclamp_drag_head_post_render(false);
let drag = agent.drag_selection.expect("drag still active");
assert_eq!(drag.head, btw_anchor, "scrollback rebuild is gated off");
agent.reclamp_drag_head_post_render(true);
let drag = agent.drag_selection.expect("drag still active");
assert_eq!(drag.head.block_line_idx, 3, "btw rebuild moves the head");
}
// -----------------------------------------------------------------------
// anchor_content_width snapshot tests
// -----------------------------------------------------------------------
/// The linear copy resolves the anchor entry's lines with the drag-start
/// width snapshot when the block is gone from `visible_blocks` (scrolled
/// fully out before mouse-up); without the snapshot that copy fails.
#[test]
fn reconstruct_drag_copy_uses_width_snapshot_when_anchor_block_scrolled_out() {
let mut agent = make_agent();
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::agent_message(
"SNAPWIDTH alpha beta",
));
// The rebuilt frame no longer contains the anchor block at all.
agent.last_scrollback_selection_model = ResolvedSelectionModel::default();
let drag = ActiveTextDrag {
anchor: RangeHit {
entry_idx: 0,
range_id: 0,
block_line_idx: 0,
col_within_range: 0,
},
head: RangeHit {
entry_idx: 0,
range_id: 0,
block_line_idx: 0,
col_within_range: 8,
},
kind: SelectionKind::Linear,
anchor_content_width: Some(60),
};
let (text, kind) = agent
.reconstruct_drag_copy(&drag)
.expect("snapshot width must reach the full-output reconstruction");
assert_eq!(text, "SNAPWIDTH");
assert_eq!(kind, SelectionKind::Linear);
// Same drag without a snapshot: the mouse-up-time lookup misses and
// the visible-model fallback has nothing.
let no_snapshot = ActiveTextDrag {
anchor_content_width: None,
..drag
};
assert!(agent.reconstruct_drag_copy(&no_snapshot).is_none());
}
/// Mouse-down on a selectable line snapshots the anchor block's render
/// width and promotion carries it onto the active drag.
#[test]
fn drag_promotion_carries_anchor_width_snapshot() {
let mut agent = make_agent();
let reg = ActionRegistry::defaults();
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 24);
agent.active_pane = AgentPane::Scrollback;
let mut model = stacked_lines_model(0, 5, 3);
model.visible_blocks.push(VisibleBlockGeometry {
entry_idx: 0,
area: Rect::new(0, 5, 46, 3),
content_area: Rect::new(0, 5, 46, 3),
selection_area: Rect::new(0, 5, 46, 3),
content_width: 46,
top_clipped: false,
bottom_clipped: false,
drag_startable: true,
});
agent.last_scrollback_selection_model = model;
let _ = agent.handle_input(&Event::Mouse(mouse_down(2, 5)), &reg);
assert_eq!(
agent.pending_text_drag.and_then(|p| p.anchor_content_width),
Some(46)
);
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 6)), &reg);
assert_eq!(
agent.drag_selection.and_then(|d| d.anchor_content_width),
Some(46)
);
}
/// Resolver miss at promotion (the anchor's range vanished from the
/// frame between press and threshold): the head collapses to the
/// anchor — the live successor of the deleted clamp-to-anchor helper.
#[test]
fn promotion_miss_collapses_head_to_anchor() {
let mut agent = make_agent();
let reg = ActionRegistry::defaults();
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 24);
agent.active_pane = AgentPane::Scrollback;
agent.last_scrollback_selection_model = stacked_lines_model(0, 5, 3);
let _ = agent.handle_input(&Event::Mouse(mouse_down(2, 5)), &reg);
assert!(agent.pending_text_drag.is_some(), "setup: pending armed");
// The rebuilt frame lost the range entirely.
agent.last_scrollback_selection_model = ResolvedSelectionModel::default();
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 7)), &reg);
let drag = agent.drag_selection.expect("promoted despite the miss");
assert_eq!(drag.head, drag.anchor, "head collapsed to the anchor");
}
/// Resolver miss mid-drag (the range scrolled fully out): the head
/// keeps its previous position instead of jumping — the live successor
/// of the deleted keep-previous-head helper.
#[test]
fn active_drag_motion_miss_keeps_previous_head() {
let mut agent = make_agent();
let reg = ActionRegistry::defaults();
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 24);
agent.active_pane = AgentPane::Scrollback;
agent.last_scrollback_selection_model = stacked_lines_model(0, 5, 3);
let _ = agent.handle_input(&Event::Mouse(mouse_down(2, 5)), &reg);
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 7)), &reg);
let head_before = agent.drag_selection.expect("setup: active").head;
assert_eq!(head_before.block_line_idx, 2, "setup: head extended");
agent.last_scrollback_selection_model = ResolvedSelectionModel::default();
let _ = agent.handle_input(&Event::Mouse(mouse_drag(30, 9)), &reg);
let drag = agent.drag_selection.expect("still active");
assert_eq!(drag.head, head_before, "head kept across the miss");
}
/// The TABLE-shaped copy also survives full scroll-out via the width
/// snapshot: the side-car geometry frozen while the block was visible
/// re-detects against the snapshot-width output and the cell text is
/// copied, with no `visible_blocks` entry at mouse-up. Without the
/// snapshot the whole copy fails.
#[test]
fn table_copy_uses_width_snapshot_when_anchor_block_scrolled_out() {
const WIDTH: u16 = 40;
let mut agent = make_agent();
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::agent_message(
"| Name | Role |\n|------|------|\n| Alice | Eng |",
));
// Freeze the side-car the way promotion does, from the entry's own
// rendering at the snapshot width (probe scan finds a grid line).
let probe_geometry = (0..6)
.find_map(|probe| {
agent
.with_entry_output_text_source(0, 0, Some(WIDTH), |src| {
TableGeometry::detect(src, probe)
})
.flatten()
})
.expect("markdown table renders a detectable grid");
let anchor_line = probe_geometry.row_lines(0).start;
let anchor_col = probe_geometry.band(0).start;
// Head sweeps to the cell's far corner so the copied span covers the
// whole cell, not a single padding column.
let head_line = probe_geometry.row_lines(0).end.saturating_sub(1);
let head_col = probe_geometry.band(0).end.saturating_sub(1);
// Re-detect at the anchor line: the copy path requires the side-car
// to exactly match its own re-detect there.
let geometry = agent
.with_entry_output_text_source(0, 0, Some(WIDTH), |src| {
TableGeometry::detect(src, anchor_line)
})
.flatten()
.expect("grid detected at the anchor line");
agent.table_selection_geometry = Some(TableSelectionGeometry {
entry_idx: 0,
range_id: 0,
geometry,
});
// The rebuilt frame no longer contains the anchor block at all.
agent.last_scrollback_selection_model = ResolvedSelectionModel::default();
let anchor = RangeHit {
entry_idx: 0,
range_id: 0,
block_line_idx: anchor_line,
col_within_range: anchor_col,
};
let drag = ActiveTextDrag {
anchor,
head: RangeHit {
block_line_idx: head_line,
col_within_range: head_col,
..anchor
},
kind: SelectionKind::TableCell,
anchor_content_width: Some(WIDTH),
};
let (text, kind) = agent
.reconstruct_drag_copy(&drag)
.expect("snapshot width must keep the table copy alive");
assert_eq!(kind, SelectionKind::TableCell);
assert!(text.contains("Name"), "cell content copied: {text:?}");
assert!(!text.contains('│'), "no border glyphs: {text:?}");
assert!(!text.contains("Role"), "band-clamped to one cell: {text:?}");
// Without the snapshot neither the table nor the linear path has a
// width, so the copy fails outright.
let no_snapshot = ActiveTextDrag {
anchor_content_width: None,
..drag
};
assert!(agent.reconstruct_drag_copy(&no_snapshot).is_none());
}
/// The btw copy prefers the drag-start width snapshot; the current
/// panel-area fallback only applies without one (here the default area
/// makes the fallback width 0, so only the snapshot path can produce
/// the text).
#[test]
fn btw_copy_prefers_width_snapshot_over_panel_fallback() {
let mut agent = make_agent();
agent.btw_state = Some(crate::views::btw_overlay::BtwOverlayState::done(
"q".to_string(),
"BTWSNAP alpha beta".to_string(),
));
let anchor = RangeHit {
entry_idx: BTW_OVERLAY_ENTRY_IDX,
range_id: 0,
block_line_idx: 0,
col_within_range: 0,
};
let drag = ActiveTextDrag {
anchor,
head: RangeHit {
col_within_range: 6,
..anchor
},
kind: SelectionKind::Linear,
anchor_content_width: Some(30),
};
let (text, kind) = agent
.reconstruct_drag_copy(&drag)
.expect("snapshot width must reach the btw full model");
assert_eq!(text, "BTWSNAP");
assert_eq!(kind, SelectionKind::Linear);
let no_snapshot = ActiveTextDrag {
anchor_content_width: None,
..drag
};
assert!(agent.reconstruct_drag_copy(&no_snapshot).is_none());
}
// -----------------------------------------------------------------------
// deferred text-press (anchor on entry into text) tests
// -----------------------------------------------------------------------
fn mouse_up(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: col,
row,
modifiers: crossterm::event::KeyModifiers::empty(),
}
}
/// Two message entries with chrome rows and a dead gap between them:
/// entry 0 area rows 4-6 (row 4 chrome, text on rows 5-6, width 46),
/// rows 7-8 dead gap, entry 1 area rows 9-10 (row 9 chrome, text on
/// row 10, width 52).
fn agent_with_chrome_and_gap() -> AgentView {
let mut agent = make_agent();
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 24);
agent.active_pane = AgentPane::Scrollback;
let mut model = ResolvedSelectionModel::default();
for (bl, y) in [(0usize, 5u16), (1, 6)] {
model.push_line(ResolvedSelectableLine {
entry_idx: 0,
range_id: 0,
block_line_idx: bl,
screen_y: y,
screen_x: 0,
selectable_cols: 0..20,
text: format!("entry zero line {bl}"),
joiner_to_previous: (bl > 0).then(|| "\n".to_string()),
});
}
model.push_line(ResolvedSelectableLine {
entry_idx: 1,
range_id: 0,
block_line_idx: 0,
screen_y: 10,
screen_x: 0,
selectable_cols: 0..20,
text: "entry one line".to_string(),
joiner_to_previous: None,
});
let block = |entry_idx: usize, area: Rect, content_width: u16| VisibleBlockGeometry {
entry_idx,
area,
content_area: area,
selection_area: area,
content_width,
top_clipped: false,
bottom_clipped: false,
drag_startable: true,
};
model
.visible_blocks
.push(block(0, Rect::new(0, 4, 46, 3), 46));
model
.visible_blocks
.push(block(1, Rect::new(0, 9, 52, 2), 52));
agent.last_scrollback_selection_model = model;
agent
}
/// A chrome press arms the block drag AND the anchor-less latch; motion
/// that stays on chrome/gap rows promotes and extends the block drag in
/// both directions exactly as before, with the latch armed but idle.
#[test]
fn chrome_press_drag_within_chrome_is_block_drag_unchanged() {
let mut agent = agent_with_chrome_and_gap();
let reg = ActionRegistry::defaults();
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 4)), &reg);
assert_eq!(agent.deferred_text_press, Some((6, 4)));
let pending = agent.pending_block_drag.expect("block drag pends");
assert_eq!(pending.anchor_entry_idx, 0);
assert!(agent.pending_text_drag.is_none());
let _ = agent.handle_input(&Event::Mouse(mouse_drag(6, 9)), &reg);
let drag = agent.block_drag_selection.expect("block drag promoted");
assert_eq!((drag.anchor_entry_idx, drag.head_entry_idx), (0, 1));
let _ = agent.handle_input(&Event::Mouse(mouse_drag(6, 4)), &reg);
let drag = agent.block_drag_selection.expect("still a block drag");
assert_eq!((drag.anchor_entry_idx, drag.head_entry_idx), (0, 0));
assert!(agent.drag_selection.is_none());
assert_eq!(agent.deferred_text_press, Some((6, 4)), "latch stays armed");
}
/// A chrome press whose drag enters text converts to a text drag
/// anchored at the ENTRY position (not the press, not nearest-to-press)
/// with the entry block's width snapshot, and cancels the block drag
/// already in flight.
#[test]
fn chrome_press_drag_into_text_converts_at_entry_point() {
let mut agent = agent_with_chrome_and_gap();
let reg = ActionRegistry::defaults();
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 4)), &reg);
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 9)), &reg);
assert!(agent.block_drag_selection.is_some(), "setup: block active");
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 10)), &reg);
let drag = agent.drag_selection.expect("converted to text drag");
let entry_hit = RangeHit {
entry_idx: 1,
range_id: 0,
block_line_idx: 0,
col_within_range: 10,
};
assert_eq!(drag.anchor, entry_hit, "anchor at the entry position");
assert_eq!(drag.head, entry_hit);
assert_eq!(drag.anchor_content_width, Some(52), "entry block's width");
assert_eq!(drag.kind, SelectionKind::Linear);
assert!(agent.block_drag_selection.is_none(), "block drag cancelled");
assert!(agent.pending_block_drag.is_none());
assert!(agent.deferred_text_press.is_none(), "latch consumed");
}
/// A press on a dead gap row (no block area, so nothing block-armed)
/// stays anchor-less through further dead rows and anchors at the exact
/// row/col where the pointer first enters text.
#[test]
fn gap_press_drag_through_gap_anchors_at_entry_row_and_col() {
let mut agent = agent_with_chrome_and_gap();
let reg = ActionRegistry::defaults();
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 7)), &reg);
assert_eq!(agent.deferred_text_press, Some((6, 7)));
assert!(agent.pending_block_drag.is_none(), "gap is outside areas");
let _ = agent.handle_input(&Event::Mouse(mouse_drag(6, 8)), &reg);
assert!(agent.drag_selection.is_none(), "still dead: no conversion");
assert!(agent.block_drag_selection.is_none());
let _ = agent.handle_input(&Event::Mouse(mouse_drag(14, 6)), &reg);
let drag = agent.drag_selection.expect("converted on text entry");
assert_eq!(
drag.anchor,
RangeHit {
entry_idx: 0,
range_id: 0,
block_line_idx: 1,
col_within_range: 14,
},
"anchored at the entry row/col"
);
assert_eq!(drag.anchor_content_width, Some(46));
}
/// Press + release with no motion is a plain click: no selection of any
/// kind, and the click cascade still consumes the press.
#[test]
fn deferred_press_release_without_motion_is_plain_click() {
let mut agent = agent_with_chrome_and_gap();
let reg = ActionRegistry::defaults();
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 7)), &reg);
assert!(
agent.pending_scrollback_click.is_some(),
"setup: click pends"
);
let _ = agent.handle_input(&Event::Mouse(mouse_up(6, 7)), &reg);
assert!(agent.deferred_text_press.is_none());
assert!(agent.drag_selection.is_none());
assert!(agent.block_drag_selection.is_none());
assert!(agent.persistent_text_selection.is_none());
assert!(
agent.pending_scrollback_click.is_none(),
"the click path consumed the press"
);
}
/// A gesture that never enters text finishes as a whole-block copy,
/// exactly as without the latch (payload content pinned by e2e).
#[test]
fn deferred_press_never_entering_text_finishes_block_copy() {
let mut agent = agent_with_chrome_and_gap();
let reg = ActionRegistry::defaults();
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::agent_message(
"BLOCKCOPY zero",
));
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::agent_message(
"BLOCKCOPY one",
));
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 4)), &reg);
let _ = agent.handle_input(&Event::Mouse(mouse_drag(6, 9)), &reg);
assert!(agent.block_drag_selection.is_some(), "setup: block active");
let outcome = agent.handle_input(&Event::Mouse(mouse_up(6, 9)), &reg);
assert!(
matches!(outcome, InputOutcome::Changed),
"block copy finished"
);
assert!(agent.block_drag_selection.is_none());
assert!(agent.drag_selection.is_none());
assert!(agent.persistent_text_selection.is_none());
assert!(agent.deferred_text_press.is_none());
}
/// Conversion is one-way: once the gesture is a text drag, motion back
/// over chrome/gap rows keeps extending it (the pre-existing head rule:
/// nearest line within the anchor's range) and never re-arms block drag.
#[test]
fn converted_drag_stays_text_over_chrome_and_gap() {
let mut agent = agent_with_chrome_and_gap();
let reg = ActionRegistry::defaults();
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 7)), &reg);
let _ = agent.handle_input(&Event::Mouse(mouse_drag(14, 6)), &reg);
assert!(agent.drag_selection.is_some(), "setup: converted");
let _ = agent.handle_input(&Event::Mouse(mouse_drag(6, 4)), &reg);
let drag = agent.drag_selection.expect("still a text drag");
assert_eq!(drag.head.block_line_idx, 0, "head snapped within range");
assert_eq!(drag.head.col_within_range, 6);
assert!(agent.block_drag_selection.is_none(), "no block re-arm");
assert!(agent.pending_block_drag.is_none());
let _ = agent.handle_input(&Event::Mouse(mouse_drag(6, 9)), &reg);
let drag = agent.drag_selection.expect("still a text drag");
assert_eq!(drag.head.block_line_idx, 1, "head follows nearest line");
assert_eq!(drag.anchor.entry_idx, 0, "anchor pinned to entry 0");
}
/// The latch clears on the recovery path and on the stale-latch guard
/// (any non-drag event, e.g. Esc, while latched).
#[test]
fn deferred_latch_cleared_on_recovery_and_stale_event() {
let mut agent = agent_with_chrome_and_gap();
let reg = ActionRegistry::defaults();
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 7)), &reg);
assert!(agent.scrollback_drag_latched(), "latch counts as a drag");
agent.clear_stuck_scrollback_drag();
assert!(agent.deferred_text_press.is_none());
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 7)), &reg);
assert!(agent.deferred_text_press.is_some());
let esc = Event::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::empty(),
));
let _ = agent.handle_input(&esc, &reg);
assert!(
agent.deferred_text_press.is_none(),
"stale-latch guard cleared the deferred press"
);
}
/// The chrome-and-gap agent with the panes shrunk so rows 16-19 form the
/// passive strip band between the scrollback pane (rows 0-15) and the
/// prompt box (rows 20-22) — turn status / banner / gap-row territory.
fn agent_with_above_prompt_strip() -> AgentView {
let mut agent = agent_with_chrome_and_gap();
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 16);
agent.pane_areas.prompt = Rect::new(0, 20, 80, 3);
agent
}
/// A press on the strip band arms ONLY the anchor-less latch: no block
/// or text drag pends, and no click latch is set (release keeps doing
/// nothing, as the band did before).
#[test]
fn strip_press_arms_deferred_latch_only() {
let mut agent = agent_with_above_prompt_strip();
let reg = ActionRegistry::defaults();
let outcome = agent.handle_input(&Event::Mouse(mouse_down(10, 17)), &reg);
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(agent.deferred_text_press, Some((10, 17)));
assert!(agent.pending_block_drag.is_none());
assert!(agent.pending_text_drag.is_none());
assert!(agent.pending_scrollback_click.is_none(), "no click latch");
}
/// Dragging from the strip band up into message text converts at the
/// entry point, exactly like the in-pane deferred press.
#[test]
fn strip_press_drag_into_text_converts_at_entry_point() {
let mut agent = agent_with_above_prompt_strip();
let reg = ActionRegistry::defaults();
let _ = agent.handle_input(&Event::Mouse(mouse_down(10, 17)), &reg);
let _ = agent.handle_input(&Event::Mouse(mouse_drag(14, 6)), &reg);
let drag = agent.drag_selection.expect("converted on text entry");
assert_eq!(
drag.anchor,
RangeHit {
entry_idx: 0,
range_id: 0,
block_line_idx: 1,
col_within_range: 14,
},
"anchored at the entry row/col"
);
assert_eq!(drag.anchor_content_width, Some(46));
assert!(agent.deferred_text_press.is_none(), "latch consumed");
}
/// Press + release on the strip without motion does what the band did
/// before the latch existed: nothing.
#[test]
fn strip_press_release_without_motion_does_nothing() {
let mut agent = agent_with_above_prompt_strip();
let reg = ActionRegistry::defaults();
let _ = agent.handle_input(&Event::Mouse(mouse_down(10, 17)), &reg);
let _ = agent.handle_input(&Event::Mouse(mouse_up(10, 17)), &reg);
assert!(agent.deferred_text_press.is_none());
assert!(agent.drag_selection.is_none());
assert!(agent.block_drag_selection.is_none());
assert!(agent.persistent_text_selection.is_none());
assert_eq!(agent.scrollback.selected(), None, "no entry selected");
}
/// A strip drag that never enters text selects nothing and leaves no
/// state behind on release.
#[test]
fn strip_press_drag_never_entering_text_selects_nothing() {
let mut agent = agent_with_above_prompt_strip();
let reg = ActionRegistry::defaults();
let _ = agent.handle_input(&Event::Mouse(mouse_down(10, 17)), &reg);
let _ = agent.handle_input(&Event::Mouse(mouse_drag(30, 18)), &reg);
assert!(agent.drag_selection.is_none(), "no text under the pointer");
let _ = agent.handle_input(&Event::Mouse(mouse_up(30, 18)), &reg);
assert!(agent.deferred_text_press.is_none());
assert!(agent.drag_selection.is_none());
assert!(agent.persistent_text_selection.is_none());
}
/// Interactive controls in and around the band never arm the latch:
/// the scrollbar and the turn-status cancel button consume their press
/// first, and the prompt box routes to the prompt pane.
#[test]
fn interactive_rows_do_not_arm_deferred_latch() {
let reg = ActionRegistry::defaults();
let mut agent = agent_with_above_prompt_strip();
agent.hit_scrollbar.set(Some(Rect::new(79, 0, 1, 20)));
let _ = agent.handle_input(&Event::Mouse(mouse_down(79, 17)), &reg);
assert!(agent.scrollbar_dragging, "scrollbar owns the press");
assert!(agent.deferred_text_press.is_none());
let mut agent = agent_with_above_prompt_strip();
agent.hit_cancel_button.set(Some(Rect::new(30, 17, 6, 1)));
let outcome = agent.handle_input(&Event::Mouse(mouse_down(32, 17)), &reg);
assert!(
matches!(
outcome,
InputOutcome::Action(crate::app::actions::Action::CancelTurn)
),
"cancel button owns the press"
);
assert!(agent.deferred_text_press.is_none());
let mut agent = agent_with_above_prompt_strip();
let _ = agent.handle_input(&Event::Mouse(mouse_down(5, 21)), &reg);
assert!(agent.deferred_text_press.is_none(), "prompt is a pane");
}
/// With the block viewer open the band must not arm: the modal owns the
/// screen while the scrollback model beneath keeps rebuilding, so an
/// armed latch would convert on (and copy) text hidden under it.
#[test]
fn strip_press_with_block_viewer_open_arms_nothing() {
let mut agent = agent_with_above_prompt_strip();
let reg = ActionRegistry::defaults();
agent.block_viewer = Some(crate::views::block_viewer::BlockViewerPane::for_plain_text(
"t", "content",
));
let _ = agent.handle_input(&Event::Mouse(mouse_down(10, 17)), &reg);
assert!(agent.deferred_text_press.is_none(), "viewer owns the press");
let _ = agent.handle_input(&Event::Mouse(mouse_drag(14, 6)), &reg);
assert!(agent.drag_selection.is_none(), "nothing converts");
assert!(agent.persistent_text_selection.is_none());
}
/// A strip press keeps focus where it was (deliberate), so with the
/// prompt focused the un-converted motion crosses the prompt box — the
/// armed gesture must own that motion: nothing may leak into the prompt
/// handlers, and the gesture must still convert once it reaches text.
#[test]
fn strip_gesture_does_not_leak_motion_into_prompt() {
let mut agent = agent_with_above_prompt_strip();
let reg = ActionRegistry::defaults();
agent.active_pane = AgentPane::Prompt;
agent.prompt.set_text("draft text");
let cursor_before = agent.prompt.cursor();
let _ = agent.handle_input(&Event::Mouse(mouse_down(10, 17)), &reg);
assert!(agent.deferred_text_press.is_some(), "setup: latch armed");
let outcome = agent.handle_input(&Event::Mouse(mouse_drag(5, 21)), &reg);
assert!(matches!(outcome, InputOutcome::Unchanged));
assert_eq!(agent.prompt.text(), "draft text", "prompt text untouched");
assert_eq!(agent.prompt.cursor(), cursor_before, "cursor untouched");
let _ = agent.handle_input(&Event::Mouse(mouse_drag(14, 6)), &reg);
assert!(
agent.drag_selection.is_some(),
"conversion survives crossing the prompt region"
);
}
/// A strip press replaces any held highlight immediately, exactly like
/// the in-pane press's eager clear.
#[test]
fn strip_press_clears_previous_persistent_selection() {
let mut agent = agent_with_above_prompt_strip();
let reg = ActionRegistry::defaults();
let anchor = RangeHit {
entry_idx: 0,
range_id: 0,
block_line_idx: 0,
col_within_range: 0,
};
agent.persist_drag_selection(
&ActiveTextDrag {
anchor,
head: RangeHit {
col_within_range: 5,
..anchor
},
kind: SelectionKind::Linear,
anchor_content_width: None,
},
SelectionKind::Linear,
);
agent.table_selection_geometry = Some(TableSelectionGeometry {
entry_idx: 0,
range_id: 0,
geometry: table_geometry(),
});
agent.selection_created_at = Some(Instant::now());
assert!(agent.persistent_text_selection.is_some(), "setup: held");
let _ = agent.handle_input(&Event::Mouse(mouse_down(10, 17)), &reg);
assert!(agent.persistent_text_selection.is_none(), "highlight gone");
assert!(agent.table_selection_geometry.is_none());
assert!(agent.selection_created_at.is_none());
assert!(agent.deferred_text_press.is_some(), "latch still arms");
}
/// A press on a recap block's non-selectable rows rides the in-pane
/// deferred path (recap is a scrollback entry, not a strip): dragging
/// up into message text converts at the entry point, and a motionless
/// press still feeds the normal scrollback click cascade.
#[test]
fn recap_block_press_converts_on_text_entry_and_clicks_as_before() {
let mut agent = agent_with_chrome_and_gap();
let reg = ActionRegistry::defaults();
// Real entries so the click cascade has something to resolve; the
// recap's rows 12-14 register no selectable lines here, modeling a
// press on its non-selectable header chrome (the summary body IS
// selection-registered in production).
for _ in 0..2 {
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::agent_message("m"));
}
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::session_event(
crate::scrollback::blocks::SessionEvent::Recap {
summary: "did things".to_string(),
auto: true,
},
));
agent
.last_scrollback_selection_model
.visible_blocks
.push(VisibleBlockGeometry {
entry_idx: 2,
area: Rect::new(0, 12, 46, 3),
content_area: Rect::new(0, 12, 46, 3),
selection_area: Rect::new(0, 12, 46, 3),
content_width: 46,
top_clipped: false,
bottom_clipped: false,
drag_startable: true,
});
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 13)), &reg);
assert_eq!(agent.deferred_text_press, Some((6, 13)));
assert!(
agent.pending_scrollback_click.is_some(),
"click still pends"
);
let _ = agent.handle_input(&Event::Mouse(mouse_drag(14, 6)), &reg);
let drag = agent.drag_selection.expect("converted on text entry");
assert_eq!(drag.anchor.entry_idx, 0);
assert_eq!(drag.anchor.block_line_idx, 1);
assert_eq!(drag.anchor.col_within_range, 14);
assert!(agent.pending_block_drag.is_none(), "block drag cancelled");
assert!(agent.block_drag_selection.is_none());
// Motionless press + release on the recap, against the REAL layout
// (prepare_layout populates entry_index_at_screen_row): the click
// cascade still selects the recap entry — the latch must not eat it.
let mut agent2 = make_agent();
agent2.pane_areas.scrollback = Rect::new(0, 0, 80, 24);
agent2.active_pane = AgentPane::Scrollback;
for _ in 0..2 {
agent2
.scrollback
.push_block(crate::scrollback::block::RenderBlock::agent_message("m"));
}
agent2
.scrollback
.push_block(crate::scrollback::block::RenderBlock::session_event(
crate::scrollback::blocks::SessionEvent::Recap {
summary: "did things".to_string(),
auto: true,
},
));
agent2.scrollback.prepare_layout(80, 24);
let (recap_area, _, _) = agent2
.scrollback
.entry_screen_area(2, agent2.pane_areas.scrollback)
.expect("recap entry laid out");
let _ = agent2.handle_input(&Event::Mouse(mouse_down(6, recap_area.y)), &reg);
assert!(agent2.deferred_text_press.is_some(), "latch armed on recap");
let _ = agent2.handle_input(&Event::Mouse(mouse_up(6, recap_area.y)), &reg);
assert_eq!(
agent2.scrollback.selected(),
Some(2),
"the recap click still selects the recap entry"
);
assert!(agent2.pending_scrollback_click.is_none(), "click consumed");
assert!(agent2.drag_selection.is_none());
assert!(agent2.deferred_text_press.is_none());
}
// -----------------------------------------------------------------------
// drag-autoscroll bounce tests (tick + reclamp interplay)
// -----------------------------------------------------------------------
/// Agent over real scrollback content taller than its viewport (30
/// one-line messages through the real layout; pane rows 0-9, prompt at
/// rows 14-16 so rows 10-13 are the strip band), so
/// `tick_drag_autoscroll` moves real offsets against real clamps.
fn agent_with_tall_scrollback() -> AgentView {
let mut agent = make_agent();
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 10);
agent.pane_areas.prompt = Rect::new(0, 14, 80, 3);
agent.active_pane = AgentPane::Scrollback;
for i in 0..30 {
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::agent_message(
format!("line {i}"),
));
}
agent.scrollback.prepare_layout(80, 10);
agent
}
/// A held pointer (no motion events) in the bottom edge zone — and
/// equally on a strip row below the pane — must scroll monotonically
/// down and stop dead at the clamp: the direction is only ever written
/// by motion handlers, so ticks alone can never flip it or oscillate
/// at the boundary. The per-tick reclamp must stay a pure head snap
/// (it never scrolls).
#[test]
fn held_pointer_autoscroll_is_monotonic_and_clamp_stable() {
let mut agent = agent_with_tall_scrollback();
let (_, viewport, total) = agent.scrollback.scroll_info();
let max_offset = total - viewport as usize;
// Row 9 = pane bottom edge zone; row 12 = strip band below it.
for row in [9u16, 12] {
agent.scrollback.scroll_up(10_000);
agent.drag_autoscroll = compute_autoscroll(row, agent.pane_areas.scrollback);
let armed = agent.drag_autoscroll.expect("edge zone arms autoscroll");
assert_eq!(armed.direction, AutoScrollDirection::Down);
let mut prev = agent.scrollback.scroll_info().0;
let mut clamped_ticks = 0;
for _ in 0..200 {
agent.tick_drag_autoscroll();
agent.reclamp_drag_head_post_render(false);
let now = agent.scrollback.scroll_info().0;
assert!(now >= prev, "offset regressed: {now} < {prev} (row {row})");
clamped_ticks = if now == prev { clamped_ticks + 1 } else { 0 };
prev = now;
assert_eq!(
agent.drag_autoscroll,
Some(armed),
"ticks must never rewrite the autoscroll state"
);
}
assert_eq!(prev, max_offset, "reached the bottom clamp (row {row})");
assert!(clamped_ticks >= 100, "held flat at the clamp, no wobble");
}
// Above the top edge: the mirror run scrolls monotonically up and
// holds at offset 0.
agent.scrollback.scroll_down(10_000);
agent.drag_autoscroll = compute_autoscroll(0, agent.pane_areas.scrollback);
assert_eq!(
agent.drag_autoscroll.map(|a| a.direction),
Some(AutoScrollDirection::Up)
);
let mut prev = agent.scrollback.scroll_info().0;
for _ in 0..200 {
agent.tick_drag_autoscroll();
agent.reclamp_drag_head_post_render(false);
let now = agent.scrollback.scroll_info().0;
assert!(now <= prev, "offset regressed upward: {now} > {prev}");
prev = now;
}
assert_eq!(prev, 0, "held at the top clamp");
}
/// The strip conversion landing on the bottommost text row (inside the
/// edge zone): with content already at-bottom the clamped offset must
/// not move and the reclamped head must not wobble; with room to
/// scroll, tick + per-frame reclamp advance offset and head
/// monotonically — the reclamp never amplifies the scroll into
/// oscillation.
#[test]
fn conversion_at_bottom_edge_ticks_without_oscillation() {
let mut agent = agent_with_tall_scrollback();
let reg = ActionRegistry::defaults();
// Content at-bottom; bottom two viewport rows carry text.
agent.scrollback.scroll_down(10_000);
let clamped = agent.scrollback.scroll_info().0;
agent.last_scrollback_selection_model = stacked_lines_model(0, 8, 2);
// Real gesture: strip press, drag up onto the bottom text row
// (conversion), then a held motion there arms Down autoscroll.
let _ = agent.handle_input(&Event::Mouse(mouse_down(2, 12)), &reg);
let _ = agent.handle_input(&Event::Mouse(mouse_drag(2, 9)), &reg);
let drag = agent.drag_selection.expect("converted on the bottom row");
assert_eq!(drag.anchor.block_line_idx, 1, "anchored on the edge row");
let _ = agent.handle_input(&Event::Mouse(mouse_drag(2, 9)), &reg);
assert_eq!(
agent.drag_autoscroll.map(|a| a.direction),
Some(AutoScrollDirection::Down),
"bottom-row anchor arms Down, same as main's bottom-row drags"
);
let head_before = agent.drag_selection.unwrap().head;
for _ in 0..50 {
agent.tick_drag_autoscroll();
agent.reclamp_drag_head_post_render(false);
assert_eq!(
agent.scrollback.scroll_info().0,
clamped,
"clamped content must not move under a held pointer"
);
assert_eq!(
agent.drag_selection.unwrap().head,
head_before,
"head must not wobble while nothing moved"
);
}
// With room to scroll: rebuild the model each tick the way render
// does (content shifted up under the held pointer) and require both
// offset and head to advance monotonically.
agent.scrollback.scroll_up(10_000);
let mut prev_offset = agent.scrollback.scroll_info().0;
let mut prev_head = 0usize;
if let Some(ref mut d) = agent.drag_selection {
d.anchor.block_line_idx = 0;
d.head.block_line_idx = 0;
}
for _ in 0..50 {
agent.tick_drag_autoscroll();
let offset = agent.scrollback.scroll_info().0;
assert!(offset >= prev_offset, "offset regressed mid-autoscroll");
prev_offset = offset;
// The frame under a held pointer: rows 0-9 now show block lines
// offset..offset+10.
agent.last_scrollback_selection_model = stacked_lines_model(offset, 0, 10);
agent.reclamp_drag_head_post_render(false);
let head = agent.drag_selection.unwrap().head.block_line_idx;
assert!(head >= prev_head, "head bounced back: {head} < {prev_head}");
prev_head = head;
}
assert!(prev_head > 0, "head advanced with the scrolled content");
}
/// A wheel-up mid-drag while Down autoscroll is armed: each writer
/// moves the offset once per its own event (the wheel never rewrites
/// the autoscroll state, ticks never re-apply the wheel), so there is
/// no feedback loop — after the wheel stops, ticks settle the offset
/// back at the clamp.
#[test]
fn wheel_up_during_autoscroll_down_settles_without_feedback() {
let mut agent = agent_with_tall_scrollback();
agent.scrollback.scroll_up(10_000);
agent.drag_autoscroll = compute_autoscroll(9, agent.pane_areas.scrollback);
let armed = agent.drag_autoscroll.expect("Down armed");
for _ in 0..10 {
agent.tick_drag_autoscroll();
}
let mid = agent.scrollback.scroll_info().0;
assert!(mid > 0, "setup: autoscroll moved off the top");
agent.handle_scroll(-3, 5, 5);
let after_wheel = agent.scrollback.scroll_info().0;
assert_eq!(after_wheel, mid - 3, "the wheel wrote exactly once");
assert_eq!(
agent.drag_autoscroll,
Some(armed),
"the wheel must not rewrite the autoscroll state"
);
let (_, viewport, total) = agent.scrollback.scroll_info();
let max_offset = total - viewport as usize;
let mut prev = after_wheel;
for _ in 0..200 {
agent.tick_drag_autoscroll();
agent.reclamp_drag_head_post_render(false);
let now = agent.scrollback.scroll_info().0;
assert!(
now >= prev,
"offset regressed after the wheel: {now} < {prev}"
);
prev = now;
}
assert_eq!(prev, max_offset, "ticks settle back at the clamp");
}
/// Btw presses never arm the deferred latch: the panel keeps its exact
/// hitbox (a press on its non-text area arms nothing).
#[test]
fn btw_press_does_not_arm_deferred_latch() {
let mut agent = make_agent();
let reg = ActionRegistry::defaults();
agent.last_btw_area = Rect::new(10, 10, 30, 6);
let _ = agent.handle_input(&Event::Mouse(mouse_down(12, 12)), &reg);
assert!(agent.deferred_text_press.is_none(), "btw stays exact-only");
assert!(agent.pending_block_drag.is_none());
assert!(agent.pending_text_drag.is_none());
}
}