/ ink / selection.ts
selection.ts
  1  /**
  2   * Text selection state for fullscreen mode.
  3   *
  4   * Tracks a linear selection in screen-buffer coordinates (0-indexed col/row).
  5   * Selection is line-based: cells from (startCol, startRow) through
  6   * (endCol, endRow) inclusive, wrapping across line boundaries. This matches
  7   * terminal-native selection behavior (not rectangular/block).
  8   *
  9   * The selection is stored as ANCHOR (where the drag started) + FOCUS (where
 10   * the cursor is now). The rendered highlight normalizes to start ≤ end.
 11   */
 12  
 13  import { clamp } from './layout/geometry.js'
 14  import type { Screen, StylePool } from './screen.js'
 15  import { CellWidth, cellAt, cellAtIndex, setCellStyleId } from './screen.js'
 16  
 17  type Point = { col: number; row: number }
 18  
 19  export type SelectionState = {
 20    /** Where the mouse-down occurred. Null when no selection. */
 21    anchor: Point | null
 22    /** Current drag position (updated on mouse-move while dragging). */
 23    focus: Point | null
 24    /** True between mouse-down and mouse-up. */
 25    isDragging: boolean
 26    /** For word/line mode: the initial word/line bounds from the first
 27     *  multi-click. Drag extends from this span to the word/line at the
 28     *  current mouse position so the original word/line stays selected
 29     *  even when dragging backward past it. Null ⇔ char mode. The kind
 30     *  tells extendSelection whether to snap to word or line boundaries. */
 31    anchorSpan: { lo: Point; hi: Point; kind: 'word' | 'line' } | null
 32    /** Text from rows that scrolled out ABOVE the viewport during
 33     *  drag-to-scroll. The screen buffer only holds the current viewport,
 34     *  so without this accumulator, dragging down past the bottom edge
 35     *  loses the top of the selection once the anchor clamps. Prepended
 36     *  to the on-screen text by getSelectedText. Reset on start/clear. */
 37    scrolledOffAbove: string[]
 38    /** Symmetric: rows scrolled out BELOW when dragging up. Appended. */
 39    scrolledOffBelow: string[]
 40    /** Soft-wrap bits parallel to scrolledOffAbove — true means the row
 41     *  is a continuation of the one before it (the `\n` was inserted by
 42     *  word-wrap, not in the source). Captured alongside the text at
 43     *  scroll time since the screen's softWrap bitmap shifts with content.
 44     *  getSelectedText uses these to join wrapped rows back into logical
 45     *  lines. */
 46    scrolledOffAboveSW: boolean[]
 47    /** Parallel to scrolledOffBelow. */
 48    scrolledOffBelowSW: boolean[]
 49    /** Pre-clamp anchor row. Set when shiftSelection clamps anchor so a
 50     *  reverse scroll can restore the true position and pop accumulators.
 51     *  Without this, PgDn (clamps anchor) → PgUp leaves anchor at the wrong
 52     *  row AND scrolledOffAbove stale — highlight ≠ copy. Undefined when
 53     *  anchor is in-bounds (no clamp debt). Cleared on start/clear. */
 54    virtualAnchorRow?: number
 55    /** Same for focus. */
 56    virtualFocusRow?: number
 57    /** True if the mouse-down that started this selection had the alt
 58     *  modifier set (SGR button bit 0x08). On macOS xterm.js this is a
 59     *  signal that VS Code's macOptionClickForcesSelection is OFF — if it
 60     *  were on, xterm.js would have consumed the event for native selection
 61     *  and we'd never receive it. Used by the footer to show the right hint. */
 62    lastPressHadAlt: boolean
 63  }
 64  
 65  export function createSelectionState(): SelectionState {
 66    return {
 67      anchor: null,
 68      focus: null,
 69      isDragging: false,
 70      anchorSpan: null,
 71      scrolledOffAbove: [],
 72      scrolledOffBelow: [],
 73      scrolledOffAboveSW: [],
 74      scrolledOffBelowSW: [],
 75      lastPressHadAlt: false,
 76    }
 77  }
 78  
 79  export function startSelection(
 80    s: SelectionState,
 81    col: number,
 82    row: number,
 83  ): void {
 84    s.anchor = { col, row }
 85    // Focus is not set until the first drag motion. A click-release with no
 86    // drag leaves focus null → hasSelection/selectionBounds return false/null
 87    // via the `!s.focus` check, so a bare click never highlights a cell.
 88    s.focus = null
 89    s.isDragging = true
 90    s.anchorSpan = null
 91    s.scrolledOffAbove = []
 92    s.scrolledOffBelow = []
 93    s.scrolledOffAboveSW = []
 94    s.scrolledOffBelowSW = []
 95    s.virtualAnchorRow = undefined
 96    s.virtualFocusRow = undefined
 97    s.lastPressHadAlt = false
 98  }
 99  
