import React, { type RefObject, useEffect, useRef } from 'react'; import { useNotifications } from '../context/notifications.js'; import { useCopyOnSelect, useSelectionBgColor } from '../hooks/useCopyOnSelect.js'; import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; import { useSelection } from '../ink/hooks/use-selection.js'; import type { FocusMove, SelectionState } from '../ink/selection.js'; import { isXtermJs } from '../ink/terminal.js'; import { getClipboardPath } from '../ink/termio/osc.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- Esc needs conditional propagation based on selection state import { type Key, useInput } from '../ink.js'; import { useKeybindings } from '../keybindings/useKeybinding.js'; import { logForDebugging } from '../utils/debug.js'; type Props = { scrollRef: RefObject; isActive: boolean; /** Called after every scroll action with the resulting sticky state and * the handle (for reading scrollTop/scrollHeight post-scroll). */ onScroll?: (sticky: boolean, handle: ScrollBoxHandle) => void; /** Enables modal pager keys (g/G, ctrl+u/d/b/f). Only safe when there * is no text input competing for those characters — i.e. transcript * mode. Defaults to false. When true, G works regardless of editorMode * and sticky state; ctrl+u/d/b/f don't conflict with kill-line/exit/ * task:background/kill-agents (none are mounted, or they mount after * this component so stopImmediatePropagation wins). */ isModal?: boolean; }; // Terminals send one SGR wheel event per intended row (verified in Ghostty // src/Surface.zig: `for (0..@abs(y.delta)) |_| { mouseReport(.four, ...) }`). // Ghostty already 3×'s discrete wheel ticks before that loop; trackpad // precision scroll is pixels/cell_size. 1 event = 1 row intended — use it // as the base, and ramp a multiplier when events arrive rapidly. The // pendingScrollDelta accumulator + proportional drain in // render-node-to-output handles smooth catch-up on big bursts. // // xterm.js (VS Code/Cursor/Windsurf integrated terminals) sends exactly 1 // event per wheel notch — no pre-amplification. A separate exponential // decay curve (below) compensates for the lower event rate, with burst // detection and gap-dependent caps tuned to VS Code's event patterns. // Native terminals: hard-window linear ramp. Events closer than the window // ramp the multiplier; idle gaps reset to `base` (default 1). Some emulators // pre-multiply at their layer (ghostty discrete=3 sends 3 SGR events/notch; // iTerm2 "faster scroll" similar) — base=1 is correct there. Others send 1 // event/notch — users on those can set CLAUDE_CODE_SCROLL_SPEED=3 to match // vim/nvim/opencode app-side defaults. We can't detect which, so knob it. const WHEEL_ACCEL_WINDOW_MS = 40; const WHEEL_ACCEL_STEP = 0.3; const WHEEL_ACCEL_MAX = 6; // Encoder bounce debounce + wheel-mode decay curve. Worn/cheap optical // encoders emit spurious reverse-direction ticks during fast spins — measured // 28% of events on Boris's mouse (2026-03-17, iTerm2). Pattern is always // flip-then-flip-back; trackpads produce ZERO flips (0/458 in same recording). // A confirmed bounce proves a physical wheel is attached — engage the same // exponential-decay curve the xterm.js path uses (it's already tuned), with // a higher cap to compensate for the lower event rate (~9/sec vs VS Code's // ~30/sec). Trackpad can't reach this path. // // The decay curve gives: 1st click after idle = 1 row (precision), 2nd = 10, // 3rd = cap. Slowing down decays smoothly toward 1 — no separate idle // threshold needed, large gaps just have m≈0 → mult→1. Wheel mode is STICKY: // once a bounce confirms it's a mouse, the decay curve applies until an idle // gap or trackpad-flick-burst signals a possible device switch. const WHEEL_BOUNCE_GAP_MAX_MS = 200; // flip-back must arrive within this // Mouse is ~9 events/sec vs VS Code's ~30 — STEP is 3× xterm.js's 5 to // compensate. At gap=100ms (m≈0.63): one click gives 1+15*0.63≈10.5. const WHEEL_MODE_STEP = 15; const WHEEL_MODE_CAP = 15; // Max mult growth per event. Without this, the +STEP*m term jumps mult // from 1→10 in one event when wheelMode engages mid-scroll (bounce // detected after N events in trackpad mode at mult=1). User sees scroll // suddenly go 10× faster. Cap=3 gives 1→4→7→10→13→15 over ~0.5s at // 9 events/sec — smooth ramp instead of a jump. Decay is unaffected // (target1500ms OR a * trackpad-signature burst (see burstCount). State lives in a useRef so * it persists across device switches; the disengages handle mouse→trackpad. */ wheelMode: boolean; /** Consecutive <5ms events. Trackpad flick produces 100+ at <5ms; mouse * produces ≤3 (verified in /tmp/wheel-tune.txt). 5+ in a row → trackpad * signature → disengage wheel mode so device-switch doesn't leak mouse * accel to trackpad. */ burstCount: number; }; /** Compute rows for one wheel event, mutating accel state. Returns 0 when * a direction flip is deferred for bounce detection — call sites no-op on * step=0 (scrollBy(0) is a no-op, onScroll(false) is idempotent). Exported * for tests. */ export function computeWheelStep(state: WheelAccelState, dir: 1 | -1, now: number): number { if (!state.xtermJs) { // Device-switch guard ①: idle disengage. Runs BEFORE pendingFlip resolve // so a pending bounce (28% of last-mouse-events) doesn't bypass it via // the real-reversal early return. state.time is either the last committed // event OR the deferred flip — both count as "last activity". if (state.wheelMode && now - state.time > WHEEL_MODE_IDLE_DISENGAGE_MS) { state.wheelMode = false; state.burstCount = 0; state.mult = state.base; } // Resolve any deferred flip BEFORE touching state.time/dir — we need the // pre-flip state.dir to distinguish bounce (flip-back) from real reversal // (flip persisted), and state.time (= bounce timestamp) for the gap check. if (state.pendingFlip) { state.pendingFlip = false; if (dir !== state.dir || now - state.time > WHEEL_BOUNCE_GAP_MAX_MS) { // Real reversal: new dir persisted, OR flip-back arrived too late. // Commit. The deferred event's 1 row is lost (acceptable latency). state.dir = dir; state.time = now; state.mult = state.base; return Math.floor(state.mult); } // Bounce confirmed: flipped back to original dir within the window. // state.dir/mult unchanged from pre-bounce. state.time was advanced to // the bounce below, so gap here = flip-back interval — reflects the // user's actual click cadence (bounce IS a physical click, just noisy). state.wheelMode = true; } const gap = now - state.time; if (dir !== state.dir && state.dir !== 0) { // Flip. Defer — next event decides bounce vs. real reversal. Advance // time (but NOT dir/mult): if this turns out to be a bounce, the // confirm event's gap will be the flip-back interval, which reflects // the user's actual click rate. The bounce IS a physical wheel click, // just misread by the encoder — it should count toward cadence. state.pendingFlip = true; state.time = now; return 0; } state.dir = dir; state.time = now; // ─── MOUSE (wheel mode, sticky until device-switch signal) ─── if (state.wheelMode) { if (gap < WHEEL_BURST_MS) { // Same-batch burst check (ported from xterm.js): iTerm2 proportional // reporting sends 2+ SGR events for one detent when macOS gives // delta>1. Without this, the 2nd event at gap<1ms has m≈1 → STEP*m=15 // → one gentle click gives 1+15=16 rows. // // Device-switch guard ②: trackpad flick produces 100+ events at <5ms // (measured); mouse produces ≤3. 5+ consecutive → trackpad flick. if (++state.burstCount >= 5) { state.wheelMode = false; state.burstCount = 0; state.mult = state.base; } else { return 1; } } else { state.burstCount = 0; } } // Re-check: may have disengaged above. if (state.wheelMode) { // xterm.js decay curve with STEP×3, higher cap. No idle threshold — // the curve handles it (gap=1000ms → m≈0.01 → mult≈1). No frac — // rounding loss is minor at high mult, and frac persisting across idle // was causing off-by-one on the first click back. const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS); const cap = Math.max(WHEEL_MODE_CAP, state.base * 2); const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m; state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP); return Math.floor(state.mult); } // ─── TRACKPAD / HI-RES (native, non-wheel-mode) ─── // Tight 40ms burst window: sub-40ms events ramp, anything slower resets. // Trackpad flick delivers 200+ events at <20ms gaps → rails to cap 6. // Trackpad slow swipe at 40-400ms gaps → resets every event → 1 row each. if (gap > WHEEL_ACCEL_WINDOW_MS) { state.mult = state.base; } else { const cap = Math.max(WHEEL_ACCEL_MAX, state.base * 2); state.mult = Math.min(cap, state.mult + WHEEL_ACCEL_STEP); } return Math.floor(state.mult); } // ─── VSCODE (xterm.js, browser wheel events) ─── // Browser wheel events — no encoder bounce, no SGR bursts. Decay curve // unchanged from the original tuning. Same formula shape as wheel mode // above (keep in sync) but STEP=5 not 15 — higher event rate here. const gap = now - state.time; const sameDir = dir === state.dir; state.time = now; state.dir = dir; // xterm.js path. Debug log shows two patterns: (a) 20-50ms gaps during // sustained scroll (~30 Hz), (b) <5ms same-batch bursts on flicks. For // (b) give 1 row/event — the burst count IS the acceleration, same as // native. For (a) the decay curve gives 3-5 rows. For sparse events // (100ms+, slow deliberate scroll) the curve gives 1-3. if (sameDir && gap < WHEEL_BURST_MS) return 1; if (!sameDir || gap > WHEEL_DECAY_IDLE_MS) { // Direction reversal or long idle: start at 2 (not 1) so the first // click after a pause moves a visible amount. Without this, idle- // then-resume in the same direction decays to mult≈1 (1 row). state.mult = 2; state.frac = 0; } else { const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS); const cap = gap >= WHEEL_DECAY_GAP_MS ? WHEEL_DECAY_CAP_SLOW : WHEEL_DECAY_CAP_FAST; state.mult = Math.min(cap, 1 + (state.mult - 1) * m + WHEEL_DECAY_STEP * m); } const total = state.mult + state.frac; const rows = Math.floor(total); state.frac = total - rows; return rows; } /** Read CLAUDE_CODE_SCROLL_SPEED, default 1, clamp (0, 20]. * Some terminals pre-multiply wheel events (ghostty discrete=3, iTerm2 * "faster scroll") — base=1 is correct there. Others send 1 event/notch — * set CLAUDE_CODE_SCROLL_SPEED=3 to match vim/nvim/opencode. We can't * detect which kind of terminal we're in, hence the knob. Called lazily * from initAndLogWheelAccel so globalSettings.env has loaded. */ export function readScrollSpeedBase(): number { const raw = process.env.CLAUDE_CODE_SCROLL_SPEED; if (!raw) return 1; const n = parseFloat(raw); return Number.isNaN(n) || n <= 0 ? 1 : Math.min(n, 20); } /** Initial wheel accel state. xtermJs=true selects the decay curve. * base is the native-path baseline rows/event (default 1). */ export function initWheelAccel(xtermJs = false, base = 1): WheelAccelState { return { time: 0, mult: base, dir: 0, xtermJs, frac: 0, base, pendingFlip: false, wheelMode: false, burstCount: 0 }; } // Lazy-init helper. isXtermJs() combines the TERM_PROGRAM env check + async // XTVERSION probe — the probe may not have resolved at render time, so this // is called on the first wheel event (>>50ms after startup) when it's settled. // Logs detected mode once so --debug users can verify SSH detection worked. // The renderer also calls isXtermJsHost() (in render-node-to-output) to // select the drain algorithm — no state to pass through. function initAndLogWheelAccel(): WheelAccelState { const xtermJs = isXtermJs(); const base = readScrollSpeedBase(); logForDebugging(`wheel accel: ${xtermJs ? 'decay (xterm.js)' : 'window (native)'} · base=${base} · TERM_PROGRAM=${process.env.TERM_PROGRAM ?? 'unset'}`); return initWheelAccel(xtermJs, base); } // Drag-to-scroll: when dragging past the viewport edge, scroll by this many // rows every AUTOSCROLL_INTERVAL_MS. Mode 1002 mouse tracking only fires on // cell change, so a timer is needed to continue scrolling while stationary. const AUTOSCROLL_LINES = 2; const AUTOSCROLL_INTERVAL_MS = 50; // Hard cap on consecutive auto-scroll ticks. If the release event is lost // (mouse released outside terminal window — some emulators don't capture the // pointer and drop the release), isDragging stays true and the timer would // run until a scroll boundary. Cap bounds the damage; any new drag motion // event restarts the count via check()→start(). const AUTOSCROLL_MAX_TICKS = 200; // 10s @ 50ms /** * Keyboard scroll navigation for the fullscreen layout's message scroll box. * PgUp/PgDn scroll by half-viewport. Mouse wheel scrolls by a few lines. * Scrolling breaks sticky mode; Ctrl+End re-enables it. Wheeling down at * the bottom also re-enables sticky so new content follows naturally. */ export function ScrollKeybindingHandler({ scrollRef, isActive, onScroll, isModal = false }: Props): React.ReactNode { const selection = useSelection(); const { addNotification } = useNotifications(); // Lazy-inited on first wheel event so the XTVERSION probe (fired at // raw-mode-enable time) has resolved by then — initializing in useRef() // would read getWheelBase() before the probe reply arrives over SSH. const wheelAccel = useRef(null); function showCopiedToast(text: string): void { // getClipboardPath reads env synchronously — predicts what setClipboard // did (native pbcopy / tmux load-buffer / raw OSC 52) so we can tell // the user whether paste will Just Work or needs prefix+]. const path = getClipboardPath(); const n = text.length; let msg: string; switch (path) { case 'native': msg = `copied ${n} chars to clipboard`; break; case 'tmux-buffer': msg = `copied ${n} chars to tmux buffer · paste with prefix + ]`; break; case 'osc52': msg = `sent ${n} chars via OSC 52 · check terminal clipboard settings if paste fails`; break; } addNotification({ key: 'selection-copied', text: msg, color: 'suggestion', priority: 'immediate', timeoutMs: path === 'native' ? 2000 : 4000 }); } function copyAndToast(): void { const text_0 = selection.copySelection(); if (text_0) showCopiedToast(text_0); } // Translate selection to track a keyboard page jump. Selection coords are // screen-buffer-local; a scrollTo that moves content by N rows must also // shift anchor+focus by N so the highlight stays on the same text (native // terminal behavior: selection moves with content, clips at viewport // edges). Rows that scroll out of the viewport are captured into // scrolledOffAbove/Below before the scroll so getSelectedText still // returns the full text. Wheel scroll (scroll:lineUp/Down via scrollBy) // still clears — its async pendingScrollDelta drain means the actual // delta isn't known synchronously (follow-up). function translateSelectionForJump(s: ScrollBoxHandle, delta: number): void { const sel = selection.getState(); if (!sel?.anchor || !sel.focus) return; const top = s.getViewportTop(); const bottom = top + s.getViewportHeight() - 1; // Only translate if the selection is ON scrollbox content. Selections // in the footer/prompt/StickyPromptHeader are on static text — the // scroll doesn't move what's under them. Same guard as ink.tsx's // auto-follow translate (commit 36a8d154). if (sel.anchor.row < top || sel.anchor.row > bottom) return; // Cross-boundary: anchor in scrollbox, focus in footer/header. Mirror // ink.tsx's Flag-3 guard — fall through without shifting OR capturing. // The static endpoint pins the selection; shifting would teleport it // into scrollbox content. if (sel.focus.row < top || sel.focus.row > bottom) return; const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); const cur = s.getScrollTop() + s.getPendingDelta(); // Actual scroll distance after boundary clamp. jumpBy may call // scrollToBottom when target >= max but the view can't move past max, // so the selection shift is bounded here. const actual = Math.max(0, Math.min(max, cur + delta)) - cur; if (actual === 0) return; if (actual > 0) { // Scrolling down: content moves up. Rows at the TOP leave viewport. // Anchor+focus shift -actual so they track the content that moved up. selection.captureScrolledRows(top, top + actual - 1, 'above'); selection.shiftSelection(-actual, top, bottom); } else { // Scrolling up: content moves down. Rows at the BOTTOM leave viewport. const a = -actual; selection.captureScrolledRows(bottom - a + 1, bottom, 'below'); selection.shiftSelection(a, top, bottom); } } useKeybindings({ 'scroll:pageUp': () => { const s_0 = scrollRef.current; if (!s_0) return; const d = -Math.max(1, Math.floor(s_0.getViewportHeight() / 2)); translateSelectionForJump(s_0, d); const sticky = jumpBy(s_0, d); onScroll?.(sticky, s_0); }, 'scroll:pageDown': () => { const s_1 = scrollRef.current; if (!s_1) return; const d_0 = Math.max(1, Math.floor(s_1.getViewportHeight() / 2)); translateSelectionForJump(s_1, d_0); const sticky_0 = jumpBy(s_1, d_0); onScroll?.(sticky_0, s_1); }, 'scroll:lineUp': () => { // Wheel: scrollBy accumulates into pendingScrollDelta, drained async // by the renderer. captureScrolledRows can't read the outgoing rows // before they leave (drain is non-deterministic). Clear for now. selection.clearSelection(); const s_2 = scrollRef.current; // Return false (not consumed) when the ScrollBox content fits — // scroll would be a no-op. Lets a child component's handler take // the wheel event instead (e.g. Settings Config's list navigation // inside the centered Modal, where the paginated slice always fits). if (!s_2 || s_2.getScrollHeight() <= s_2.getViewportHeight()) return false; wheelAccel.current ??= initAndLogWheelAccel(); scrollUp(s_2, computeWheelStep(wheelAccel.current, -1, performance.now())); onScroll?.(false, s_2); }, 'scroll:lineDown': () => { selection.clearSelection(); const s_3 = scrollRef.current; if (!s_3 || s_3.getScrollHeight() <= s_3.getViewportHeight()) return false; wheelAccel.current ??= initAndLogWheelAccel(); const step = computeWheelStep(wheelAccel.current, 1, performance.now()); const reachedBottom = scrollDown(s_3, step); onScroll?.(reachedBottom, s_3); }, 'scroll:top': () => { const s_4 = scrollRef.current; if (!s_4) return; translateSelectionForJump(s_4, -(s_4.getScrollTop() + s_4.getPendingDelta())); s_4.scrollTo(0); onScroll?.(false, s_4); }, 'scroll:bottom': () => { const s_5 = scrollRef.current; if (!s_5) return; const max_0 = Math.max(0, s_5.getScrollHeight() - s_5.getViewportHeight()); translateSelectionForJump(s_5, max_0 - (s_5.getScrollTop() + s_5.getPendingDelta())); // scrollTo(max) eager-writes scrollTop so the render-phase sticky // follow computes followDelta=0. Without this, scrollToBottom() // alone leaves scrollTop stale → followDelta=max-stale → // shiftSelectionForFollow applies the SAME shift we already did // above, 2× offset. scrollToBottom() then re-enables sticky. s_5.scrollTo(max_0); s_5.scrollToBottom(); onScroll?.(true, s_5); }, 'selection:copy': copyAndToast }, { context: 'Scroll', isActive }); // scroll:halfPage*/fullPage* have no default key bindings — ctrl+u/d/b/f // all have real owners in normal mode (kill-line/exit/task:background/ // kill-agents). Transcript mode gets them via the isModal raw useInput // below. These handlers stay for custom rebinds only. useKeybindings({ 'scroll:halfPageUp': () => { const s_6 = scrollRef.current; if (!s_6) return; const d_1 = -Math.max(1, Math.floor(s_6.getViewportHeight() / 2)); translateSelectionForJump(s_6, d_1); const sticky_1 = jumpBy(s_6, d_1); onScroll?.(sticky_1, s_6); }, 'scroll:halfPageDown': () => { const s_7 = scrollRef.current; if (!s_7) return; const d_2 = Math.max(1, Math.floor(s_7.getViewportHeight() / 2)); translateSelectionForJump(s_7, d_2); const sticky_2 = jumpBy(s_7, d_2); onScroll?.(sticky_2, s_7); }, 'scroll:fullPageUp': () => { const s_8 = scrollRef.current; if (!s_8) return; const d_3 = -Math.max(1, s_8.getViewportHeight()); translateSelectionForJump(s_8, d_3); const sticky_3 = jumpBy(s_8, d_3); onScroll?.(sticky_3, s_8); }, 'scroll:fullPageDown': () => { const s_9 = scrollRef.current; if (!s_9) return; const d_4 = Math.max(1, s_9.getViewportHeight()); translateSelectionForJump(s_9, d_4); const sticky_4 = jumpBy(s_9, d_4); onScroll?.(sticky_4, s_9); } }, { context: 'Scroll', isActive }); // Modal pager keys — transcript mode only. less/tmux copy-mode lineage: // ctrl+u/d (half-page), ctrl+b/f (full-page), g/G (top/bottom). Tom's // resolution (2026-03-15): "In ctrl-o mode, ctrl-u, ctrl-d, etc. should // roughly just work!" — transcript is the copy-mode container. // // Safe because the conflicting handlers aren't reachable here: // ctrl+u → kill-line, ctrl+d → exit: PromptInput not mounted // ctrl+b → task:background: SessionBackgroundHint not mounted // ctrl+f → chat:killAgents moved to ctrl+x ctrl+k; no conflict // g/G → printable chars: no prompt to eat them, no vim/sticky gate needed // // TODO(search): `/`, n/N — build on Richard Kim's d94b07add4 (branch // claude/jump-recent-message-CEPcq). getItemY Yoga-walk + computeOrigin + // anchorY already solve scroll-to-index. jumpToPrevTurn is the n/N // template. Single-shot via OVERSCAN_ROWS=80; two-phase was tried and // abandoned (❯ oscillation). See team memory scroll-copy-mode-design.md. useInput((input, key, event) => { const s_10 = scrollRef.current; if (!s_10) return; const sticky_5 = applyModalPagerAction(s_10, modalPagerAction(input, key), d_5 => translateSelectionForJump(s_10, d_5)); if (sticky_5 === null) return; onScroll?.(sticky_5, s_10); event.stopImmediatePropagation(); }, { isActive: isActive && isModal }); // Esc clears selection; any other keystroke also clears it (matches // native terminal behavior where selection disappears on input). // Ctrl+C copies when a selection exists — needed on legacy terminals // where ctrl+shift+c sends the same byte (\x03, shift is lost) and // cmd+c never reaches the pty (terminal intercepts it for Edit > Copy). // Handled via raw useInput so we can conditionally consume: Esc/Ctrl+C // only stop propagation when a selection exists, letting them still work // for cancel-request / interrupt otherwise. Other keys never stop // propagation — they're observed to clear selection as a side-effect. // The selection:copy keybinding (ctrl+shift+c / cmd+c) registers above // via useKeybindings and consumes its event before reaching here. useInput((input_0, key_0, event_0) => { if (!selection.hasSelection()) return; if (key_0.escape) { selection.clearSelection(); event_0.stopImmediatePropagation(); return; } if (key_0.ctrl && !key_0.shift && !key_0.meta && input_0 === 'c') { copyAndToast(); event_0.stopImmediatePropagation(); return; } const move = selectionFocusMoveForKey(key_0); if (move) { selection.moveFocus(move); event_0.stopImmediatePropagation(); return; } if (shouldClearSelectionOnKey(key_0)) { selection.clearSelection(); } }, { isActive }); useDragToScroll(scrollRef, selection, isActive, onScroll); useCopyOnSelect(selection, isActive, showCopiedToast); useSelectionBgColor(selection); return null; } /** * Auto-scroll the ScrollBox when the user drags a selection past its top or * bottom edge. The anchor is shifted in the opposite direction so it stays * on the same content (content that was at viewport row N is now at row N±d * after scrolling by d). Focus stays at the mouse position (edge row). * * Selection coords are screen-buffer-local, so the anchor is clamped to the * viewport bounds once the original content scrolls out. To preserve the full * selection, rows about to scroll out are captured into scrolledOffAbove/ * scrolledOffBelow before each scroll step and joined back in by * getSelectedText. */ function useDragToScroll(scrollRef: RefObject, selection: ReturnType, isActive: boolean, onScroll: Props['onScroll']): void { const timerRef = useRef(null); const dirRef = useRef<-1 | 0 | 1>(0); // -1 scrolling up, +1 down, 0 idle // Survives stop() — reset only on drag-finish. See check() for semantics. const lastScrolledDirRef = useRef<-1 | 0 | 1>(0); const ticksRef = useRef(0); // onScroll may change identity every render (if not memoized by caller). // Read through a ref so the effect doesn't re-subscribe and kill the timer // on each scroll-induced re-render. const onScrollRef = useRef(onScroll); onScrollRef.current = onScroll; useEffect(() => { if (!isActive) return; function stop(): void { dirRef.current = 0; if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } } function tick(): void { const sel = selection.getState(); const s = scrollRef.current; const dir = dirRef.current; // dir === 0 defends against a stale interval (start() may have set one // after the immediate tick already called stop() at a scroll boundary). // ticks cap defends against a lost release event (mouse released // outside terminal window) leaving isDragging stuck true. if (!sel?.isDragging || !sel.focus || !s || dir === 0 || ++ticksRef.current > AUTOSCROLL_MAX_TICKS) { stop(); return; } // scrollBy accumulates into pendingScrollDelta; the screen buffer // doesn't update until the next render drains it. If a previous // tick's scroll hasn't drained yet, captureScrolledRows would read // stale content (same rows as last tick → duplicated in the // accumulator AND missing the rows that actually scrolled out). // Skip this tick; the 50ms interval will retry after Ink's 16ms // render catches up. Also prevents shiftAnchor from desyncing. if (s.getPendingDelta() !== 0) return; const top = s.getViewportTop(); const bottom = top + s.getViewportHeight() - 1; // Clamp anchor within [top, bottom]. Not [0, bottom]: the ScrollBox // padding row at 0 would produce a blank line between scrolledOffAbove // and the on-screen content in getSelectedText. The padding-row // highlight was a minor visual nicety; text correctness wins. if (dir < 0) { if (s.getScrollTop() <= 0) { stop(); return; } // Scrolling up: content moves down in viewport, so anchor row +N. // Clamp to actual scroll distance so anchor stays in sync when near // the top boundary (renderer clamps scrollTop to 0 on drain). const actual = Math.min(AUTOSCROLL_LINES, s.getScrollTop()); // Capture rows about to scroll out the BOTTOM before scrollBy // overwrites them. Only rows inside the selection are captured // (captureScrolledRows intersects with selection bounds). selection.captureScrolledRows(bottom - actual + 1, bottom, 'below'); selection.shiftAnchor(actual, 0, bottom); s.scrollBy(-AUTOSCROLL_LINES); } else { const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); if (s.getScrollTop() >= max) { stop(); return; } // Scrolling down: content moves up in viewport, so anchor row -N. // Clamp to actual scroll distance so anchor stays in sync when near // the bottom boundary (renderer clamps scrollTop to max on drain). const actual_0 = Math.min(AUTOSCROLL_LINES, max - s.getScrollTop()); // Capture rows about to scroll out the TOP. selection.captureScrolledRows(top, top + actual_0 - 1, 'above'); selection.shiftAnchor(-actual_0, top, bottom); s.scrollBy(AUTOSCROLL_LINES); } onScrollRef.current?.(false, s); } function start(dir_0: -1 | 1): void { // Record BEFORE early-return: the empty-accumulator reset in check() // may have zeroed this during the pre-crossing phase (accumulators // empty until the anchor row enters the capture range). Re-record // on every call so the corruption is instantly healed. lastScrolledDirRef.current = dir_0; if (dirRef.current === dir_0) return; // already going this way stop(); dirRef.current = dir_0; ticksRef.current = 0; tick(); // tick() may have hit a scroll boundary and called stop() (dir reset to // 0). Only start the interval if we're still going — otherwise the // interval would run forever with dir === 0 doing nothing useful. if (dirRef.current === dir_0) { timerRef.current = setInterval(tick, AUTOSCROLL_INTERVAL_MS); } } // Re-evaluated on every selection change (start/drag/finish/clear). // Drives drag-to-scroll autoscroll when the drag leaves the viewport. // Prior versions broke sticky here on drag-start to prevent selection // drift during streaming — ink.tsx now translates selection coords by // the follow delta instead (native terminal behavior: view keeps // scrolling, highlight walks up with the text). Keeping sticky also // avoids useVirtualScroll's tail-walk → forward-walk phantom growth. function check(): void { const s_0 = scrollRef.current; if (!s_0) { stop(); return; } const top_0 = s_0.getViewportTop(); const bottom_0 = top_0 + s_0.getViewportHeight() - 1; const sel_0 = selection.getState(); // Pass the LAST-scrolled direction (not dirRef) so the anchor guard is // bypassed after shiftAnchor has clamped anchor toward row 0. Using // lastScrolledDirRef (survives stop()) lets autoscroll resume after a // brief mouse dip into the viewport. Same-direction only — a mouse // jump from below-bottom to above-top must stop, since reversing while // the scrolledOffAbove/Below accumulators hold the prior direction's // rows would duplicate text in getSelectedText. Reset on drag-finish // OR when both accumulators are empty: startSelection clears them // (selection.ts), so a new drag after a lost-release (isDragging // stuck true, the reason AUTOSCROLL_MAX_TICKS exists) still resets. // Safe: start() below re-records lastScrolledDirRef before its // early-return, so a mid-scroll reset here is instantly undone. if (!sel_0?.isDragging || sel_0.scrolledOffAbove.length === 0 && sel_0.scrolledOffBelow.length === 0) { lastScrolledDirRef.current = 0; } const dir_1 = dragScrollDirection(sel_0, top_0, bottom_0, lastScrolledDirRef.current); if (dir_1 === 0) { // Blocked reversal: focus jumped to the opposite edge (off-window // drag return, fast flick). handleSelectionDrag already moved focus // past the anchor, flipping selectionBounds — the accumulator is // now orphaned (holds rows on the wrong side). Clear it so // getSelectedText matches the visible highlight. if (lastScrolledDirRef.current !== 0 && sel_0?.focus) { const want = sel_0.focus.row < top_0 ? -1 : sel_0.focus.row > bottom_0 ? 1 : 0; if (want !== 0 && want !== lastScrolledDirRef.current) { sel_0.scrolledOffAbove = []; sel_0.scrolledOffBelow = []; sel_0.scrolledOffAboveSW = []; sel_0.scrolledOffBelowSW = []; lastScrolledDirRef.current = 0; } } stop(); } else start(dir_1); } const unsubscribe = selection.subscribe(check); return () => { unsubscribe(); stop(); lastScrolledDirRef.current = 0; }; }, [isActive, scrollRef, selection]); } /** * Compute autoscroll direction for a drag selection relative to the ScrollBox * viewport. Returns 0 when not dragging, anchor/focus missing, or the anchor * is outside the viewport — a multi-click or drag that started in the input * area must not commandeer the message scroll (double-click in the input area * while scrolled up previously corrupted the anchor via shiftAnchor and * spuriously scrolled the message history every 50ms until release). * * alreadyScrollingDir bypasses the anchor-in-viewport guard once autoscroll * is active (shiftAnchor legitimately clamps the anchor toward row 0, below * `top`) but only allows SAME-direction continuation. If the focus jumps to * the opposite edge (below→above or above→below — possible with a fast flick * or off-window drag since mode 1002 reports on cell change, not per cell), * returns 0 to stop — reversing without clearing scrolledOffAbove/Below * would duplicate captured rows when they scroll back on-screen. */ export function dragScrollDirection(sel: SelectionState | null, top: number, bottom: number, alreadyScrollingDir: -1 | 0 | 1 = 0): -1 | 0 | 1 { if (!sel?.isDragging || !sel.anchor || !sel.focus) return 0; const row = sel.focus.row; const want: -1 | 0 | 1 = row < top ? -1 : row > bottom ? 1 : 0; if (alreadyScrollingDir !== 0) { // Same-direction only. Focus on the opposite side, or back inside the // viewport, stops the scroll — captured rows stay in scrolledOffAbove/ // Below but never scroll back on-screen, so getSelectedText is correct. return want === alreadyScrollingDir ? want : 0; } // Anchor must be inside the viewport for us to own this drag. If the // user started selecting in the input box or header, autoscrolling the // message history is surprising and corrupts the anchor via shiftAnchor. if (sel.anchor.row < top || sel.anchor.row > bottom) return 0; return want; } // Keyboard page jumps: scrollTo() writes scrollTop directly and clears // pendingScrollDelta — one frame, no drain. scrollBy() accumulates into // pendingScrollDelta which the renderer drains over several frames // (render-node-to-output.ts drainProportional/drainAdaptive) — correct for // wheel smoothness, wrong for PgUp/ctrl+u where the user expects a snap. // Target is relative to scrollTop+pendingDelta so a jump mid-wheel-burst // lands where the wheel was heading. export function jumpBy(s: ScrollBoxHandle, delta: number): boolean { const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); const target = s.getScrollTop() + s.getPendingDelta() + delta; if (target >= max) { // Eager-write scrollTop so follow-scroll sees followDelta=0. Callers // that ran translateSelectionForJump already shifted; scrollToBottom() // alone would double-shift via the render-phase sticky follow. s.scrollTo(max); s.scrollToBottom(); return true; } s.scrollTo(Math.max(0, target)); return false; } // Wheel-down past maxScroll re-enables sticky so wheeling at the bottom // naturally re-pins (matches typical chat-app behavior). Returns the // resulting sticky state so callers can propagate it. function scrollDown(s: ScrollBoxHandle, amount: number): boolean { const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); // Include pendingDelta: scrollBy accumulates into pendingScrollDelta // without updating scrollTop, so getScrollTop() alone is stale within // a batch of wheel events. Without this, wheeling to the bottom never // re-enables sticky scroll. const effectiveTop = s.getScrollTop() + s.getPendingDelta(); if (effectiveTop + amount >= max) { s.scrollToBottom(); return true; } s.scrollBy(amount); return false; } // Wheel-up past scrollTop=0 clamps via scrollTo(0), clearing // pendingScrollDelta so aggressive wheel bursts (e.g. MX Master free-spin) // don't accumulate an unbounded negative delta. Without this clamp, // useVirtualScroll's [effLo, effHi] span grows past what MAX_MOUNTED_ITEMS // can cover and intermediate drain frames render at scrollTops with no // mounted children — blank viewport. export function scrollUp(s: ScrollBoxHandle, amount: number): void { // Include pendingDelta: scrollBy accumulates without updating scrollTop, // so getScrollTop() alone is stale within a batch of wheel events. const effectiveTop = s.getScrollTop() + s.getPendingDelta(); if (effectiveTop - amount <= 0) { s.scrollTo(0); return; } s.scrollBy(-amount); } export type ModalPagerAction = 'lineUp' | 'lineDown' | 'halfPageUp' | 'halfPageDown' | 'fullPageUp' | 'fullPageDown' | 'top' | 'bottom'; /** * Maps a keystroke to a modal pager action. Exported for testing. * Returns null for keys the modal pager doesn't handle (they fall through). * * ctrl+u/d/b/f are the less-lineage bindings. g/G are bare letters (only * safe when no prompt is mounted). G arrives as input='G' shift=false on * legacy terminals, or input='g' shift=true on kitty-protocol terminals. * Lowercase g needs the !shift guard so it doesn't also match kitty-G. * * Key-repeat: stdin coalesces held-down printables into one multi-char * string (e.g. 'ggg'). Only uniform-char batches are handled — mixed input * like 'gG' isn't key-repeat. g/G are idempotent absolute jumps, so the * count is irrelevant (consuming the batch just prevents it from leaking * to the selection-clear-on-printable handler). */ export function modalPagerAction(input: string, key: Pick): ModalPagerAction | null { if (key.meta) return null; // Special keys first — arrows/home/end arrive with empty or junk input, // so these must be checked before any input-string logic. shift is // reserved for selection-extend (selectionFocusMoveForKey); ctrl+home/end // already has a useKeybindings route to scroll:top/bottom. if (!key.ctrl && !key.shift) { if (key.upArrow) return 'lineUp'; if (key.downArrow) return 'lineDown'; if (key.home) return 'top'; if (key.end) return 'bottom'; } if (key.ctrl) { if (key.shift) return null; switch (input) { case 'u': return 'halfPageUp'; case 'd': return 'halfPageDown'; case 'b': return 'fullPageUp'; case 'f': return 'fullPageDown'; // emacs-style line scroll (less accepts both ctrl+n/p and ctrl+e/y). // Works during search nav — fine-adjust after a jump without // leaving modal. No !searchOpen gate on this useInput's isActive. case 'n': return 'lineDown'; case 'p': return 'lineUp'; default: return null; } } // Bare letters. Key-repeat batches: only act on uniform runs. const c = input[0]; if (!c || input !== c.repeat(input.length)) return null; // kitty sends G as input='g' shift=true; legacy as 'G' shift=false. // Check BEFORE the shift-gate so both hit 'bottom'. if (c === 'G' || c === 'g' && key.shift) return 'bottom'; if (key.shift) return null; switch (c) { case 'g': return 'top'; // j/k re-added per Tom Mar 18 — reversal of Mar 16 removal. Works // during search nav (fine-adjust after n/N lands) since isModal is // independent of searchOpen. case 'j': return 'lineDown'; case 'k': return 'lineUp'; // less: space = page down, b = page up. ctrl+b already maps above; // bare b is the less-native version. case ' ': return 'fullPageDown'; case 'b': return 'fullPageUp'; default: return null; } } /** * Applies a modal pager action to a ScrollBox. Returns the resulting sticky * state, or null if the action was null (nothing to do — caller should fall * through). Calls onBeforeJump(delta) before scrolling so the caller can * translate the text selection by the scroll delta (capture outgoing rows, * shift anchor+focus) instead of clearing it. Exported for testing. */ export function applyModalPagerAction(s: ScrollBoxHandle, act: ModalPagerAction | null, onBeforeJump: (delta: number) => void): boolean | null { switch (act) { case null: return null; case 'lineUp': case 'lineDown': { const d = act === 'lineDown' ? 1 : -1; onBeforeJump(d); return jumpBy(s, d); } case 'halfPageUp': case 'halfPageDown': { const half = Math.max(1, Math.floor(s.getViewportHeight() / 2)); const d = act === 'halfPageDown' ? half : -half; onBeforeJump(d); return jumpBy(s, d); } case 'fullPageUp': case 'fullPageDown': { const page = Math.max(1, s.getViewportHeight()); const d = act === 'fullPageDown' ? page : -page; onBeforeJump(d); return jumpBy(s, d); } case 'top': onBeforeJump(-(s.getScrollTop() + s.getPendingDelta())); s.scrollTo(0); return false; case 'bottom': { const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); onBeforeJump(max - (s.getScrollTop() + s.getPendingDelta())); // Eager-write scrollTop before scrollToBottom — same double-shift // fix as scroll:bottom and jumpBy's max branch. s.scrollTo(max); s.scrollToBottom(); return true; } } }