Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
35 kB · 917 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918/** * Text selection state for fullscreen mode. * * Tracks a linear selection in screen-buffer coordinates (0-indexed col/row). * Selection is line-based: cells from (startCol, startRow) through * (endCol, endRow) inclusive, wrapping across line boundaries. This matches * terminal-native selection behavior (not rectangular/block). * * The selection is stored as ANCHOR (where the drag started) + FOCUS (where * the cursor is now). The rendered highlight normalizes to start ≤ end. */
import { clamp } from './layout/geometry.js'import type { Screen, StylePool } from './screen.js'import { CellWidth, cellAt, cellAtIndex, setCellStyleId } from './screen.js'
type Point = { col: number; row: number }
export type SelectionState = { /** Where the mouse-down occurred. Null when no selection. */ anchor: Point | null /** Current drag position (updated on mouse-move while dragging). */ focus: Point | null /** True between mouse-down and mouse-up. */ isDragging: boolean /** For word/line mode: the initial word/line bounds from the first * multi-click. Drag extends from this span to the word/line at the * current mouse position so the original word/line stays selected * even when dragging backward past it. Null ⇔ char mode. The kind * tells extendSelection whether to snap to word or line boundaries. */ anchorSpan: { lo: Point; hi: Point; kind: 'word' | 'line' } | null /** Text from rows that scrolled out ABOVE the viewport during * drag-to-scroll. The screen buffer only holds the current viewport, * so without this accumulator, dragging down past the bottom edge * loses the top of the selection once the anchor clamps. Prepended * to the on-screen text by getSelectedText. Reset on start/clear. */ scrolledOffAbove: string[] /** Symmetric: rows scrolled out BELOW when dragging up. Appended. */ scrolledOffBelow: string[] /** Soft-wrap bits parallel to scrolledOffAbove — true means the row * is a continuation of the one before it (the `\n` was inserted by * word-wrap, not in the source). Captured alongside the text at * scroll time since the screen's softWrap bitmap shifts with content. * getSelectedText uses these to join wrapped rows back into logical * lines. */ scrolledOffAboveSW: boolean[] /** Parallel to scrolledOffBelow. */ scrolledOffBelowSW: boolean[] /** Pre-clamp anchor row. Set when shiftSelection clamps anchor so a * reverse scroll can restore the true position and pop accumulators. * Without this, PgDn (clamps anchor) → PgUp leaves anchor at the wrong * row AND scrolledOffAbove stale — highlight ≠ copy. Undefined when * anchor is in-bounds (no clamp debt). Cleared on start/clear. */ virtualAnchorRow?: number /** Same for focus. */ virtualFocusRow?: number /** True if the mouse-down that started this selection had the alt * modifier set (SGR button bit 0x08). On macOS xterm.js this is a * signal that VS Code's macOptionClickForcesSelection is OFF — if it * were on, xterm.js would have consumed the event for native selection * and we'd never receive it. Used by the footer to show the right hint. */ lastPressHadAlt: boolean}
export function createSelectionState(): SelectionState { return { anchor: null, focus: null, isDragging: false, anchorSpan: null, scrolledOffAbove: [], scrolledOffBelow: [], scrolledOffAboveSW: [], scrolledOffBelowSW: [], lastPressHadAlt: false, }}
export function startSelection( s: SelectionState, col: number, row: number,): void { s.anchor = { col, row } // Focus is not set until the first drag motion. A click-release with no // drag leaves focus null → hasSelection/selectionBounds return false/null // via the `!s.focus` check, so a bare click never highlights a cell. s.focus = null s.isDragging = true s.anchorSpan = null s.scrolledOffAbove = [] s.scrolledOffBelow = [] s.scrolledOffAboveSW = [] s.scrolledOffBelowSW = [] s.virtualAnchorRow = undefined s.virtualFocusRow = undefined s.lastPressHadAlt = false}
export function updateSelection( s: SelectionState, col: number, row: number,): void { if (!s.isDragging) return // First motion at the same cell as anchor is a no-op. Terminals in mode // 1002 can fire a drag event at the anchor cell (sub-pixel tremor, or a // motion-release pair). Setting focus here would turn a bare click into // a 1-cell selection and clobber the clipboard via useCopyOnSelect. Once // focus is set (real drag), we track normally including back to anchor. if (!s.focus && s.anchor && s.anchor.col === col && s.anchor.row === row) return s.focus = { col, row }}
export function finishSelection(s: SelectionState): void { s.isDragging = false // Keep anchor/focus so highlight stays visible and text can be copied. // Clear via clearSelection() on Esc or after copy.}
export function clearSelection(s: SelectionState): void { s.anchor = null s.focus = null s.isDragging = false s.anchorSpan = null s.scrolledOffAbove = [] s.scrolledOffBelow = [] s.scrolledOffAboveSW = [] s.scrolledOffBelowSW = [] s.virtualAnchorRow = undefined s.virtualFocusRow = undefined s.lastPressHadAlt = false}
// Unicode-aware word character matcher: letters (any script), digits,// and the punctuation set iTerm2 treats as word-part by default.// Matching iTerm2's default means double-clicking a path like// `/usr/bin/bash` or `~/.claude/config.json` selects the whole thing,// which is the muscle memory most macOS terminal users have.// iTerm2 default "characters considered part of a word": /-+\~_.const WORD_CHAR = /[\p{L}\p{N}_/.\-+~\\]/u
/** * Character class for double-click word-expansion. Cells with the same * class as the clicked cell are included in the selection; a class change * is a boundary. Matches typical terminal-emulator behavior (iTerm2 etc.): * double-click on `foo` selects `foo`, on `->` selects `->`, on spaces * selects the whitespace run. */function charClass(c: string): 0 | 1 | 2 { if (c === ' ' || c === '') return 0 if (WORD_CHAR.test(c)) return 1 return 2}
/** * Find the bounds of the same-class character run at (col, row). Returns * null if the click is out of bounds or lands on a noSelect cell. Used by * selectWordAt (initial double-click) and extendWordSelection (drag). */function wordBoundsAt( screen: Screen, col: number, row: number,): { lo: number; hi: number } | null { if (row < 0 || row >= screen.height) return null const width = screen.width const noSelect = screen.noSelect const rowOff = row * width
// If the click landed on the spacer tail of a wide char, step back to // the head so the class check sees the actual grapheme. let c = col if (c > 0) { const cell = cellAt(screen, c, row) if (cell && cell.width === CellWidth.SpacerTail) c -= 1 } if (c < 0 || c >= width || noSelect[rowOff + c] === 1) return null
const startCell = cellAt(screen, c, row) if (!startCell) return null const cls = charClass(startCell.char)
// Expand left: include cells of the same class, stop at noSelect or // class change. SpacerTail cells are stepped over (the wide-char head // at the preceding column determines the class). let lo = c while (lo > 0) { const prev = lo - 1 if (noSelect[rowOff + prev] === 1) break const pc = cellAt(screen, prev, row) if (!pc) break if (pc.width === CellWidth.SpacerTail) { // Step over the spacer to the wide-char head if (prev === 0 || noSelect[rowOff + prev - 1] === 1) break const head = cellAt(screen, prev - 1, row) if (!head || charClass(head.char) !== cls) break lo = prev - 1 continue } if (charClass(pc.char) !== cls) break lo = prev }
// Expand right: same logic, skipping spacer tails. let hi = c while (hi < width - 1) { const next = hi + 1 if (noSelect[rowOff + next] === 1) break const nc = cellAt(screen, next, row) if (!nc) break if (nc.width === CellWidth.SpacerTail) { // Include the spacer tail in the selection range (it belongs to // the wide char at hi) and continue past it. hi = next continue } if (charClass(nc.char) !== cls) break hi = next }
return { lo, hi }}
/** -1 if a < b, 1 if a > b, 0 if equal (reading order: row then col). */function comparePoints(a: Point, b: Point): number { if (a.row !== b.row) return a.row < b.row ? -1 : 1 if (a.col !== b.col) return a.col < b.col ? -1 : 1 return 0}
/** * Select the word at (col, row) by scanning the screen buffer for the * bounds of the same-class character run. Mutates the selection in place. * No-op if the click is out of bounds or lands on a noSelect cell. * Sets isDragging=true and anchorSpan so a subsequent drag extends the * selection word-by-word (native macOS behavior). */export function selectWordAt( s: SelectionState, screen: Screen, col: number, row: number,): void { const b = wordBoundsAt(screen, col, row) if (!b) return const lo = { col: b.lo, row } const hi = { col: b.hi, row } s.anchor = lo s.focus = hi s.isDragging = true s.anchorSpan = { lo, hi, kind: 'word' }}
// Printable ASCII minus terminal URL delimiters. Restricting to single-// codeunit ASCII keeps cell-count === string-index, so the column-span// check below is exact (no wide-char/grapheme drift).const URL_BOUNDARY = new Set([...'<>"\'` '])function isUrlChar(c: string): boolean { if (c.length !== 1) return false const code = c.charCodeAt(0) return code >= 0x21 && code <= 0x7e && !URL_BOUNDARY.has(c)}
/** * Scan the screen buffer for a plain-text URL at (col, row). Mirrors the * terminal's native Cmd+Click URL detection, which fullscreen mode's mouse * tracking intercepts. Called from getHyperlinkAt as a fallback when the * cell has no OSC 8 hyperlink. */export function findPlainTextUrlAt( screen: Screen, col: number, row: number,): string | undefined { if (row < 0 || row >= screen.height) return undefined const width = screen.width const noSelect = screen.noSelect const rowOff = row * width
let c = col if (c > 0) { const cell = cellAt(screen, c, row) if (cell && cell.width === CellWidth.SpacerTail) c -= 1 } if (c < 0 || c >= width || noSelect[rowOff + c] === 1) return undefined
const startCell = cellAt(screen, c, row) if (!startCell || !isUrlChar(startCell.char)) return undefined
// Expand left/right to the bounds of the URL-char run. URLs are ASCII // (CellWidth.Narrow, 1 codeunit), so hitting a non-ASCII/wide/spacer // cell is a boundary — no need to step over spacers like wordBoundsAt. let lo = c while (lo > 0) { const prev = lo - 1 if (noSelect[rowOff + prev] === 1) break const pc = cellAt(screen, prev, row) if (!pc || pc.width !== CellWidth.Narrow || !isUrlChar(pc.char)) break lo = prev } let hi = c while (hi < width - 1) { const next = hi + 1 if (noSelect[rowOff + next] === 1) break const nc = cellAt(screen, next, row) if (!nc || nc.width !== CellWidth.Narrow || !isUrlChar(nc.char)) break hi = next }
let token = '' for (let i = lo; i <= hi; i++) token += cellAt(screen, i, row)!.char
// 1 cell = 1 char across [lo, hi] (ASCII-only run), so string index = // column offset. Find the last scheme anchor at or before the click — // a run like `https://a.com,https://b.com` has two, and clicking the // second should return the second URL, not the greedy match of both. const clickIdx = c - lo const schemeRe = /(?:https?|file):\/\//g let urlStart = -1 let urlEnd = token.length for (let m; (m = schemeRe.exec(token)); ) { if (m.index > clickIdx) { urlEnd = m.index break } urlStart = m.index } if (urlStart < 0) return undefined let url = token.slice(urlStart, urlEnd)
// Strip trailing sentence punctuation. For closers () ] }, only strip // if unbalanced — `/wiki/Foo_(bar)` keeps `)`, `/arr[0]` keeps `]`. const OPENER: Record<string, string> = { ')': '(', ']': '[', '}': '{' } while (url.length > 0) { const last = url.at(-1)! if ('.,;:!?'.includes(last)) { url = url.slice(0, -1) continue } const opener = OPENER[last] if (!opener) break let opens = 0 let closes = 0 for (let i = 0; i < url.length; i++) { const ch = url.charAt(i) if (ch === opener) opens++ else if (ch === last) closes++ } if (closes > opens) url = url.slice(0, -1) else break }
// urlStart already guarantees click >= URL start; check right edge. if (clickIdx >= urlStart + url.length) return undefined
return url}
/** * Select the entire row. Sets isDragging=true and anchorSpan so a * subsequent drag extends the selection line-by-line. The anchor/focus * span from col 0 to width-1; getSelectedText handles noSelect skipping * and trailing-whitespace trimming so the copied text is just the visible * line content. */export function selectLineAt( s: SelectionState, screen: Screen, row: number,): void { if (row < 0 || row >= screen.height) return const lo = { col: 0, row } const hi = { col: screen.width - 1, row } s.anchor = lo s.focus = hi s.isDragging = true s.anchorSpan = { lo, hi, kind: 'line' }}
/** * Extend a word/line-mode selection to the word/line at (col, row). The * anchor span (the original multi-clicked word/line) stays selected; the * selection grows from that span to the word/line at the current mouse * position. Word mode falls back to the raw cell when the mouse is over a * noSelect cell or out of bounds, so dragging into gutters still extends. */export function extendSelection( s: SelectionState, screen: Screen, col: number, row: number,): void { if (!s.isDragging || !s.anchorSpan) return const span = s.anchorSpan let mLo: Point let mHi: Point if (span.kind === 'word') { const b = wordBoundsAt(screen, col, row) mLo = { col: b ? b.lo : col, row } mHi = { col: b ? b.hi : col, row } } else { const r = clamp(row, 0, screen.height - 1) mLo = { col: 0, row: r } mHi = { col: screen.width - 1, row: r } } if (comparePoints(mHi, span.lo) < 0) { // Mouse target ends before anchor span: extend backward. s.anchor = span.hi s.focus = mLo } else if (comparePoints(mLo, span.hi) > 0) { // Mouse target starts after anchor span: extend forward. s.anchor = span.lo s.focus = mHi } else { // Mouse overlaps the anchor span: just select the anchor span. s.anchor = span.lo s.focus = span.hi }}
/** Semantic keyboard focus moves. See moveSelectionFocus in ink.tsx for * how screen bounds + row-wrap are applied. */export type FocusMove = | 'left' | 'right' | 'up' | 'down' | 'lineStart' | 'lineEnd'
/** * Set focus to (col, row) for keyboard selection extension (shift+arrow). * Anchor stays fixed; selection grows or shrinks depending on where focus * moves relative to anchor. Drops to char mode (clears anchorSpan) — * native macOS does this too: shift+arrow after a double-click word-select * extends char-by-char from the word edge, not word-by-word. Scrolled-off * accumulators are preserved: keyboard-extending a drag-scrolled selection * keeps the off-screen rows. Caller supplies coords already clamped/wrapped. */export function moveFocus(s: SelectionState, col: number, row: number): void { if (!s.focus) return s.anchorSpan = null s.focus = { col, row } // Explicit user repositioning — any stale virtual focus (from a prior // shiftSelection clamp) no longer reflects intent. Anchor stays put so // virtualAnchorRow is still valid for its own round-trip. s.virtualFocusRow = undefined}
/** * Shift anchor AND focus by dRow, clamped to [minRow, maxRow]. Used for * keyboard scroll (PgUp/PgDn/ctrl+u/d/b/f): the whole selection must track * the content, unlike drag-to-scroll where focus stays at the mouse. Any * point that hits a clamp bound gets its col reset to the full-width edge — * its original content scrolled off-screen and was captured by * captureScrolledRows, so the col constraint was already consumed. Keeping * it would truncate the NEW content now at that screen row. Clamp col is 0 * for dRow<0 (scrolling down, top leaves, 'above' semantics) or width-1 for * dRow>0 (scrolling up, bottom leaves, 'below' semantics). * * If both ends overshoot the SAME viewport edge (select text → Home/End/g/G * jumps far enough that both are out of view), clear — otherwise both clamp * to the same corner cell and a ghost 1-cell highlight lingers, and * getSelectedText returns one unrelated char from that corner. Symmetric * with shiftSelectionForFollow's top-edge check, but bidirectional: keyboard * scroll can jump either way. */export function shiftSelection( s: SelectionState, dRow: number, minRow: number, maxRow: number, width: number,): void { if (!s.anchor || !s.focus) return // Virtual rows track pre-clamp positions so reverse scrolls restore // correctly. Without this, clamp(5→0) + shift(+10) = 10, not the true 5, // and scrolledOffAbove stays stale (highlight ≠ copy). const vAnchor = (s.virtualAnchorRow ?? s.anchor.row) + dRow const vFocus = (s.virtualFocusRow ?? s.focus.row) + dRow if ( (vAnchor < minRow && vFocus < minRow) || (vAnchor > maxRow && vFocus > maxRow) ) { clearSelection(s) return } // Debt = how far the nearer endpoint overshoots each edge. When debt // shrinks (reverse scroll), those rows are back on-screen — pop from // the accumulator so getSelectedText doesn't double-count them. const oldMin = Math.min( s.virtualAnchorRow ?? s.anchor.row, s.virtualFocusRow ?? s.focus.row, ) const oldMax = Math.max( s.virtualAnchorRow ?? s.anchor.row, s.virtualFocusRow ?? s.focus.row, ) const oldAboveDebt = Math.max(0, minRow - oldMin) const oldBelowDebt = Math.max(0, oldMax - maxRow) const newAboveDebt = Math.max(0, minRow - Math.min(vAnchor, vFocus)) const newBelowDebt = Math.max(0, Math.max(vAnchor, vFocus) - maxRow) if (newAboveDebt < oldAboveDebt) { // scrolledOffAbove pushes newest at the end (closest to on-screen). const drop = oldAboveDebt - newAboveDebt s.scrolledOffAbove.length -= drop s.scrolledOffAboveSW.length = s.scrolledOffAbove.length } if (newBelowDebt < oldBelowDebt) { // scrolledOffBelow unshifts newest at the front (closest to on-screen). const drop = oldBelowDebt - newBelowDebt s.scrolledOffBelow.splice(0, drop) s.scrolledOffBelowSW.splice(0, drop) } // Invariant: accumulator length ≤ debt. If the accumulator exceeds debt, // the excess is stale — e.g., moveFocus cleared virtualFocusRow without // trimming the accumulator, orphaning entries the pop above can never // reach because oldDebt was ALREADY 0. Truncate to debt (keeping the // newest = closest-to-on-screen entries). Check newDebt (not oldDebt): // captureScrolledRows runs BEFORE this shift in the real flow (ink.tsx), // so at entry the accumulator is populated but oldDebt is still 0 — // that's the normal establish-debt path, not stale. if (s.scrolledOffAbove.length > newAboveDebt) { // Above pushes newest at END → keep END. s.scrolledOffAbove = newAboveDebt > 0 ? s.scrolledOffAbove.slice(-newAboveDebt) : [] s.scrolledOffAboveSW = newAboveDebt > 0 ? s.scrolledOffAboveSW.slice(-newAboveDebt) : [] } if (s.scrolledOffBelow.length > newBelowDebt) { // Below unshifts newest at FRONT → keep FRONT. s.scrolledOffBelow = s.scrolledOffBelow.slice(0, newBelowDebt) s.scrolledOffBelowSW = s.scrolledOffBelowSW.slice(0, newBelowDebt) } // Clamp col depends on which EDGE (not dRow direction): virtual tracking // means a top-clamped point can stay top-clamped during a dRow>0 reverse // shift — dRow-based clampCol would give it the bottom col. const shift = (p: Point, vRow: number): Point => { if (vRow < minRow) return { col: 0, row: minRow } if (vRow > maxRow) return { col: width - 1, row: maxRow } return { col: p.col, row: vRow } } s.anchor = shift(s.anchor, vAnchor) s.focus = shift(s.focus, vFocus) s.virtualAnchorRow = vAnchor < minRow || vAnchor > maxRow ? vAnchor : undefined s.virtualFocusRow = vFocus < minRow || vFocus > maxRow ? vFocus : undefined // anchorSpan not virtual-tracked: it's for word/line extend-on-drag, // irrelevant to the keyboard-scroll round-trip case. if (s.anchorSpan) { const sp = (p: Point): Point => { const r = p.row + dRow if (r < minRow) return { col: 0, row: minRow } if (r > maxRow) return { col: width - 1, row: maxRow } return { col: p.col, row: r } } s.anchorSpan = { lo: sp(s.anchorSpan.lo), hi: sp(s.anchorSpan.hi), kind: s.anchorSpan.kind, } }}
/** * Shift the anchor row by dRow, clamped to [minRow, maxRow]. Used during * drag-to-scroll: when the ScrollBox scrolls by N rows, the content that * was under the anchor is now at a different viewport row, so the anchor * must follow it. Focus is left unchanged (it stays at the mouse position). */export function shiftAnchor( s: SelectionState, dRow: number, minRow: number, maxRow: number,): void { if (!s.anchor) return // Same virtual-row tracking as shiftSelection/shiftSelectionForFollow: the // drag→follow transition hands off to shiftSelectionForFollow, which reads // (virtualAnchorRow ?? anchor.row). Without this, drag-phase clamping // leaves virtual undefined → follow initializes from the already-clamped // row, under-counting total drift → shiftSelection's invariant-restore // prematurely clears valid drag-phase accumulator entries. const raw = (s.virtualAnchorRow ?? s.anchor.row) + dRow s.anchor = { col: s.anchor.col, row: clamp(raw, minRow, maxRow) } s.virtualAnchorRow = raw < minRow || raw > maxRow ? raw : undefined // anchorSpan not virtual-tracked (word/line extend, irrelevant to // keyboard-scroll round-trip) — plain clamp from current row. if (s.anchorSpan) { const shift = (p: Point): Point => ({ col: p.col, row: clamp(p.row + dRow, minRow, maxRow), }) s.anchorSpan = { lo: shift(s.anchorSpan.lo), hi: shift(s.anchorSpan.hi), kind: s.anchorSpan.kind, } }}
/** * Shift the whole selection (anchor + focus + anchorSpan) by dRow, clamped * to [minRow, maxRow]. Used when sticky/auto-follow scrolls the ScrollBox * while a selection is active — native terminal behavior is for the * highlight to walk up the screen with the text (not stay at the same * screen position). * * Differs from shiftAnchor: during drag-to-scroll, focus tracks the live * mouse position and only anchor follows the text. During streaming-follow, * the selection is text-anchored at both ends — both must move. The * isDragging check in ink.tsx picks which shift to apply. * * If both ends would shift strictly BELOW minRow (unclamped), the selected * text has scrolled entirely off the top. Clear it — otherwise a single * inverted cell lingers at the viewport top as a ghost (native terminals * drop the selection when it leaves scrollback). Landing AT minRow is * still valid: that cell holds the correct text. Returns true if the * selection was cleared so the caller can notify React-land subscribers * (useHasSelection) — the caller is inside onRender so it can't use * notifySelectionChange (recursion), must fire listeners directly. */export function shiftSelectionForFollow( s: SelectionState, dRow: number, minRow: number, maxRow: number,): boolean { if (!s.anchor) return false // Mirror shiftSelection: compute raw (unclamped) positions from virtual // if set, else current. This handles BOTH the update path (virtual already // set from a prior keyboard scroll) AND the initialize path (first clamp // happens HERE via follow-scroll, no prior keyboard scroll). Without the // initialize path, follow-scroll-first leaves virtual undefined even // though the clamp below occurred → a later PgUp computes debt from the // clamped row instead of the true pre-clamp row and never pops the // accumulator — getSelectedText double-counts the off-screen rows. const rawAnchor = (s.virtualAnchorRow ?? s.anchor.row) + dRow const rawFocus = s.focus ? (s.virtualFocusRow ?? s.focus.row) + dRow : undefined if (rawAnchor < minRow && rawFocus !== undefined && rawFocus < minRow) { clearSelection(s) return true } // Clamp from raw, not p.row+dRow — so a virtual position coming back // in-bounds lands at the TRUE position, not the stale clamped one. s.anchor = { col: s.anchor.col, row: clamp(rawAnchor, minRow, maxRow) } if (s.focus && rawFocus !== undefined) { s.focus = { col: s.focus.col, row: clamp(rawFocus, minRow, maxRow) } } s.virtualAnchorRow = rawAnchor < minRow || rawAnchor > maxRow ? rawAnchor : undefined s.virtualFocusRow = rawFocus !== undefined && (rawFocus < minRow || rawFocus > maxRow) ? rawFocus : undefined // anchorSpan not virtual-tracked (word/line extend, irrelevant to // keyboard-scroll round-trip) — plain clamp from current row. if (s.anchorSpan) { const shift = (p: Point): Point => ({ col: p.col, row: clamp(p.row + dRow, minRow, maxRow), }) s.anchorSpan = { lo: shift(s.anchorSpan.lo), hi: shift(s.anchorSpan.hi), kind: s.anchorSpan.kind, } } return false}
export function hasSelection(s: SelectionState): boolean { return s.anchor !== null && s.focus !== null}
/** * Normalized selection bounds: start is always before end in reading order. * Returns null if no active selection. */export function selectionBounds(s: SelectionState): { start: { col: number; row: number } end: { col: number; row: number }} | null { if (!s.anchor || !s.focus) return null return comparePoints(s.anchor, s.focus) <= 0 ? { start: s.anchor, end: s.focus } : { start: s.focus, end: s.anchor }}
/** * Check if a cell at (col, row) is within the current selection range. * Used by the renderer to apply inverse style. */export function isCellSelected( s: SelectionState, col: number, row: number,): boolean { const b = selectionBounds(s) if (!b) return false const { start, end } = b if (row < start.row || row > end.row) return false if (row === start.row && col < start.col) return false if (row === end.row && col > end.col) return false return true}
/** Extract text from one screen row. When the next row is a soft-wrap * continuation (screen.softWrap[row+1]>0), clamp to that content-end * column and skip the trailing trim so the word-separator space survives * the join. See Screen.softWrap for why the clamp is necessary. */function extractRowText( screen: Screen, row: number, colStart: number, colEnd: number,): string { const noSelect = screen.noSelect const rowOff = row * screen.width const contentEnd = row + 1 < screen.height ? screen.softWrap[row + 1]! : 0 const lastCol = contentEnd > 0 ? Math.min(colEnd, contentEnd - 1) : colEnd let line = '' for (let col = colStart; col <= lastCol; col++) { // Skip cells marked noSelect (gutters, line numbers, diff sigils). // Check before cellAt to avoid the decode cost for excluded cells. if (noSelect[rowOff + col] === 1) continue const cell = cellAt(screen, col, row) if (!cell) continue // Skip spacer tails (second half of wide chars) — the head already // contains the full grapheme. SpacerHead is a blank at line-end. if ( cell.width === CellWidth.SpacerTail || cell.width === CellWidth.SpacerHead ) { continue } line += cell.char } return contentEnd > 0 ? line : line.replace(/\s+$/, '')}
/** Accumulator for selected text that merges soft-wrapped rows back * into logical lines. push(text, sw) appends a newline before text * only when sw=false (i.e. the row starts a new logical line). Rows * with sw=true are concatenated onto the previous row. */function joinRows( lines: string[], text: string, sw: boolean | undefined,): void { if (sw && lines.length > 0) { lines[lines.length - 1] += text } else { lines.push(text) }}
/** * Extract text from the screen buffer within the selection range. * Rows are joined with newlines unless the screen's softWrap bitmap * marks a row as a word-wrap continuation — those rows are concatenated * onto the previous row so the copied text matches the logical source * line, not the visual wrapped layout. Trailing whitespace on the last * fragment of each logical line is trimmed. Wide-char spacer cells are * skipped. Rows that scrolled out of the viewport during drag-to-scroll * are joined back in from the scrolledOffAbove/Below accumulators along * with their captured softWrap bits. */export function getSelectedText(s: SelectionState, screen: Screen): string { const b = selectionBounds(s) if (!b) return '' const { start, end } = b const sw = screen.softWrap const lines: string[] = []
for (let i = 0; i < s.scrolledOffAbove.length; i++) { joinRows(lines, s.scrolledOffAbove[i]!, s.scrolledOffAboveSW[i]) }
for (let row = start.row; row <= end.row; row++) { const rowStart = row === start.row ? start.col : 0 const rowEnd = row === end.row ? end.col : screen.width - 1 joinRows(lines, extractRowText(screen, row, rowStart, rowEnd), sw[row]! > 0) }
for (let i = 0; i < s.scrolledOffBelow.length; i++) { joinRows(lines, s.scrolledOffBelow[i]!, s.scrolledOffBelowSW[i]) }
return lines.join('\n')}
/** * Capture text from rows about to scroll out of the viewport during * drag-to-scroll, BEFORE scrollBy overwrites them. Only the rows that * intersect the selection are captured, using the selection's col bounds * for the anchor-side boundary row. After capturing the anchor row, the * anchor.col AND anchorSpan cols are reset to the full-width boundary so * subsequent captures and the final getSelectedText don't re-apply a stale * col constraint to content that's no longer under the original anchor. * Both span cols are reset (not just the near side): after a blocked * reversal the drag can flip direction, and extendSelection then reads the * OPPOSITE span side — which would otherwise still hold the original word * boundary and truncate one subsequently-captured row. * * side='above': rows scrolling out the top (dragging down, anchor=start). * side='below': rows scrolling out the bottom (dragging up, anchor=end). */export function captureScrolledRows( s: SelectionState, screen: Screen, firstRow: number, lastRow: number, side: 'above' | 'below',): void { const b = selectionBounds(s) if (!b || firstRow > lastRow) return const { start, end } = b // Intersect [firstRow, lastRow] with [start.row, end.row]. Rows outside // the selection aren't captured — they weren't selected. const lo = Math.max(firstRow, start.row) const hi = Math.min(lastRow, end.row) if (lo > hi) return
const width = screen.width const sw = screen.softWrap const captured: string[] = [] const capturedSW: boolean[] = [] for (let row = lo; row <= hi; row++) { const colStart = row === start.row ? start.col : 0 const colEnd = row === end.row ? end.col : width - 1 captured.push(extractRowText(screen, row, colStart, colEnd)) capturedSW.push(sw[row]! > 0) }
if (side === 'above') { // Newest rows go at the bottom of the above-accumulator (closest to // the on-screen content in reading order). s.scrolledOffAbove.push(...captured) s.scrolledOffAboveSW.push(...capturedSW) // We just captured the top of the selection. The anchor (=start when // dragging down) is now pointing at content that will scroll out; its // col constraint was applied to the captured row. Reset to col 0 so // the NEXT tick and the final getSelectedText read the full row. if (s.anchor && s.anchor.row === start.row && lo === start.row) { s.anchor = { col: 0, row: s.anchor.row } if (s.anchorSpan) { s.anchorSpan = { kind: s.anchorSpan.kind, lo: { col: 0, row: s.anchorSpan.lo.row }, hi: { col: width - 1, row: s.anchorSpan.hi.row }, } } } } else { // Newest rows go at the TOP of the below-accumulator — they're // closest to the on-screen content. s.scrolledOffBelow.unshift(...captured) s.scrolledOffBelowSW.unshift(...capturedSW) if (s.anchor && s.anchor.row === end.row && hi === end.row) { s.anchor = { col: width - 1, row: s.anchor.row } if (s.anchorSpan) { s.anchorSpan = { kind: s.anchorSpan.kind, lo: { col: 0, row: s.anchorSpan.lo.row }, hi: { col: width - 1, row: s.anchorSpan.hi.row }, } } } }}
/** * Apply the selection overlay directly to the screen buffer by changing * the style of every cell in the selection range. Called after the * renderer produces the Frame but before the diff — the normal diffEach * then picks up the restyled cells as ordinary changes, so LogUpdate * stays a pure diff engine with no selection awareness. * * Uses a SOLID selection background (theme-provided via StylePool. * setSelectionBg) that REPLACES each cell's bg while PRESERVING its fg — * matches native terminal selection. Previously SGR-7 inverse (swapped * fg/bg per cell), which fragmented badly over syntax-highlighted text: * every distinct fg color became a different bg stripe. * * Uses StylePool caches so on drag the only work per cell is a Map * lookup + packed-int write. */export function applySelectionOverlay( screen: Screen, selection: SelectionState, stylePool: StylePool,): void { const b = selectionBounds(selection) if (!b) return const { start, end } = b const width = screen.width const noSelect = screen.noSelect for (let row = start.row; row <= end.row && row < screen.height; row++) { const colStart = row === start.row ? start.col : 0 const colEnd = row === end.row ? Math.min(end.col, width - 1) : width - 1 const rowOff = row * width for (let col = colStart; col <= colEnd; col++) { const idx = rowOff + col // Skip noSelect cells — gutters stay visually unchanged so it's // clear they're not part of the copy. Surrounding selectable cells // still highlight so the selection extent remains visible. if (noSelect[idx] === 1) continue const cell = cellAtIndex(screen, idx) setCellStyleId(screen, col, row, stylePool.withSelectionBg(cell.styleId)) } }}