100  export function updateSelection(
101    s: SelectionState,
102    col: number,
103    row: number,
104  ): void {
105    if (!s.isDragging) return
106    // First motion at the same cell as anchor is a no-op. Terminals in mode
107    // 1002 can fire a drag event at the anchor cell (sub-pixel tremor, or a
108    // motion-release pair). Setting focus here would turn a bare click into
109    // a 1-cell selection and clobber the clipboard via useCopyOnSelect. Once
110    // focus is set (real drag), we track normally including back to anchor.
111    if (!s.focus && s.anchor && s.anchor.col === col && s.anchor.row === row)
112      return
113    s.focus = { col, row }
114  }
115  
116  export function finishSelection(s: SelectionState): void {
117    s.isDragging = false
118    // Keep anchor/focus so highlight stays visible and text can be copied.
119    // Clear via clearSelection() on Esc or after copy.
120  }
121  
122  export function clearSelection(s: SelectionState): void {
123    s.anchor = null
124    s.focus = null
125    s.isDragging = false
126    s.anchorSpan = null
127    s.scrolledOffAbove = []
128    s.scrolledOffBelow = []
129    s.scrolledOffAboveSW = []
130    s.scrolledOffBelowSW = []
131    s.virtualAnchorRow = undefined
132    s.virtualFocusRow = undefined
133    s.lastPressHadAlt = false
134  }
135  
136  // Unicode-aware word character matcher: letters (any script), digits,
137  // and the punctuation set iTerm2 treats as word-part by default.
138  // Matching iTerm2's default means double-clicking a path like
139  // `/usr/bin/bash` or `~/.claude/config.json` selects the whole thing,
140  // which is the muscle memory most macOS terminal users have.
141  // iTerm2 default "characters considered part of a word": /-+\~_.
142  const WORD_CHAR = /[\p{L}\p{N}_/.\-+~\\]/u
143  
144  /**
145   * Character class for double-click word-expansion. Cells with the same
146   * class as the clicked cell are included in the selection; a class change
147   * is a boundary. Matches typical terminal-emulator behavior (iTerm2 etc.):
148   * double-click on `foo` selects `foo`, on `->` selects `->`, on spaces
149   * selects the whitespace run.
150   */
151  function charClass(c: string): 0 | 1 | 2 {
152    if (c === ' ' || c === '') return 0
153    if (WORD_CHAR.test(c)) return 1
154    return 2
155  }
156  
157  /**
158   * Find the bounds of the same-class character run at (col, row). Returns
159   * null if the click is out of bounds or lands on a noSelect cell. Used by
160   * selectWordAt (initial double-click) and extendWordSelection (drag).
161   */
162  function wordBoundsAt(
163    screen: Screen,
164    col: number,
165    row: number,
166  ): { lo: number; hi: number } | null {
167    if (row < 0 || row >= screen.height) return null
168    const width = screen.width
169    const noSelect = screen.noSelect
170    const rowOff = row * width
171  
172    // If the click landed on the spacer tail of a wide char, step back to
173    // the head so the class check sees the actual grapheme.
174    let c = col
175    if (c > 0) {
176      const cell = cellAt(screen, c, row)
177      if (cell && cell.width === CellWidth.SpacerTail) c -= 1
178    }
179    if (c < 0 || c >= width || noSelect[rowOff + c] === 1) return null
180  
181    const startCell = cellAt(screen, c, row)
182    if (!startCell) return null
183    const cls = charClass(startCell.char)
184  
185    // Expand left: include cells of the same class, stop at noSelect or
186    // class change. SpacerTail cells are stepped over (the wide-char head
187    // at the preceding column determines the class).
188    let lo = c
189    while (lo > 0) {
190      const prev = lo - 1
191      if (noSelect[rowOff + prev] === 1) break
192      const pc = cellAt(screen, prev, row)
193      if (!pc) break
194      if (pc.width === CellWidth.SpacerTail) {
195        // Step over the spacer to the wide-char head
196        if (prev === 0 || noSelect[rowOff + prev - 1] === 1) break
197        const head = cellAt(screen, prev - 1, row)
198        if (!head || charClass(head.char) !== cls) break
199        lo = prev - 1
200        continue
201      }
202      if (charClass(pc.char) !== cls) break
203      lo = prev
204    }
205  
206    // Expand right: same logic, skipping spacer tails.
207    let hi = c
208    while (hi < width - 1) {
209      const next = hi + 1
210      if (noSelect[rowOff + next] === 1) break
211      const nc = cellAt(screen, next, row)
212      if (!nc) break
213      if (nc.width === CellWidth.SpacerTail) {
214        // Include the spacer tail in the selection range (it belongs to
215        // the wide char at hi) and continue past it.
216        hi = next
217        continue
218      }
219      if (charClass(nc.char) !== cls) break
220      hi = next
221    }
222  
223    return { lo, hi }
224  }
225  
226  /** -1 if a < b, 1 if a > b, 0 if equal (reading order: row then col). */
227  function comparePoints(a: Point, b: Point): number {
228    if (a.row !== b.row) return a.row < b.row ? -1 : 1
229    if (a.col !== b.col) return a.col < b.col ? -1 : 1
230    return 0
231  }
232  
233  /**
234   * Select the word at (col, row) by scanning the screen buffer for the
235   * bounds of the same-class character run. Mutates the selection in place.
236   * No-op if the click is out of bounds or lands on a noSelect cell.
237   * Sets isDragging=true and anchorSpan so a subsequent drag extends the
238   * selection word-by-word (native macOS behavior).
239   */
240  export function selectWordAt(
241    s: SelectionState,
242    screen: Screen,
243    col: number,
244    row: number,
245  ): void {
246    const b = wordBoundsAt(screen, col, row)
247    if (!b) return
248    const lo = { col: b.lo, row }
249    const hi = { col: b.hi, row }
250    s.anchor = lo
251    s.focus = hi
252    s.isDragging = true
253    s.anchorSpan = { lo, hi, kind: 'word' }
254  }
255  
256  // Printable ASCII minus terminal URL delimiters. Restricting to single-
257  // codeunit ASCII keeps cell-count === string-index, so the column-span
258  // check below is exact (no wide-char/grapheme drift).
259  const URL_BOUNDARY = new Set([...'<>"\'` '])
260  function isUrlChar(c: string): boolean {
261    if (c.length !== 1) return false
262    const code = c.charCodeAt(0)
263    return code >= 0x21 && code <= 0x7e && !URL_BOUNDARY.has(c)
264  }
265  
266  /**
267   * Scan the screen buffer for a plain-text URL at (col, row). Mirrors the
268   * terminal's native Cmd+Click URL detection, which fullscreen mode's mouse
269   * tracking intercepts. Called from getHyperlinkAt as a fallback when the
270   * cell has no OSC 8 hyperlink.
271   */
272  export function findPlainTextUrlAt(
273    screen: Screen,
274    col: number,
275    row: number,
276  ): string | undefined {
277    if (row < 0 || row >= screen.height) return undefined
278    const width = screen.width
279    const noSelect = screen.noSelect
280    const rowOff = row * width
281  
282    let c = col
283    if (c > 0) {
284      const cell = cellAt(screen, c, row)
285      if (cell && cell.width === CellWidth.SpacerTail) c -= 1
286    }
287    if (c < 0 || c >= width || noSelect[rowOff + c] === 1) return undefined
288  
289    const startCell = cellAt(screen, c, row)
290    if (!startCell || !isUrlChar(startCell.char)) return undefined
291  
292    // Expand left/right to the bounds of the URL-char run. URLs are ASCII
293    // (CellWidth.Narrow, 1 codeunit), so hitting a non-ASCII/wide/spacer
294    // cell is a boundary — no need to step over spacers like wordBoundsAt.
295    let lo = c
296    while (lo > 0) {
297      const prev = lo - 1
298      if (noSelect[rowOff + prev] === 1) break
299      const pc = cellAt(screen, prev, row)
300      if (!pc || pc.width !== CellWidth.Narrow || !isUrlChar(pc.char)) break
301      lo = prev
302    }
303    let hi = c
304    while (hi < width - 1) {
305      const next = hi + 1
306      if (noSelect[rowOff + next] === 1) break
307      const nc = cellAt(screen, next, row)
308      if (!nc || nc.width !== CellWidth.Narrow || !isUrlChar(nc.char)) break
309      hi = next
310    }
311  
312    let token = ''
313    for (let i = lo; i <= hi; i++) token += cellAt(screen, i, row)!.char
314  
315    // 1 cell = 1 char across [lo, hi] (ASCII-only run), so string index =
316    // column offset. Find the last scheme anchor at or before the click —
317    // a run like `https://a.com,https://b.com` has two, and clicking the
318    // second should return the second URL, not the greedy match of both.
319    const clickIdx = c - lo
320    const schemeRe = /(?:https?|file):\/\//g
321    let urlStart = -1
322    let urlEnd = token.length
323    for (let m; (m = schemeRe.exec(token)); ) {
324      if (m.index > clickIdx) {
325        urlEnd = m.index
326        break
327      }
328      urlStart = m.index
329    }
330    if (urlStart < 0) return undefined
331    let url = token.slice(urlStart, urlEnd)
332  
333    // Strip trailing sentence punctuation. For closers () ] }, only strip
334    // if unbalanced — `/wiki/Foo_(bar)` keeps `)`, `/arr[0]` keeps `]`.
335    const OPENER: Record<string, string> = { ')': '(', ']': '[', '}': '{' }
336    while (url.length > 0) {
337      const last = url.at(-1)!
338      if ('.,;:!?'.includes(last)) {
339        url = url.slice(0, -1)
340        continue
341      }
342      const opener = OPENER[last]
343      if (!opener) break
344      let opens = 0
345      let closes = 0
346      for (let i = 0; i < url.length; i++) {
347        const ch = url.charAt(i)
348        if (ch === opener) opens++
349        else if (ch === last) closes++
350      }
351      if (closes > opens) url = url.slice(0, -1)
352      else break
353    }
354  
355    // urlStart already guarantees click >= URL start; check right edge.
356    if (clickIdx >= urlStart + url.length) return undefined
357  
358    return url
359  }
360  
361  /**
362   * Select the entire row. Sets isDragging=true and anchorSpan so a
363   * subsequent drag extends the selection line-by-line. The anchor/focus
364   * span from col 0 to width-1; getSelectedText handles noSelect skipping
365   * and trailing-whitespace trimming so the copied text is just the visible
366   * line content.
367   */
368  export function selectLineAt(
369    s: SelectionState,
370    screen: Screen,
371    row: number,
372  ): void {
373    if (row < 0 || row >= screen.height) return
374    const lo = { col: 0, row }
375    const hi = { col: screen.width - 1, row }
376    s.anchor = lo
377    s.focus = hi
378    s.isDragging = true
379    s.anchorSpan = { lo, hi, kind: 'line' }
380  }
381  
382  /**
383   * Extend a word/line-mode selection to the word/line at (col, row). The
384   * anchor span (the original multi-clicked word/line) stays selected; the
385   * selection grows from that span to the word/line at the current mouse
386   * position. Word mode falls back to the raw cell when the mouse is over a
387   * noSelect cell or out of bounds, so dragging into gutters still extends.
388   */
389  export function extendSelection(
390    s: SelectionState,
391    screen: Screen,
392    col: number,
393    row: number,
394  ): void {
395    if (!s.isDragging || !s.anchorSpan) return
396    const span = s.anchorSpan
397    let mLo: Point
398    let mHi: Point
399    if (span.kind === 'word') {
400      const b = wordBoundsAt(screen, col, row)
401      mLo = { col: b ? b.lo : col, row }
402      mHi = { col: b ? b.hi : col, row }
403    } else {
404      const r = clamp(row, 0, screen.height - 1)
405      mLo = { col: 0, row: r }
406      mHi = { col: screen.width - 1, row: r }
407    }
408    if (comparePoints(mHi, span.lo) < 0) {
409      // Mouse target ends before anchor span: extend backward.
410      s.anchor = span.hi
411      s.focus = mLo
412    } else if (comparePoints(mLo, span.hi) > 0) {
413      // Mouse target starts after anchor span: extend forward.
414      s.anchor = span.lo
415      s.focus = mHi
416    } else {
417      // Mouse overlaps the anchor span: just select the anchor span.
418      s.anchor = span.lo
419      s.focus = span.hi
420    }
421  }
422  
423  /** Semantic keyboard focus moves. See moveSelectionFocus in ink.tsx for
424   *  how screen bounds + row-wrap are applied. */
425  export type FocusMove =
426    | 'left'
427    | 'right'
428    | 'up'
429    | 'down'
430    | 'lineStart'
431    | 'lineEnd'
432  
433  /**
434   * Set focus to (col, row) for keyboard selection extension (shift+arrow).
435   * Anchor stays fixed; selection grows or shrinks depending on where focus
436   * moves relative to anchor. Drops to char mode (clears anchorSpan) —
437   * native macOS does this too: shift+arrow after a double-click word-select
438   * extends char-by-char from the word edge, not word-by-word. Scrolled-off
439   * accumulators are preserved: keyboard-extending a drag-scrolled selection
440   * keeps the off-screen rows. Caller supplies coords already clamped/wrapped.
441   */
442  export function moveFocus(s: SelectionState, col: number, row: number): void {
443    if (!s.focus) return
444    s.anchorSpan = null
445    s.focus = { col, row }
446    // Explicit user repositioning — any stale virtual focus (from a prior
447    // shiftSelection clamp) no longer reflects intent. Anchor stays put so
448    // virtualAnchorRow is still valid for its own round-trip.
449    s.virtualFocusRow = undefined
450  }
451  
452  /**
453   * Shift anchor AND focus by dRow, clamped to [minRow, maxRow]. Used for
454   * keyboard scroll (PgUp/PgDn/ctrl+u/d/b/f): the whole selection must track
455   * the content, unlike drag-to-scroll where focus stays at the mouse. Any
456   * point that hits a clamp bound gets its col reset to the full-width edge —
457   * its original content scrolled off-screen and was captured by
458   * captureScrolledRows, so the col constraint was already consumed. Keeping
459   * it would truncate the NEW content now at that screen row. Clamp col is 0
460   * for dRow<0 (scrolling down, top leaves, 'above' semantics) or width-1 for
461   * dRow>0 (scrolling up, bottom leaves, 'below' semantics).
462   *
463   * If both ends overshoot the SAME viewport edge (select text → Home/End/g/G
464   * jumps far enough that both are out of view), clear — otherwise both clamp
465   * to the same corner cell and a ghost 1-cell highlight lingers, and
466   * getSelectedText returns one unrelated char from that corner. Symmetric
467   * with shiftSelectionForFollow's top-edge check, but bidirectional: keyboard
468   * scroll can jump either way.
469   */
470  export function shiftSelection(
471    s: SelectionState,
472    dRow: number,
473    minRow: number,
474    maxRow: number,
475    width: number,
476  ): void {
477    if (!s.anchor || !s.focus) return
478    // Virtual rows track pre-clamp positions so reverse scrolls restore
479    // correctly. Without this, clamp(5→0) + shift(+10) = 10, not the true 5,
480    // and scrolledOffAbove stays stale (highlight ≠ copy).
481    const vAnchor = (s.virtualAnchorRow ?? s.anchor.row) + dRow
482    const vFocus = (s.virtualFocusRow ?? s.focus.row) + dRow
483    if (
484      (vAnchor < minRow && vFocus < minRow) ||
485      (vAnchor > maxRow && vFocus > maxRow)
486    ) {
487      clearSelection(s)
488      return
489    }
490    // Debt = how far the nearer endpoint overshoots each edge. When debt
491    // shrinks (reverse scroll), those rows are back on-screen — pop from
492    // the accumulator so getSelectedText doesn't double-count them.
493    const oldMin = Math.min(
494      s.virtualAnchorRow ?? s.anchor.row,
495      s.virtualFocusRow ?? s.focus.row,
496    )
497    const oldMax = Math.max(
498      s.virtualAnchorRow ?? s.anchor.row,
499      s.virtualFocusRow ?? s.focus.row,
500    )
501    const oldAboveDebt = Math.max(0, minRow - oldMin)
502    const oldBelowDebt = Math.max(0, oldMax - maxRow)
503    const newAboveDebt = Math.max(0, minRow - Math.min(vAnchor, vFocus))
504    const newBelowDebt = Math.max(0, Math.max(vAnchor, vFocus) - maxRow)
505    if (newAboveDebt < oldAboveDebt) {
506      // scrolledOffAbove pushes newest at the end (closest to on-screen).
507      const drop = oldAboveDebt - newAboveDebt
508      s.scrolledOffAbove.length -= drop
509      s.scrolledOffAboveSW.length = s.scrolledOffAbove.length
510    }
511    if (newBelowDebt < oldBelowDebt) {
512      // scrolledOffBelow unshifts newest at the front (closest to on-screen).
513      const drop = oldBelowDebt - newBelowDebt
514      s.scrolledOffBelow.splice(0, drop)
515      s.scrolledOffBelowSW.splice(0, drop)
516    }
517    // Invariant: accumulator length ≤ debt. If the accumulator exceeds debt,
518    // the excess is stale — e.g., moveFocus cleared virtualFocusRow without
519    // trimming the accumulator, orphaning entries the pop above can never
520    // reach because oldDebt was ALREADY 0. Truncate to debt (keeping the
521    // newest = closest-to-on-screen entries). Check newDebt (not oldDebt):
522    // captureScrolledRows runs BEFORE this shift in the real flow (ink.tsx),
523    // so at entry the accumulator is populated but oldDebt is still 0 —
524    // that's the normal establish-debt path, not stale.
525    if (s.scrolledOffAbove.length > newAboveDebt) {
526      // Above pushes newest at END → keep END.
527      s.scrolledOffAbove =
528        newAboveDebt > 0 ? s.scrolledOffAbove.slice(-newAboveDebt) : []
529      s.scrolledOffAboveSW =
530        newAboveDebt > 0 ? s.scrolledOffAboveSW.slice(-newAboveDebt) : []
531    }
532    if (s.scrolledOffBelow.length > newBelowDebt) {
533      // Below unshifts newest at FRONT → keep FRONT.
534      s.scrolledOffBelow = s.scrolledOffBelow.slice(0, newBelowDebt)
535      s.scrolledOffBelowSW = s.scrolledOffBelowSW.slice(0, newBelowDebt)
536    }
537    // Clamp col depends on which EDGE (not dRow direction): virtual tracking
538    // means a top-clamped point can stay top-clamped during a dRow>0 reverse
539    // shift — dRow-based clampCol would give it the bottom col.
540    const shift = (p: Point, vRow: number): Point => {
541      if (vRow < minRow) return { col: 0, row: minRow }
542      if (vRow > maxRow) return { col: width - 1, row: maxRow }
543      return { col: p.col, row: vRow }
544    }
545    s.anchor = shift(s.anchor, vAnchor)
546    s.focus = shift(s.focus, vFocus)
547    s.virtualAnchorRow =
548      vAnchor < minRow || vAnchor > maxRow ? vAnchor : undefined
549    s.virtualFocusRow = vFocus < minRow || vFocus > maxRow ? vFocus : undefined
550    // anchorSpan not virtual-tracked: it's for word/line extend-on-drag,
551    // irrelevant to the keyboard-scroll round-trip case.
552    if (s.anchorSpan) {
553      const sp = (p: Point): Point => {
554        const r = p.row + dRow
555        if (r < minRow) return { col: 0, row: minRow }
556        if (r > maxRow) return { col: width - 1, row: maxRow }
557        return { col: p.col, row: r }
558      }
559      s.anchorSpan = {
560        lo: sp(s.anchorSpan.lo),
561        hi: sp(s.anchorSpan.hi),
562        kind: s.anchorSpan.kind,
563      }
564    }
565  }
566  
567  /**
568   * Shift the anchor row by dRow, clamped to [minRow, maxRow]. Used during
569   * drag-to-scroll: when the ScrollBox scrolls by N rows, the content that
570   * was under the anchor is now at a different viewport row, so the anchor
571   * must follow it. Focus is left unchanged (it stays at the mouse position).
572   */
573  export function shiftAnchor(
574    s: SelectionState,
575    dRow: number,
576    minRow: number,
577    maxRow: number,
578  ): void {
579    if (!s.anchor) return
580    // Same virtual-row tracking as shiftSelection/shiftSelectionForFollow: the
581    // drag→follow transition hands off to shiftSelectionForFollow, which reads
582    // (virtualAnchorRow ?? anchor.row). Without this, drag-phase clamping
583    // leaves virtual undefined → follow initializes from the already-clamped
584    // row, under-counting total drift → shiftSelection's invariant-restore
585    // prematurely clears valid drag-phase accumulator entries.
586    const raw = (s.virtualAnchorRow ?? s.anchor.row) + dRow
587    s.anchor = { col: s.anchor.col, row: clamp(raw, minRow, maxRow) }
588    s.virtualAnchorRow = raw < minRow || raw > maxRow ? raw : undefined
589    // anchorSpan not virtual-tracked (word/line extend, irrelevant to
590    // keyboard-scroll round-trip) — plain clamp from current row.
591    if (s.anchorSpan) {
592      const shift = (p: Point): Point => ({
593        col: p.col,
594        row: clamp(p.row + dRow, minRow, maxRow),
595      })
596      s.anchorSpan = {
597        lo: shift(s.anchorSpan.lo),
598        hi: shift(s.anchorSpan.hi),
599        kind: s.anchorSpan.kind,
600      }
601    }
602  }
603  
604  /**
605   * Shift the whole selection (anchor + focus + anchorSpan) by dRow, clamped
606   * to [minRow, maxRow]. Used when sticky/auto-follow scrolls the ScrollBox
607   * while a selection is active — native terminal behavior is for the
608   * highlight to walk up the screen with the text (not stay at the same
609   * screen position).
610   *
611   * Differs from shiftAnchor: during drag-to-scroll, focus tracks the live
612   * mouse position and only anchor follows the text. During streaming-follow,
613   * the selection is text-anchored at both ends — both must move. The
614   * isDragging check in ink.tsx picks which shift to apply.
615   *
616   * If both ends would shift strictly BELOW minRow (unclamped), the selected
617   * text has scrolled entirely off the top. Clear it — otherwise a single
618   * inverted cell lingers at the viewport top as a ghost (native terminals
619   * drop the selection when it leaves scrollback). Landing AT minRow is
620   * still valid: that cell holds the correct text. Returns true if the
621   * selection was cleared so the caller can notify React-land subscribers
622   * (useHasSelection) — the caller is inside onRender so it can't use
623   * notifySelectionChange (recursion), must fire listeners directly.
624   */
625  export function shiftSelectionForFollow(
626    s: SelectionState,
627    dRow: number,
628    minRow: number,
629    maxRow: number,
630  ): boolean {
631    if (!s.anchor) return false
632    // Mirror shiftSelection: compute raw (unclamped) positions from virtual
633    // if set, else current. This handles BOTH the update path (virtual already
634    // set from a prior keyboard scroll) AND the initialize path (first clamp
635    // happens HERE via follow-scroll, no prior keyboard scroll). Without the
636    // initialize path, follow-scroll-first leaves virtual undefined even
637    // though the clamp below occurred → a later PgUp computes debt from the
638    // clamped row instead of the true pre-clamp row and never pops the
639    // accumulator — getSelectedText double-counts the off-screen rows.
640    const rawAnchor = (s.virtualAnchorRow ?? s.anchor.row) + dRow
641    const rawFocus = s.focus
642      ? (s.virtualFocusRow ?? s.focus.row) + dRow
643      : undefined
644    if (rawAnchor < minRow && rawFocus !== undefined && rawFocus < minRow) {
645      clearSelection(s)
646      return true
647    }
648    // Clamp from raw, not p.row+dRow — so a virtual position coming back
649    // in-bounds lands at the TRUE position, not the stale clamped one.
650    s.anchor = { col: s.anchor.col, row: clamp(rawAnchor, minRow, maxRow) }
651    if (s.focus && rawFocus !== undefined) {
652      s.focus = { col: s.focus.col, row: clamp(rawFocus, minRow, maxRow) }
653    }
654    s.virtualAnchorRow =
655      rawAnchor < minRow || rawAnchor > maxRow ? rawAnchor : undefined
656    s.virtualFocusRow =
657      rawFocus !== undefined && (rawFocus < minRow || rawFocus > maxRow)
658        ? rawFocus
659        : undefined
660    // anchorSpan not virtual-tracked (word/line extend, irrelevant to
661    // keyboard-scroll round-trip) — plain clamp from current row.
662    if (s.anchorSpan) {
663      const shift = (p: Point): Point => ({
664        col: p.col,
665        row: clamp(p.row + dRow, minRow, maxRow),
666      })
667      s.anchorSpan = {
668        lo: shift(s.anchorSpan.lo),
669        hi: shift(s.anchorSpan.hi),
670        kind: s.anchorSpan.kind,
671      }
672    }
673    return false
674  }
675  
676  export function hasSelection(s: SelectionState): boolean {
677    return s.anchor !== null && s.focus !== null
678  }
679  
680  /**
681   * Normalized selection bounds: start is always before end in reading order.
682   * Returns null if no active selection.
683   */
684  export function selectionBounds(s: SelectionState): {
685    start: { col: number; row: number }
686    end: { col: number; row: number }
687  } | null {
688    if (!s.anchor || !s.focus) return null
689    return comparePoints(s.anchor, s.focus) <= 0
690      ? { start: s.anchor, end: s.focus }
691      : { start: s.focus, end: s.anchor }
692  }
693  
694  /**
695   * Check if a cell at (col, row) is within the current selection range.
696   * Used by the renderer to apply inverse style.
697   */
698  export function isCellSelected(
699    s: SelectionState,
700    col: number,
701    row: number,
702  ): boolean {
703    const b = selectionBounds(s)
704    if (!b) return false
705    const { start, end } = b
706    if (row < start.row || row > end.row) return false
707    if (row === start.row && col < start.col) return false
708    if (row === end.row && col > end.col) return false
709    return true
710  }
711  
712  /** Extract text from one screen row. When the next row is a soft-wrap
713   *  continuation (screen.softWrap[row+1]>0), clamp to that content-end
714   *  column and skip the trailing trim so the word-separator space survives
715   *  the join. See Screen.softWrap for why the clamp is necessary. */
716  function extractRowText(
717    screen: Screen,
718    row: number,
719    colStart: number,
720    colEnd: number,
721  ): string {
722    const noSelect = screen.noSelect
723    const rowOff = row * screen.width
724    const contentEnd = row + 1 < screen.height ? screen.softWrap[row + 1]! : 0
725    const lastCol = contentEnd > 0 ? Math.min(colEnd, contentEnd - 1) : colEnd
726    let line = ''
727    for (let col = colStart; col <= lastCol; col++) {
728      // Skip cells marked noSelect (gutters, line numbers, diff sigils).
729      // Check before cellAt to avoid the decode cost for excluded cells.
730      if (noSelect[rowOff + col] === 1) continue
731      const cell = cellAt(screen, col, row)
732      if (!cell) continue
733      // Skip spacer tails (second half of wide chars) — the head already
734      // contains the full grapheme. SpacerHead is a blank at line-end.
735      if (
736        cell.width === CellWidth.SpacerTail ||
737        cell.width === CellWidth.SpacerHead
738      ) {
739        continue
740      }
741      line += cell.char
742    }
743    return contentEnd > 0 ? line : line.replace(/\s+$/, '')
744  }
745  
746  /** Accumulator for selected text that merges soft-wrapped rows back
747   *  into logical lines. push(text, sw) appends a newline before text
748   *  only when sw=false (i.e. the row starts a new logical line). Rows
749   *  with sw=true are concatenated onto the previous row. */
750  function joinRows(
751    lines: string[],
752    text: string,
753    sw: boolean | undefined,
754  ): void {
755    if (sw && lines.length > 0) {
756      lines[lines.length - 1] += text
757    } else {
758      lines.push(text)
759    }
760  }
761  
762  /**
763   * Extract text from the screen buffer within the selection range.
764   * Rows are joined with newlines unless the screen's softWrap bitmap
765   * marks a row as a word-wrap continuation — those rows are concatenated
766   * onto the previous row so the copied text matches the logical source
767   * line, not the visual wrapped layout. Trailing whitespace on the last
768   * fragment of each logical line is trimmed. Wide-char spacer cells are
769   * skipped. Rows that scrolled out of the viewport during drag-to-scroll
770   * are joined back in from the scrolledOffAbove/Below accumulators along
771   * with their captured softWrap bits.
772   */
773  export function getSelectedText(s: SelectionState, screen: Screen): string {
774    const b = selectionBounds(s)
775    if (!b) return ''
776    const { start, end } = b
777    const sw = screen.softWrap
778    const lines: string[] = []
779  
780    for (let i = 0; i < s.scrolledOffAbove.length; i++) {
781      joinRows(lines, s.scrolledOffAbove[i]!, s.scrolledOffAboveSW[i])
782    }
783  
784    for (let row = start.row; row <= end.row; row++) {
785      const rowStart = row === start.row ? start.col : 0
786      const rowEnd = row === end.row ? end.col : screen.width - 1
787      joinRows(lines, extractRowText(screen, row, rowStart, rowEnd), sw[row]! > 0)
788    }
789  
790    for (let i = 0; i < s.scrolledOffBelow.length; i++) {
791      joinRows(lines, s.scrolledOffBelow[i]!, s.scrolledOffBelowSW[i])
792    }
793  
794    return lines.join('\n')
795  }
796  
797  /**
798   * Capture text from rows about to scroll out of the viewport during
799   * drag-to-scroll, BEFORE scrollBy overwrites them. Only the rows that
800   * intersect the selection are captured, using the selection's col bounds
801   * for the anchor-side boundary row. After capturing the anchor row, the
802   * anchor.col AND anchorSpan cols are reset to the full-width boundary so
803   * subsequent captures and the final getSelectedText don't re-apply a stale
804   * col constraint to content that's no longer under the original anchor.
805   * Both span cols are reset (not just the near side): after a blocked
806   * reversal the drag can flip direction, and extendSelection then reads the
807   * OPPOSITE span side — which would otherwise still hold the original word
808   * boundary and truncate one subsequently-captured row.
809   *
810   * side='above': rows scrolling out the top (dragging down, anchor=start).
811   * side='below': rows scrolling out the bottom (dragging up, anchor=end).
812   */
813  export function captureScrolledRows(
814    s: SelectionState,
815    screen: Screen,
816    firstRow: number,
817    lastRow: number,
818    side: 'above' | 'below',
819  ): void {
820    const b = selectionBounds(s)
821    if (!b || firstRow > lastRow) return
822    const { start, end } = b
823    // Intersect [firstRow, lastRow] with [start.row, end.row]. Rows outside
824    // the selection aren't captured — they weren't selected.
825    const lo = Math.max(firstRow, start.row)
826    const hi = Math.min(lastRow, end.row)
827    if (lo > hi) return
828  
829    const width = screen.width
830    const sw = screen.softWrap
831    const captured: string[] = []
832    const capturedSW: boolean[] = []
833    for (let row = lo; row <= hi; row++) {
834      const colStart = row === start.row ? start.col : 0
835      const colEnd = row === end.row ? end.col : width - 1
836      captured.push(extractRowText(screen, row, colStart, colEnd))
837      capturedSW.push(sw[row]! > 0)
838    }
839  
840    if (side === 'above') {
841      // Newest rows go at the bottom of the above-accumulator (closest to
842      // the on-screen content in reading order).
843      s.scrolledOffAbove.push(...captured)
844      s.scrolledOffAboveSW.push(...capturedSW)
845      // We just captured the top of the selection. The anchor (=start when
846      // dragging down) is now pointing at content that will scroll out; its
847      // col constraint was applied to the captured row. Reset to col 0 so
848      // the NEXT tick and the final getSelectedText read the full row.
849      if (s.anchor && s.anchor.row === start.row && lo === start.row) {
850        s.anchor = { col: 0, row: s.anchor.row }
851        if (s.anchorSpan) {
852          s.anchorSpan = {
853            kind: s.anchorSpan.kind,
854            lo: { col: 0, row: s.anchorSpan.lo.row },
855            hi: { col: width - 1, row: s.anchorSpan.hi.row },
856          }
857        }
858      }
859    } else {
860      // Newest rows go at the TOP of the below-accumulator — they're
861      // closest to the on-screen content.
862      s.scrolledOffBelow.unshift(...captured)
863      s.scrolledOffBelowSW.unshift(...capturedSW)
864      if (s.anchor && s.anchor.row === end.row && hi === end.row) {
865        s.anchor = { col: width - 1, row: s.anchor.row }
866        if (s.anchorSpan) {
867          s.anchorSpan = {
868            kind: s.anchorSpan.kind,
869            lo: { col: 0, row: s.anchorSpan.lo.row },
870            hi: { col: width - 1, row: s.anchorSpan.hi.row },
871          }
872        }
873      }
874    }
875  }
876  
877  /**
878   * Apply the selection overlay directly to the screen buffer by changing
879   * the style of every cell in the selection range. Called after the
880   * renderer produces the Frame but before the diff — the normal diffEach
881   * then picks up the restyled cells as ordinary changes, so LogUpdate
882   * stays a pure diff engine with no selection awareness.
883   *
884   * Uses a SOLID selection background (theme-provided via StylePool.
885   * setSelectionBg) that REPLACES each cell's bg while PRESERVING its fg —
886   * matches native terminal selection. Previously SGR-7 inverse (swapped
887   * fg/bg per cell), which fragmented badly over syntax-highlighted text:
888   * every distinct fg color became a different bg stripe.
889   *
890   * Uses StylePool caches so on drag the only work per cell is a Map
891   * lookup + packed-int write.
892   */
893  export function applySelectionOverlay(
894    screen: Screen,
895    selection: SelectionState,
896    stylePool: StylePool,
897  ): void {
898    const b = selectionBounds(selection)
899    if (!b) return
900    const { start, end } = b
901    const width = screen.width
902    const noSelect = screen.noSelect
903    for (let row = start.row; row <= end.row && row < screen.height; row++) {
904      const colStart = row === start.row ? start.col : 0
905      const colEnd = row === end.row ? Math.min(end.col, width - 1) : width - 1
906      const rowOff = row * width
907      for (let col = colStart; col <= colEnd; col++) {
908        const idx = rowOff + col
909        // Skip noSelect cells — gutters stay visually unchanged so it's
910        // clear they're not part of the copy. Surrounding selectable cells
911        // still highlight so the selection extent remains visible.
912        if (noSelect[idx] === 1) continue
913        const cell = cellAtIndex(screen, idx)
914        setCellStyleId(screen, col, row, stylePool.withSelectionBg(cell.styleId))
915      }
916    }
917  }