// @ts-nocheck — generated/ported terminal scroll handler; types are intentionally loose
import { useEffect, useRef } from 'react';
import { useNotifications } from '../context/notifications.js';
import { useCopyOnSelect, useSelectionBgColor } from '../hooks/useCopyOnSelect.js';
import { useSelection } from '../ink/hooks/use-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 { useInput } from '../ink.js';
import { useKeybindings } from '../keybindings/useKeybinding.js';
import { logForDebugging } from '../utils/debug.js';
// 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 OWLCODA_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
// (target<mult wins the min).
const WHEEL_MODE_RAMP = 3;
// Device-switch disengage: mouse finger-repositions max at ~830ms (measured);
// trackpad between-gesture pauses are 2000ms+. An idle gap above this means
// the user stopped — might have switched devices. Disengage; the next mouse
// bounce re-engages. Trackpad slow swipe (no <5ms bursts, so the burst-count
// guard doesn't catch it) is what this protects against.
const WHEEL_MODE_IDLE_DISENGAGE_MS = 1500;
// xterm.js: exponential decay. momentum=0.5^(gap/hl) — slow click → m≈0
// → mult→1 (precision); fast → m≈1 → carries momentum. Steady-state
// = 1 + step×m/(1-m), capped. Measured event rates in VS Code (wheel.log):
// sustained scroll sends events at 20-50ms gaps (20-40 Hz), plus 0-2ms
// same-batch bursts on flicks. Cap is low (3–6, gap-dependent) because event
// frequency is high — at 40 Hz × 6 = 240 rows/sec max demand, which the
// adaptive drain at ~200fps (measured) handles. Higher cap → pending explosion.
// Tuned empirically (boris 2026-03). See docs/research/terminal-scroll-*.
const WHEEL_DECAY_HALFLIFE_MS = 150;
const WHEEL_DECAY_STEP = 5;
// Same-batch events (<BURST_MS) arrive in one stdin batch — the terminal
// is doing proportional reporting. Treat as 1 row/event like native.
const WHEEL_BURST_MS = 5;
// Cap boundary: slow events (≥GAP_MS) cap low for short smooth drains;
// fast events cap higher for throughput (adaptive drain handles backlog).
const WHEEL_DECAY_GAP_MS = 80;
const WHEEL_DECAY_CAP_SLOW = 3; // gap ≥ GAP_MS: precision
const WHEEL_DECAY_CAP_FAST = 6; // gap < GAP_MS: throughput
// Idle threshold: gaps beyond this reset to the kick value (2) so the
// first click after a pause feels responsive regardless of direction.
const WHEEL_DECAY_IDLE_MS = 500;
/**
 * Whether a keypress should clear the virtual text selection. Mimics
 * native terminal selection: any keystroke clears, EXCEPT modified nav
 * keys (shift/opt/cmd + arrow/home/end/page*). In native macOS contexts,
 * shift+nav extends selection, and cmd/opt+nav are often intercepted by
 * the terminal emulator for scrollback nav — neither disturbs selection.
 * Bare arrows DO clear (user's cursor moves, native deselects). Wheel is
 * excluded — scroll:lineUp/Down already clears via the keybinding path.
 */
export function shouldClearSelectionOnKey(key) {
    if (key.wheelUp || key.wheelDown)
        return false;
    const isNav = key.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.home || key.end || key.pageUp || key.pageDown;
    if (isNav && (key.shift || key.meta || key.super))
        return false;
    return true;
}
/**
 * Map a keypress to a selection focus move (keyboard extension). Only
 * shift extends — that's the universal text-selection modifier. cmd
 * (super) only arrives via kitty keyboard protocol — in most terminals
 * cmd+arrow is intercepted by the emulator and never reaches the pty, so
 * no super branch. shift+home/end covers line-edge jumps (and fn+shift+
 * left/right on mac laptops = shift+home/end). shift+opt (word-jump) not
 * yet implemented — falls through to shouldClearSelectionOnKey which
 * preserves (modified nav). Returns null for non-extend keys.
 */
export function selectionFocusMoveForKey(key) {
    if (!key.shift || key.meta)
        return null;
    if (key.leftArrow)
        return 'left';
    if (key.rightArrow)
        return 'right';
    if (key.upArrow)
        return 'up';
    if (key.downArrow)
        return 'down';
    if (key.home)
        return 'lineStart';
    if (key.end)
        return 'lineEnd';
    return null;
}
/** 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, dir, now) {
    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 OWLCODA_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 OWLCODA_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() {
    const raw = process.env.OWLCODA_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) {
    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() {
    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 }) {
    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) {
        // 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;
        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() {
        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, delta) {
        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. 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, selection, isActive, onScroll) {
    const timerRef = useRef(null);
    const dirRef = useRef(0); // -1 scrolling up, +1 down, 0 idle
    // Survives stop() — reset only on drag-finish. See check() for semantics.
    const lastScrolledDirRef = useRef(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() {
            dirRef.current = 0;
            if (timerRef.current) {
                clearInterval(timerRef.current);
                timerRef.current = null;
            }
        }
        function tick() {
            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) {
            // 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() {
            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, top, bottom, alreadyScrollingDir = 0) {
    if (!sel?.isDragging || !sel.anchor || !sel.focus)
        return 0;
    const row = sel.focus.row;
    const want = 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, delta) {
    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, amount) {
    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, amount) {
    // 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);
}
/**
 * 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, key) {
    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, act, onBeforeJump) {
    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;
            }
    }
}
//# sourceMappingURL=ScrollKeybindingHandler.js.map