mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
1082 lines
46 KiB
TypeScript
1082 lines
46 KiB
TypeScript
import React, { type RefObject, useEffect, useRef } from 'react'
|
||
import { useNotifications } from '../context/notifications.js'
|
||
import {
|
||
useCopyOnSelect,
|
||
useSelectionBgColor,
|
||
} from '../hooks/useCopyOnSelect.js'
|
||
import type { ScrollBoxHandle, FocusMove, SelectionState } from '@anthropic/ink'
|
||
import { useSelection, type Key, useInput, isXtermJs, getClipboardPath } from '@anthropic/ink'
|
||
import { useKeybindings } from '../keybindings/useKeybinding.js'
|
||
import { logForDebugging } from '../utils/debug.js'
|
||
|
||
type Props = {
|
||
scrollRef: RefObject<ScrollBoxHandle | null>
|
||
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
|
||
// (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: Key): boolean {
|
||
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: Key): FocusMove | null {
|
||
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
|
||
}
|
||
|
||
export type WheelAccelState = {
|
||
time: number
|
||
mult: number
|
||
dir: 0 | 1 | -1
|
||
xtermJs: boolean
|
||
/** Carried fractional scroll (xterm.js only). scrollBy floors, so without
|
||
* this a mult of 1.5 gives 1 row every time. Carrying the remainder gives
|
||
* 1,2,1,2 on average for mult=1.5 — correct throughput over time. */
|
||
frac: number
|
||
/** Native-path baseline rows/event. Reset value on idle/reversal; ramp
|
||
* builds on top. xterm.js path ignores this (own kick=2 tuning). */
|
||
base: number
|
||
/** Deferred direction flip (native only). Might be encoder bounce or a
|
||
* real reversal — resolved by the NEXT event. Real reversal loses 1 row
|
||
* of latency; bounce is swallowed and triggers wheel mode. The flip's
|
||
* direction and timestamp are derivable (it's always -state.dir at
|
||
* state.time) so this is just a marker. */
|
||
pendingFlip: boolean
|
||
/** Set true once a bounce is confirmed (flip-then-flip-back within
|
||
* BOUNCE_GAP_MAX). Sticky — but disengaged on idle gap >1500ms 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<WheelAccelState | null>(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 = selection.copySelection()
|
||
if (text) showCopiedToast(text)
|
||
}
|
||
|
||
// 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 = scrollRef.current
|
||
if (!s) return
|
||
const d = -Math.max(1, Math.floor(s.getViewportHeight() / 2))
|
||
translateSelectionForJump(s, d)
|
||
const sticky = jumpBy(s, d)
|
||
onScroll?.(sticky, s)
|
||
},
|
||
'scroll:pageDown': () => {
|
||
const s = scrollRef.current
|
||
if (!s) return
|
||
const d = Math.max(1, Math.floor(s.getViewportHeight() / 2))
|
||
translateSelectionForJump(s, d)
|
||
const sticky = jumpBy(s, d)
|
||
onScroll?.(sticky, s)
|
||
},
|
||
'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 = 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 || s.getScrollHeight() <= s.getViewportHeight()) return false
|
||
wheelAccel.current ??= initAndLogWheelAccel()
|
||
scrollUp(s, computeWheelStep(wheelAccel.current, -1, performance.now()))
|
||
onScroll?.(false, s)
|
||
},
|
||
'scroll:lineDown': () => {
|
||
selection.clearSelection()
|
||
const s = scrollRef.current
|
||
if (!s || s.getScrollHeight() <= s.getViewportHeight()) return false
|
||
wheelAccel.current ??= initAndLogWheelAccel()
|
||
const step = computeWheelStep(wheelAccel.current, 1, performance.now())
|
||
const reachedBottom = scrollDown(s, step)
|
||
onScroll?.(reachedBottom, s)
|
||
},
|
||
'scroll:top': () => {
|
||
const s = scrollRef.current
|
||
if (!s) return
|
||
translateSelectionForJump(s, -(s.getScrollTop() + s.getPendingDelta()))
|
||
s.scrollTo(0)
|
||
onScroll?.(false, s)
|
||
},
|
||
'scroll:bottom': () => {
|
||
const s = scrollRef.current
|
||
if (!s) return
|
||
const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight())
|
||
translateSelectionForJump(
|
||
s,
|
||
max - (s.getScrollTop() + s.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.scrollTo(max)
|
||
s.scrollToBottom()
|
||
onScroll?.(true, s)
|
||
},
|
||
'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 = scrollRef.current
|
||
if (!s) return
|
||
const d = -Math.max(1, Math.floor(s.getViewportHeight() / 2))
|
||
translateSelectionForJump(s, d)
|
||
const sticky = jumpBy(s, d)
|
||
onScroll?.(sticky, s)
|
||
},
|
||
'scroll:halfPageDown': () => {
|
||
const s = scrollRef.current
|
||
if (!s) return
|
||
const d = Math.max(1, Math.floor(s.getViewportHeight() / 2))
|
||
translateSelectionForJump(s, d)
|
||
const sticky = jumpBy(s, d)
|
||
onScroll?.(sticky, s)
|
||
},
|
||
'scroll:fullPageUp': () => {
|
||
const s = scrollRef.current
|
||
if (!s) return
|
||
const d = -Math.max(1, s.getViewportHeight())
|
||
translateSelectionForJump(s, d)
|
||
const sticky = jumpBy(s, d)
|
||
onScroll?.(sticky, s)
|
||
},
|
||
'scroll:fullPageDown': () => {
|
||
const s = scrollRef.current
|
||
if (!s) return
|
||
const d = Math.max(1, s.getViewportHeight())
|
||
translateSelectionForJump(s, d)
|
||
const sticky = jumpBy(s, d)
|
||
onScroll?.(sticky, s)
|
||
},
|
||
},
|
||
{ 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 = scrollRef.current
|
||
if (!s) return
|
||
const sticky = applyModalPagerAction(s, modalPagerAction(input, key), d =>
|
||
translateSelectionForJump(s, d),
|
||
)
|
||
if (sticky === null) return
|
||
onScroll?.(sticky, s)
|
||
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, key, event) => {
|
||
if (!selection.hasSelection()) return
|
||
if (key.escape) {
|
||
selection.clearSelection()
|
||
event.stopImmediatePropagation()
|
||
return
|
||
}
|
||
if (key.ctrl && !key.shift && !key.meta && input === 'c') {
|
||
copyAndToast()
|
||
event.stopImmediatePropagation()
|
||
return
|
||
}
|
||
const move = selectionFocusMoveForKey(key)
|
||
if (move) {
|
||
selection.moveFocus(move)
|
||
event.stopImmediatePropagation()
|
||
return
|
||
}
|
||
if (shouldClearSelectionOnKey(key)) {
|
||
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<ScrollBoxHandle | null>,
|
||
selection: ReturnType<typeof useSelection>,
|
||
isActive: boolean,
|
||
onScroll: Props['onScroll'],
|
||
): void {
|
||
const timerRef = useRef<NodeJS.Timeout | null>(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 = Math.min(AUTOSCROLL_LINES, max - s.getScrollTop())
|
||
// Capture rows about to scroll out the TOP.
|
||
selection.captureScrolledRows(top, top + actual - 1, 'above')
|
||
selection.shiftAnchor(-actual, top, bottom)
|
||
s.scrollBy(AUTOSCROLL_LINES)
|
||
}
|
||
onScrollRef.current?.(false, s)
|
||
}
|
||
|
||
function start(dir: -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
|
||
if (dirRef.current === dir) return // already going this way
|
||
stop()
|
||
dirRef.current = dir
|
||
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) {
|
||
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 = scrollRef.current
|
||
if (!s) {
|
||
stop()
|
||
return
|
||
}
|
||
const top = s.getViewportTop()
|
||
const bottom = top + s.getViewportHeight() - 1
|
||
const sel = 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?.isDragging ||
|
||
(sel.scrolledOffAbove.length === 0 && sel.scrolledOffBelow.length === 0)
|
||
) {
|
||
lastScrolledDirRef.current = 0
|
||
}
|
||
const dir = dragScrollDirection(
|
||
sel,
|
||
top,
|
||
bottom,
|
||
lastScrolledDirRef.current,
|
||
)
|
||
if (dir === 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?.focus) {
|
||
const want = sel.focus.row < top ? -1 : sel.focus.row > bottom ? 1 : 0
|
||
if (want !== 0 && want !== lastScrolledDirRef.current) {
|
||
sel.scrolledOffAbove = []
|
||
sel.scrolledOffBelow = []
|
||
sel.scrolledOffAboveSW = []
|
||
sel.scrolledOffBelowSW = []
|
||
lastScrolledDirRef.current = 0
|
||
}
|
||
}
|
||
stop()
|
||
} else start(dir)
|
||
}
|
||
|
||
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<
|
||
Key,
|
||
'ctrl' | 'meta' | 'shift' | 'upArrow' | 'downArrow' | 'home' | 'end'
|
||
>,
|
||
): 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
|
||
}
|
||
}
|
||
}
|