/ src / utils / Cursor.ts
Cursor.ts
   1  import { stringWidth } from '../ink/stringWidth.js'
   2  import { wrapAnsi } from '../ink/wrapAnsi.js'
   3  import {
   4    firstGrapheme,
   5    getGraphemeSegmenter,
   6    getWordSegmenter,
   7  } from './intl.js'
   8  
   9  /**
  10   * Kill ring for storing killed (cut) text that can be yanked (pasted) with Ctrl+Y.
  11   * This is global state that shares one kill ring across all input fields.
  12   *
  13   * Consecutive kills accumulate in the kill ring until the user types some
  14   * other key. Alt+Y cycles through previous kills after a yank.
  15   */
  16  const KILL_RING_MAX_SIZE = 10
  17  let killRing: string[] = []
  18  let killRingIndex = 0
  19  let lastActionWasKill = false
  20  
  21  // Track yank state for yank-pop (alt-y)
  22  let lastYankStart = 0
  23  let lastYankLength = 0
  24  let lastActionWasYank = false
  25  
  26  export function pushToKillRing(
  27    text: string,
  28    direction: 'prepend' | 'append' = 'append',
  29  ): void {
  30    if (text.length > 0) {
  31      if (lastActionWasKill && killRing.length > 0) {
  32        // Accumulate with the most recent kill
  33        if (direction === 'prepend') {
  34          killRing[0] = text + killRing[0]
  35        } else {
  36          killRing[0] = killRing[0] + text
  37        }
  38      } else {
  39        // Add new entry to front of ring
  40        killRing.unshift(text)
  41        if (killRing.length > KILL_RING_MAX_SIZE) {
  42          killRing.pop()
  43        }
  44      }
  45      lastActionWasKill = true
  46      // Reset yank state when killing new text
  47      lastActionWasYank = false
  48    }
  49  }
  50  
  51  export function getLastKill(): string {
  52    return killRing[0] ?? ''
  53  }
  54  
  55  export function getKillRingItem(index: number): string {
  56    if (killRing.length === 0) return ''
  57    const normalizedIndex =
  58      ((index % killRing.length) + killRing.length) % killRing.length
  59    return killRing[normalizedIndex] ?? ''
  60  }
  61  
  62  export function getKillRingSize(): number {
  63    return killRing.length
  64  }
  65  
  66  export function clearKillRing(): void {
  67    killRing = []
  68    killRingIndex = 0
  69    lastActionWasKill = false
  70    lastActionWasYank = false
  71    lastYankStart = 0
  72    lastYankLength = 0
  73  }
  74  
  75  export function resetKillAccumulation(): void {
  76    lastActionWasKill = false
  77  }
  78  
  79  // Yank tracking for yank-pop
  80  export function recordYank(start: number, length: number): void {
  81    lastYankStart = start
  82    lastYankLength = length
  83    lastActionWasYank = true
  84    killRingIndex = 0
  85  }
  86  
  87  export function canYankPop(): boolean {
  88    return lastActionWasYank && killRing.length > 1
  89  }
  90  
  91  export function yankPop(): {
  92    text: string
  93    start: number
  94    length: number
  95  } | null {
  96    if (!lastActionWasYank || killRing.length <= 1) {
  97      return null
  98    }
  99    // Cycle to next item in kill ring
 100    killRingIndex = (killRingIndex + 1) % killRing.length
 101    const text = killRing[killRingIndex] ?? ''
 102    return { text, start: lastYankStart, length: lastYankLength }
 103  }
 104  
 105  export function updateYankLength(length: number): void {
 106    lastYankLength = length
 107  }
 108  
 109  export function resetYankState(): void {
 110    lastActionWasYank = false
 111  }
 112  
 113  /**
 114   * Text Processing Flow for Unicode Normalization:
 115   *
 116   * User Input (raw text, potentially mixed NFD/NFC)
 117   *     ↓
 118   * MeasuredText (normalizes to NFC + builds grapheme info)
 119   *     ↓
 120   * All cursor operations use normalized text/offsets
 121   *     ↓
 122   * Display uses normalized text from wrappedLines
 123   *
 124   * This flow ensures consistent Unicode handling:
 125   * - NFD/NFC normalization differences don't break cursor movement
 126   * - Grapheme clusters (like 👨‍👩‍👧‍👦) are treated as single units
 127   * - Display width calculations are accurate for CJK characters
 128   *
 129   * RULE: Once text enters MeasuredText, all operations
 130   * work on the normalized version.
 131   */
 132  
 133  // Pre-compiled regex patterns for Vim word detection (avoid creating in hot loops)
 134  export const VIM_WORD_CHAR_REGEX = /^[\p{L}\p{N}\p{M}_]$/u
 135  export const WHITESPACE_REGEX = /\s/
 136  
 137  // Exported helper functions for Vim character classification
 138  export const isVimWordChar = (ch: string): boolean =>
 139    VIM_WORD_CHAR_REGEX.test(ch)
 140  export const isVimWhitespace = (ch: string): boolean =>
 141    WHITESPACE_REGEX.test(ch)
 142  export const isVimPunctuation = (ch: string): boolean =>
 143    ch.length > 0 && !isVimWhitespace(ch) && !isVimWordChar(ch)
 144  
 145  type WrappedText = string[]
 146  type Position = {
 147    line: number
 148    column: number
 149  }
 150  
 151  export class Cursor {
 152    readonly offset: number
 153    constructor(
 154      readonly measuredText: MeasuredText,
 155      offset: number = 0,
 156      readonly selection: number = 0,
 157    ) {
 158      // it's ok for the cursor to be 1 char beyond the end of the string
 159      this.offset = Math.max(0, Math.min(this.text.length, offset))
 160    }
 161  
 162    static fromText(
 163      text: string,
 164      columns: number,
 165      offset: number = 0,
 166      selection: number = 0,
 167    ): Cursor {
 168      // make MeasuredText on less than columns width, to account for cursor
 169      return new Cursor(new MeasuredText(text, columns - 1), offset, selection)
 170    }
 171  
 172    getViewportStartLine(maxVisibleLines?: number): number {
 173      if (maxVisibleLines === undefined || maxVisibleLines <= 0) return 0
 174      const { line } = this.getPosition()
 175      const allLines = this.measuredText.getWrappedText()
 176      if (allLines.length <= maxVisibleLines) return 0
 177      const half = Math.floor(maxVisibleLines / 2)
 178      let startLine = Math.max(0, line - half)
 179      const endLine = Math.min(allLines.length, startLine + maxVisibleLines)
 180      if (endLine - startLine < maxVisibleLines) {
 181        startLine = Math.max(0, endLine - maxVisibleLines)
 182      }
 183      return startLine
 184    }
 185  
 186    getViewportCharOffset(maxVisibleLines?: number): number {
 187      const startLine = this.getViewportStartLine(maxVisibleLines)
 188      if (startLine === 0) return 0
 189      const wrappedLines = this.measuredText.getWrappedLines()
 190      return wrappedLines[startLine]?.startOffset ?? 0
 191    }
 192  
 193    getViewportCharEnd(maxVisibleLines?: number): number {
 194      const startLine = this.getViewportStartLine(maxVisibleLines)
 195      const allLines = this.measuredText.getWrappedLines()
 196      if (maxVisibleLines === undefined || maxVisibleLines <= 0)
 197        return this.text.length
 198      const endLine = Math.min(allLines.length, startLine + maxVisibleLines)
 199      if (endLine >= allLines.length) return this.text.length
 200      return allLines[endLine]?.startOffset ?? this.text.length
 201    }
 202  
 203    render(
 204      cursorChar: string,
 205      mask: string,
 206      invert: (text: string) => string,
 207      ghostText?: { text: string; dim: (text: string) => string },
 208      maxVisibleLines?: number,
 209    ) {
 210      const { line, column } = this.getPosition()
 211      const allLines = this.measuredText.getWrappedText()
 212  
 213      const startLine = this.getViewportStartLine(maxVisibleLines)
 214      const endLine =
 215        maxVisibleLines !== undefined && maxVisibleLines > 0
 216          ? Math.min(allLines.length, startLine + maxVisibleLines)
 217          : allLines.length
 218  
 219      return allLines
 220        .slice(startLine, endLine)
 221        .map((text, i) => {
 222          const currentLine = i + startLine
 223          let displayText = text
 224          if (mask) {
 225            const graphemes = Array.from(getGraphemeSegmenter().segment(text))
 226            if (currentLine === allLines.length - 1) {
 227              // Last line: mask all but the trailing 6 chars so the user can
 228              // confirm they pasted the right thing without exposing the full token
 229              const visibleCount = Math.min(6, graphemes.length)
 230              const maskCount = graphemes.length - visibleCount
 231              const splitOffset =
 232                graphemes.length > visibleCount ? graphemes[maskCount]!.index : 0
 233              displayText = mask.repeat(maskCount) + text.slice(splitOffset)
 234            } else {
 235              // Earlier wrapped lines: fully mask. Previously only the last line
 236              // was masked, leaking the start of the token on narrow terminals
 237              // where the pasted OAuth code wraps across multiple lines.
 238              displayText = mask.repeat(graphemes.length)
 239            }
 240          }
 241          // looking for the line with the cursor
 242          if (line !== currentLine) return displayText.trimEnd()
 243  
 244          // Split the line into before/at/after cursor in a single pass over the
 245          // graphemes, accumulating display width until we reach the cursor column.
 246          // This replaces a two-pass approach (displayWidthToStringIndex + a second
 247          // segmenter pass) — the intermediate stringIndex from that approach is
 248          // always a grapheme boundary, so the "cursor in the middle of a
 249          // multi-codepoint character" branch was unreachable.
 250          let beforeCursor = ''
 251          let atCursor = cursorChar
 252          let afterCursor = ''
 253          let currentWidth = 0
 254          let cursorFound = false
 255  
 256          for (const { segment } of getGraphemeSegmenter().segment(displayText)) {
 257            if (cursorFound) {
 258              afterCursor += segment
 259              continue
 260            }
 261            const nextWidth = currentWidth + stringWidth(segment)
 262            if (nextWidth > column) {
 263              atCursor = segment
 264              cursorFound = true
 265            } else {
 266              currentWidth = nextWidth
 267              beforeCursor += segment
 268            }
 269          }
 270  
 271          // Only invert the cursor if we have a cursor character to show
 272          // When ghost text is present and cursor is at end, show first ghost char in cursor
 273          let renderedCursor: string
 274          let ghostSuffix = ''
 275          if (
 276            ghostText &&
 277            currentLine === allLines.length - 1 &&
 278            this.isAtEnd() &&
 279            ghostText.text.length > 0
 280          ) {
 281            // First ghost character goes in the inverted cursor (grapheme-safe)
 282            const firstGhostChar =
 283              firstGrapheme(ghostText.text) || ghostText.text[0]!
 284            renderedCursor = cursorChar ? invert(firstGhostChar) : firstGhostChar
 285            // Rest of ghost text is dimmed after cursor
 286            const ghostRest = ghostText.text.slice(firstGhostChar.length)
 287            if (ghostRest.length > 0) {
 288              ghostSuffix = ghostText.dim(ghostRest)
 289            }
 290          } else {
 291            renderedCursor = cursorChar ? invert(atCursor) : atCursor
 292          }
 293  
 294          return (
 295            beforeCursor + renderedCursor + ghostSuffix + afterCursor.trimEnd()
 296          )
 297        })
 298        .join('\n')
 299    }
 300  
 301    left(): Cursor {
 302      if (this.offset === 0) return this
 303  
 304      const chip = this.imageRefEndingAt(this.offset)
 305      if (chip) return new Cursor(this.measuredText, chip.start)
 306  
 307      const prevOffset = this.measuredText.prevOffset(this.offset)
 308      return new Cursor(this.measuredText, prevOffset)
 309    }
 310  
 311    right(): Cursor {
 312      if (this.offset >= this.text.length) return this
 313  
 314      const chip = this.imageRefStartingAt(this.offset)
 315      if (chip) return new Cursor(this.measuredText, chip.end)
 316  
 317      const nextOffset = this.measuredText.nextOffset(this.offset)
 318      return new Cursor(this.measuredText, Math.min(nextOffset, this.text.length))
 319    }
 320  
 321    /**
 322     * If an [Image #N] chip ends at `offset`, return its bounds. Used by left()
 323     * to hop the cursor over the chip instead of stepping into it.
 324     */
 325    imageRefEndingAt(offset: number): { start: number; end: number } | null {
 326      const m = this.text.slice(0, offset).match(/\[Image #\d+\]$/)
 327      return m ? { start: offset - m[0].length, end: offset } : null
 328    }
 329  
 330    imageRefStartingAt(offset: number): { start: number; end: number } | null {
 331      const m = this.text.slice(offset).match(/^\[Image #\d+\]/)
 332      return m ? { start: offset, end: offset + m[0].length } : null
 333    }
 334  
 335    /**
 336     * If offset lands strictly inside an [Image #N] chip, snap it to the given
 337     * boundary. Used by word-movement methods so Ctrl+W / Alt+D never leave a
 338     * partial chip.
 339     */
 340    snapOutOfImageRef(offset: number, toward: 'start' | 'end'): number {
 341      const re = /\[Image #\d+\]/g
 342      let m
 343      while ((m = re.exec(this.text)) !== null) {
 344        const start = m.index
 345        const end = start + m[0].length
 346        if (offset > start && offset < end) {
 347          return toward === 'start' ? start : end
 348        }
 349      }
 350      return offset
 351    }
 352  
 353    up(): Cursor {
 354      const { line, column } = this.getPosition()
 355      if (line === 0) {
 356        return this
 357      }
 358  
 359      const prevLine = this.measuredText.getWrappedText()[line - 1]
 360      if (prevLine === undefined) {
 361        return this
 362      }
 363  
 364      const prevLineDisplayWidth = stringWidth(prevLine)
 365      if (column > prevLineDisplayWidth) {
 366        const newOffset = this.getOffset({
 367          line: line - 1,
 368          column: prevLineDisplayWidth,
 369        })
 370        return new Cursor(this.measuredText, newOffset, 0)
 371      }
 372  
 373      const newOffset = this.getOffset({ line: line - 1, column })
 374      return new Cursor(this.measuredText, newOffset, 0)
 375    }
 376  
 377    down(): Cursor {
 378      const { line, column } = this.getPosition()
 379      if (line >= this.measuredText.lineCount - 1) {
 380        return this
 381      }
 382  
 383      // If there is no next line, stay on the current line,
 384      // and let the caller handle it (e.g. for prompt input,
 385      // we move to the next history entry)
 386      const nextLine = this.measuredText.getWrappedText()[line + 1]
 387      if (nextLine === undefined) {
 388        return this
 389      }
 390  
 391      // If the current column is past the end of the next line,
 392      // move to the end of the next line
 393      const nextLineDisplayWidth = stringWidth(nextLine)
 394      if (column > nextLineDisplayWidth) {
 395        const newOffset = this.getOffset({
 396          line: line + 1,
 397          column: nextLineDisplayWidth,
 398        })
 399        return new Cursor(this.measuredText, newOffset, 0)
 400      }
 401  
 402      // Otherwise, move to the same column on the next line
 403      const newOffset = this.getOffset({
 404        line: line + 1,
 405        column,
 406      })
 407      return new Cursor(this.measuredText, newOffset, 0)
 408    }
 409  
 410    /**
 411     * Move to the start of the current line (column 0).
 412     * This is the raw version used internally by startOfLine.
 413     */
 414    private startOfCurrentLine(): Cursor {
 415      const { line } = this.getPosition()
 416      return new Cursor(
 417        this.measuredText,
 418        this.getOffset({
 419          line,
 420          column: 0,
 421        }),
 422        0,
 423      )
 424    }
 425  
 426    startOfLine(): Cursor {
 427      const { line, column } = this.getPosition()
 428  
 429      // If already at start of line and not at first line, move to previous line
 430      if (column === 0 && line > 0) {
 431        return new Cursor(
 432          this.measuredText,
 433          this.getOffset({
 434            line: line - 1,
 435            column: 0,
 436          }),
 437          0,
 438        )
 439      }
 440  
 441      return this.startOfCurrentLine()
 442    }
 443  
 444    firstNonBlankInLine(): Cursor {
 445      const { line } = this.getPosition()
 446      const lineText = this.measuredText.getWrappedText()[line] || ''
 447  
 448      const match = lineText.match(/^\s*\S/)
 449      const column = match?.index ? match.index + match[0].length - 1 : 0
 450      const offset = this.getOffset({ line, column })
 451  
 452      return new Cursor(this.measuredText, offset, 0)
 453    }
 454  
 455    endOfLine(): Cursor {
 456      const { line } = this.getPosition()
 457      const column = this.measuredText.getLineLength(line)
 458      const offset = this.getOffset({ line, column })
 459      return new Cursor(this.measuredText, offset, 0)
 460    }
 461  
 462    // Helper methods for finding logical line boundaries
 463    private findLogicalLineStart(fromOffset: number = this.offset): number {
 464      const prevNewline = this.text.lastIndexOf('\n', fromOffset - 1)
 465      return prevNewline === -1 ? 0 : prevNewline + 1
 466    }
 467  
 468    private findLogicalLineEnd(fromOffset: number = this.offset): number {
 469      const nextNewline = this.text.indexOf('\n', fromOffset)
 470      return nextNewline === -1 ? this.text.length : nextNewline
 471    }
 472  
 473    // Helper to get logical line bounds for current position
 474    private getLogicalLineBounds(): { start: number; end: number } {
 475      return {
 476        start: this.findLogicalLineStart(),
 477        end: this.findLogicalLineEnd(),
 478      }
 479    }
 480  
 481    // Helper to create cursor with preserved column, clamped to line length
 482    // Snaps to grapheme boundary to avoid landing mid-grapheme
 483    private createCursorWithColumn(
 484      lineStart: number,
 485      lineEnd: number,
 486      targetColumn: number,
 487    ): Cursor {
 488      const lineLength = lineEnd - lineStart
 489      const clampedColumn = Math.min(targetColumn, lineLength)
 490      const rawOffset = lineStart + clampedColumn
 491      const offset = this.measuredText.snapToGraphemeBoundary(rawOffset)
 492      return new Cursor(this.measuredText, offset, 0)
 493    }
 494  
 495    endOfLogicalLine(): Cursor {
 496      return new Cursor(this.measuredText, this.findLogicalLineEnd(), 0)
 497    }
 498  
 499    startOfLogicalLine(): Cursor {
 500      return new Cursor(this.measuredText, this.findLogicalLineStart(), 0)
 501    }
 502  
 503    firstNonBlankInLogicalLine(): Cursor {
 504      const { start, end } = this.getLogicalLineBounds()
 505      const lineText = this.text.slice(start, end)
 506      const match = lineText.match(/\S/)
 507      const offset = start + (match?.index ?? 0)
 508      return new Cursor(this.measuredText, offset, 0)
 509    }
 510  
 511    upLogicalLine(): Cursor {
 512      const { start: currentStart } = this.getLogicalLineBounds()
 513  
 514      // At first line - stay at beginning
 515      if (currentStart === 0) {
 516        return new Cursor(this.measuredText, 0, 0)
 517      }
 518  
 519      // Calculate target column position
 520      const currentColumn = this.offset - currentStart
 521  
 522      // Find previous line bounds
 523      const prevLineEnd = currentStart - 1
 524      const prevLineStart = this.findLogicalLineStart(prevLineEnd)
 525  
 526      return this.createCursorWithColumn(
 527        prevLineStart,
 528        prevLineEnd,
 529        currentColumn,
 530      )
 531    }
 532  
 533    downLogicalLine(): Cursor {
 534      const { start: currentStart, end: currentEnd } = this.getLogicalLineBounds()
 535  
 536      // At last line - stay at end
 537      if (currentEnd >= this.text.length) {
 538        return new Cursor(this.measuredText, this.text.length, 0)
 539      }
 540  
 541      // Calculate target column position
 542      const currentColumn = this.offset - currentStart
 543  
 544      // Find next line bounds
 545      const nextLineStart = currentEnd + 1
 546      const nextLineEnd = this.findLogicalLineEnd(nextLineStart)
 547  
 548      return this.createCursorWithColumn(
 549        nextLineStart,
 550        nextLineEnd,
 551        currentColumn,
 552      )
 553    }
 554  
 555    // Vim word vs WORD movements:
 556    // - word (lowercase w/b/e): sequences of letters, digits, and underscores
 557    // - WORD (uppercase W/B/E): sequences of non-whitespace characters
 558    // For example, in "hello-world!", word movements see 3 words: "hello", "world", and nothing
 559    // But WORD movements see 1 WORD: "hello-world!"
 560  
 561    nextWord(): Cursor {
 562      if (this.isAtEnd()) {
 563        return this
 564      }
 565  
 566      // Use Intl.Segmenter for proper word boundary detection (including CJK)
 567      const wordBoundaries = this.measuredText.getWordBoundaries()
 568  
 569      // Find the next word start boundary after current position
 570      for (const boundary of wordBoundaries) {
 571        if (boundary.isWordLike && boundary.start > this.offset) {
 572          return new Cursor(this.measuredText, boundary.start)
 573        }
 574      }
 575  
 576      // If no next word found, go to end
 577      return new Cursor(this.measuredText, this.text.length)
 578    }
 579  
 580    endOfWord(): Cursor {
 581      if (this.isAtEnd()) {
 582        return this
 583      }
 584  
 585      // Use Intl.Segmenter for proper word boundary detection (including CJK)
 586      const wordBoundaries = this.measuredText.getWordBoundaries()
 587  
 588      // Find the current word boundary we're in
 589      for (const boundary of wordBoundaries) {
 590        if (!boundary.isWordLike) continue
 591  
 592        // If we're inside this word but NOT at the last character
 593        if (this.offset >= boundary.start && this.offset < boundary.end - 1) {
 594          // Move to end of this word (last character position)
 595          return new Cursor(this.measuredText, boundary.end - 1)
 596        }
 597  
 598        // If we're at the last character of a word (end - 1), find the next word's end
 599        if (this.offset === boundary.end - 1) {
 600          // Find next word
 601          for (const nextBoundary of wordBoundaries) {
 602            if (nextBoundary.isWordLike && nextBoundary.start > this.offset) {
 603              return new Cursor(this.measuredText, nextBoundary.end - 1)
 604            }
 605          }
 606          return this
 607        }
 608      }
 609  
 610      // If not in a word, find the next word and go to its end
 611      for (const boundary of wordBoundaries) {
 612        if (boundary.isWordLike && boundary.start > this.offset) {
 613          return new Cursor(this.measuredText, boundary.end - 1)
 614        }
 615      }
 616  
 617      return this
 618    }
 619  
 620    prevWord(): Cursor {
 621      if (this.isAtStart()) {
 622        return this
 623      }
 624  
 625      // Use Intl.Segmenter for proper word boundary detection (including CJK)
 626      const wordBoundaries = this.measuredText.getWordBoundaries()
 627  
 628      // Find the previous word start boundary before current position
 629      // We need to iterate in reverse to find the previous word
 630      let prevWordStart: number | null = null
 631  
 632      for (const boundary of wordBoundaries) {
 633        if (!boundary.isWordLike) continue
 634  
 635        // If we're at or after the start of this word, but this word starts before us
 636        if (boundary.start < this.offset) {
 637          // If we're inside this word (not at the start), go to its start
 638          if (this.offset > boundary.start && this.offset <= boundary.end) {
 639            return new Cursor(this.measuredText, boundary.start)
 640          }
 641          // Otherwise, remember this as a candidate for previous word
 642          prevWordStart = boundary.start
 643        }
 644      }
 645  
 646      if (prevWordStart !== null) {
 647        return new Cursor(this.measuredText, prevWordStart)
 648      }
 649  
 650      return new Cursor(this.measuredText, 0)
 651    }
 652  
 653    // Vim-specific word methods
 654    // In Vim, a "word" is either:
 655    // 1. A sequence of word characters (letters, digits, underscore) - including Unicode
 656    // 2. A sequence of non-blank, non-word characters (punctuation/symbols)
 657  
 658    nextVimWord(): Cursor {
 659      if (this.isAtEnd()) {
 660        return this
 661      }
 662  
 663      let pos = this.offset
 664      const advance = (p: number): number => this.measuredText.nextOffset(p)
 665  
 666      const currentGrapheme = this.graphemeAt(pos)
 667      if (!currentGrapheme) {
 668        return this
 669      }
 670  
 671      if (isVimWordChar(currentGrapheme)) {
 672        while (pos < this.text.length && isVimWordChar(this.graphemeAt(pos))) {
 673          pos = advance(pos)
 674        }
 675      } else if (isVimPunctuation(currentGrapheme)) {
 676        while (pos < this.text.length && isVimPunctuation(this.graphemeAt(pos))) {
 677          pos = advance(pos)
 678        }
 679      }
 680  
 681      while (
 682        pos < this.text.length &&
 683        WHITESPACE_REGEX.test(this.graphemeAt(pos))
 684      ) {
 685        pos = advance(pos)
 686      }
 687  
 688      return new Cursor(this.measuredText, pos)
 689    }
 690  
 691    endOfVimWord(): Cursor {
 692      if (this.isAtEnd()) {
 693        return this
 694      }
 695  
 696      const text = this.text
 697      let pos = this.offset
 698      const advance = (p: number): number => this.measuredText.nextOffset(p)
 699  
 700      if (this.graphemeAt(pos) === '') {
 701        return this
 702      }
 703  
 704      pos = advance(pos)
 705  
 706      while (pos < text.length && WHITESPACE_REGEX.test(this.graphemeAt(pos))) {
 707        pos = advance(pos)
 708      }
 709  
 710      if (pos >= text.length) {
 711        return new Cursor(this.measuredText, text.length)
 712      }
 713  
 714      const charAtPos = this.graphemeAt(pos)
 715      if (isVimWordChar(charAtPos)) {
 716        while (pos < text.length) {
 717          const nextPos = advance(pos)
 718          if (nextPos >= text.length || !isVimWordChar(this.graphemeAt(nextPos)))
 719            break
 720          pos = nextPos
 721        }
 722      } else if (isVimPunctuation(charAtPos)) {
 723        while (pos < text.length) {
 724          const nextPos = advance(pos)
 725          if (
 726            nextPos >= text.length ||
 727            !isVimPunctuation(this.graphemeAt(nextPos))
 728          )
 729            break
 730          pos = nextPos
 731        }
 732      }
 733  
 734      return new Cursor(this.measuredText, pos)
 735    }
 736  
 737    prevVimWord(): Cursor {
 738      if (this.isAtStart()) {
 739        return this
 740      }
 741  
 742      let pos = this.offset
 743      const retreat = (p: number): number => this.measuredText.prevOffset(p)
 744  
 745      pos = retreat(pos)
 746  
 747      while (pos > 0 && WHITESPACE_REGEX.test(this.graphemeAt(pos))) {
 748        pos = retreat(pos)
 749      }
 750  
 751      // At position 0 with whitespace means no previous word exists, go to start
 752      if (pos === 0 && WHITESPACE_REGEX.test(this.graphemeAt(0))) {
 753        return new Cursor(this.measuredText, 0)
 754      }
 755  
 756      const charAtPos = this.graphemeAt(pos)
 757      if (isVimWordChar(charAtPos)) {
 758        while (pos > 0) {
 759          const prevPos = retreat(pos)
 760          if (!isVimWordChar(this.graphemeAt(prevPos))) break
 761          pos = prevPos
 762        }
 763      } else if (isVimPunctuation(charAtPos)) {
 764        while (pos > 0) {
 765          const prevPos = retreat(pos)
 766          if (!isVimPunctuation(this.graphemeAt(prevPos))) break
 767          pos = prevPos
 768        }
 769      }
 770  
 771      return new Cursor(this.measuredText, pos)
 772    }
 773  
 774    nextWORD(): Cursor {
 775      // eslint-disable-next-line @typescript-eslint/no-this-alias
 776      let nextCursor: Cursor = this
 777      // If we're on a non-whitespace character, move to the next whitespace
 778      while (!nextCursor.isOverWhitespace() && !nextCursor.isAtEnd()) {
 779        nextCursor = nextCursor.right()
 780      }
 781      // now move to the next non-whitespace character
 782      while (nextCursor.isOverWhitespace() && !nextCursor.isAtEnd()) {
 783        nextCursor = nextCursor.right()
 784      }
 785      return nextCursor
 786    }
 787  
 788    endOfWORD(): Cursor {
 789      if (this.isAtEnd()) {
 790        return this
 791      }
 792  
 793      // eslint-disable-next-line @typescript-eslint/no-this-alias
 794      let cursor: Cursor = this
 795  
 796      // Check if we're already at the end of a WORD
 797      // (current character is non-whitespace, but next character is whitespace or we're at the end)
 798      const atEndOfWORD =
 799        !cursor.isOverWhitespace() &&
 800        (cursor.right().isOverWhitespace() || cursor.right().isAtEnd())
 801  
 802      if (atEndOfWORD) {
 803        // We're already at the end of a WORD, move to the next WORD
 804        cursor = cursor.right()
 805        return cursor.endOfWORD()
 806      }
 807  
 808      // If we're on a whitespace character, find the next WORD
 809      if (cursor.isOverWhitespace()) {
 810        cursor = cursor.nextWORD()
 811      }
 812  
 813      // Now move to the end of the current WORD
 814      while (!cursor.right().isOverWhitespace() && !cursor.isAtEnd()) {
 815        cursor = cursor.right()
 816      }
 817  
 818      return cursor
 819    }
 820  
 821    prevWORD(): Cursor {
 822      // eslint-disable-next-line @typescript-eslint/no-this-alias
 823      let cursor: Cursor = this
 824  
 825      // if we are already at the beginning of a WORD, step off it
 826      if (cursor.left().isOverWhitespace()) {
 827        cursor = cursor.left()
 828      }
 829  
 830      // Move left over any whitespace characters
 831      while (cursor.isOverWhitespace() && !cursor.isAtStart()) {
 832        cursor = cursor.left()
 833      }
 834  
 835      // If we're over a non-whitespace character, move to the start of this WORD
 836      if (!cursor.isOverWhitespace()) {
 837        while (!cursor.left().isOverWhitespace() && !cursor.isAtStart()) {
 838          cursor = cursor.left()
 839        }
 840      }
 841  
 842      return cursor
 843    }
 844  
 845    modifyText(end: Cursor, insertString: string = ''): Cursor {
 846      const startOffset = this.offset
 847      const endOffset = end.offset
 848  
 849      const newText =
 850        this.text.slice(0, startOffset) +
 851        insertString +
 852        this.text.slice(endOffset)
 853  
 854      return Cursor.fromText(
 855        newText,
 856        this.columns,
 857        startOffset + insertString.normalize('NFC').length,
 858      )
 859    }
 860  
 861    insert(insertString: string): Cursor {
 862      const newCursor = this.modifyText(this, insertString)
 863      return newCursor
 864    }
 865  
 866    del(): Cursor {
 867      if (this.isAtEnd()) {
 868        return this
 869      }
 870      return this.modifyText(this.right())
 871    }
 872  
 873    backspace(): Cursor {
 874      if (this.isAtStart()) {
 875        return this
 876      }
 877      return this.left().modifyText(this)
 878    }
 879  
 880    deleteToLineStart(): { cursor: Cursor; killed: string } {
 881      // If cursor is right after a newline (at start of line), delete just that
 882      // newline — symmetric with deleteToLineEnd's newline handling. This lets
 883      // repeated ctrl+u clear across lines.
 884      if (this.offset > 0 && this.text[this.offset - 1] === '\n') {
 885        return { cursor: this.left().modifyText(this), killed: '\n' }
 886      }
 887  
 888      // Use startOfLine() so that at column 0 of a wrapped visual line,
 889      // the cursor moves to the previous visual line's start instead of
 890      // getting stuck.
 891      const startCursor = this.startOfLine()
 892      const killed = this.text.slice(startCursor.offset, this.offset)
 893      return { cursor: startCursor.modifyText(this), killed }
 894    }
 895  
 896    deleteToLineEnd(): { cursor: Cursor; killed: string } {
 897      // If cursor is on a newline character, delete just that character
 898      if (this.text[this.offset] === '\n') {
 899        return { cursor: this.modifyText(this.right()), killed: '\n' }
 900      }
 901  
 902      const endCursor = this.endOfLine()
 903      const killed = this.text.slice(this.offset, endCursor.offset)
 904      return { cursor: this.modifyText(endCursor), killed }
 905    }
 906  
 907    deleteToLogicalLineEnd(): Cursor {
 908      // If cursor is on a newline character, delete just that character
 909      if (this.text[this.offset] === '\n') {
 910        return this.modifyText(this.right())
 911      }
 912  
 913      return this.modifyText(this.endOfLogicalLine())
 914    }
 915  
 916    deleteWordBefore(): { cursor: Cursor; killed: string } {
 917      if (this.isAtStart()) {
 918        return { cursor: this, killed: '' }
 919      }
 920      const target = this.snapOutOfImageRef(this.prevWord().offset, 'start')
 921      const prevWordCursor = new Cursor(this.measuredText, target)
 922      const killed = this.text.slice(prevWordCursor.offset, this.offset)
 923      return { cursor: prevWordCursor.modifyText(this), killed }
 924    }
 925  
 926    /**
 927     * Deletes a token before the cursor if one exists.
 928     * Supports pasted text refs: [Pasted text #1], [Pasted text #1 +10 lines],
 929     * [...Truncated text #1 +10 lines...]
 930     *
 931     * Note: @mentions are NOT tokenized since users may want to correct typos
 932     * in file paths. Use Ctrl/Cmd+backspace for word-deletion on mentions.
 933     *
 934     * Returns null if no token found at cursor position.
 935     * Only triggers when cursor is at end of token (followed by whitespace or EOL).
 936     */
 937    deleteTokenBefore(): Cursor | null {
 938      // Cursor at chip.start is the "selected" state — backspace deletes the
 939      // chip forward, not the char before it.
 940      const chipAfter = this.imageRefStartingAt(this.offset)
 941      if (chipAfter) {
 942        const end =
 943          this.text[chipAfter.end] === ' ' ? chipAfter.end + 1 : chipAfter.end
 944        return this.modifyText(new Cursor(this.measuredText, end))
 945      }
 946  
 947      if (this.isAtStart()) {
 948        return null
 949      }
 950  
 951      // Only trigger if cursor is at a word boundary (whitespace or end of string after cursor)
 952      const charAfter = this.text[this.offset]
 953      if (charAfter !== undefined && !/\s/.test(charAfter)) {
 954        return null
 955      }
 956  
 957      const textBefore = this.text.slice(0, this.offset)
 958  
 959      // Check for pasted/truncated text refs: [Pasted text #1] or [...Truncated text #1 +50 lines...]
 960      const pasteMatch = textBefore.match(
 961        /(^|\s)\[(Pasted text #\d+(?: \+\d+ lines)?|Image #\d+|\.\.\.Truncated text #\d+ \+\d+ lines\.\.\.)\]$/,
 962      )
 963      if (pasteMatch) {
 964        const matchStart = pasteMatch.index! + pasteMatch[1]!.length
 965        return new Cursor(this.measuredText, matchStart).modifyText(this)
 966      }
 967  
 968      return null
 969    }
 970  
 971    deleteWordAfter(): Cursor {
 972      if (this.isAtEnd()) {
 973        return this
 974      }
 975  
 976      const target = this.snapOutOfImageRef(this.nextWord().offset, 'end')
 977      return this.modifyText(new Cursor(this.measuredText, target))
 978    }
 979  
 980    private graphemeAt(pos: number): string {
 981      if (pos >= this.text.length) return ''
 982      const nextOff = this.measuredText.nextOffset(pos)
 983      return this.text.slice(pos, nextOff)
 984    }
 985  
 986    private isOverWhitespace(): boolean {
 987      const currentChar = this.text[this.offset] ?? ''
 988      return /\s/.test(currentChar)
 989    }
 990  
 991    equals(other: Cursor): boolean {
 992      return (
 993        this.offset === other.offset && this.measuredText === other.measuredText
 994      )
 995    }
 996  
 997    isAtStart(): boolean {
 998      return this.offset === 0
 999    }
1000    isAtEnd(): boolean {
1001      return this.offset >= this.text.length
1002    }
1003  
1004    startOfFirstLine(): Cursor {
1005      // Go to the very beginning of the text (first character of first line)
1006      return new Cursor(this.measuredText, 0, 0)
1007    }
1008  
1009    startOfLastLine(): Cursor {
1010      // Go to the beginning of the last line
1011      const lastNewlineIndex = this.text.lastIndexOf('\n')
1012  
1013      if (lastNewlineIndex === -1) {
1014        // If there are no newlines, the text is a single line
1015        return this.startOfLine()
1016      }
1017  
1018      // Position after the last newline character
1019      return new Cursor(this.measuredText, lastNewlineIndex + 1, 0)
1020    }
1021  
1022    goToLine(lineNumber: number): Cursor {
1023      // Go to the beginning of the specified logical line (1-indexed, like vim)
1024      // Uses logical lines (separated by \n), not wrapped display lines
1025      const lines = this.text.split('\n')
1026      const targetLine = Math.min(Math.max(0, lineNumber - 1), lines.length - 1)
1027      let offset = 0
1028      for (let i = 0; i < targetLine; i++) {
1029        offset += (lines[i]?.length ?? 0) + 1 // +1 for newline
1030      }
1031      return new Cursor(this.measuredText, offset, 0)
1032    }
1033  
1034    endOfFile(): Cursor {
1035      return new Cursor(this.measuredText, this.text.length, 0)
1036    }
1037  
1038    public get text(): string {
1039      return this.measuredText.text
1040    }
1041  
1042    private get columns(): number {
1043      return this.measuredText.columns + 1
1044    }
1045  
1046    getPosition(): Position {
1047      return this.measuredText.getPositionFromOffset(this.offset)
1048    }
1049  
1050    private getOffset(position: Position): number {
1051      return this.measuredText.getOffsetFromPosition(position)
1052    }
1053  
1054    /**
1055     * Find a character using vim f/F/t/T semantics.
1056     *
1057     * @param char - The character to find
1058     * @param type - 'f' (forward to), 'F' (backward to), 't' (forward till), 'T' (backward till)
1059     * @param count - Find the Nth occurrence
1060     * @returns The target offset, or null if not found
1061     */
1062    findCharacter(
1063      char: string,
1064      type: 'f' | 'F' | 't' | 'T',
1065      count: number = 1,
1066    ): number | null {
1067      const text = this.text
1068      const forward = type === 'f' || type === 't'
1069      const till = type === 't' || type === 'T'
1070      let found = 0
1071  
1072      if (forward) {
1073        let pos = this.measuredText.nextOffset(this.offset)
1074        while (pos < text.length) {
1075          const grapheme = this.graphemeAt(pos)
1076          if (grapheme === char) {
1077            found++
1078            if (found === count) {
1079              return till
1080                ? Math.max(this.offset, this.measuredText.prevOffset(pos))
1081                : pos
1082            }
1083          }
1084          pos = this.measuredText.nextOffset(pos)
1085        }
1086      } else {
1087        if (this.offset === 0) return null
1088        let pos = this.measuredText.prevOffset(this.offset)
1089        while (pos >= 0) {
1090          const grapheme = this.graphemeAt(pos)
1091          if (grapheme === char) {
1092            found++
1093            if (found === count) {
1094              return till
1095                ? Math.min(this.offset, this.measuredText.nextOffset(pos))
1096                : pos
1097            }
1098          }
1099          if (pos === 0) break
1100          pos = this.measuredText.prevOffset(pos)
1101        }
1102      }
1103  
1104      return null
1105    }
1106  }
1107  
1108  class WrappedLine {
1109    constructor(
1110      public readonly text: string,
1111      public readonly startOffset: number,
1112      public readonly isPrecededByNewline: boolean,
1113      public readonly endsWithNewline: boolean = false,
1114    ) {}
1115  
1116    equals(other: WrappedLine): boolean {
1117      return this.text === other.text && this.startOffset === other.startOffset
1118    }
1119  
1120    get length(): number {
1121      return this.text.length + (this.endsWithNewline ? 1 : 0)
1122    }
1123  }
1124  
1125  export class MeasuredText {
1126    private _wrappedLines?: WrappedLine[]
1127    public readonly text: string
1128    private navigationCache: Map<string, number>
1129    private graphemeBoundaries?: number[]
1130  
1131    constructor(
1132      text: string,
1133      readonly columns: number,
1134    ) {
1135      this.text = text.normalize('NFC')
1136      this.navigationCache = new Map()
1137    }
1138  
1139    /**
1140     * Lazily computes and caches wrapped lines.
1141     * This expensive operation is deferred until actually needed.
1142     */
1143    private get wrappedLines(): WrappedLine[] {
1144      if (!this._wrappedLines) {
1145        this._wrappedLines = this.measureWrappedText()
1146      }
1147      return this._wrappedLines
1148    }
1149  
1150    private getGraphemeBoundaries(): number[] {
1151      if (!this.graphemeBoundaries) {
1152        this.graphemeBoundaries = []
1153        for (const { index } of getGraphemeSegmenter().segment(this.text)) {
1154          this.graphemeBoundaries.push(index)
1155        }
1156        // Add the end of text as a boundary
1157        this.graphemeBoundaries.push(this.text.length)
1158      }
1159      return this.graphemeBoundaries
1160    }
1161  
1162    private wordBoundariesCache?: Array<{
1163      start: number
1164      end: number
1165      isWordLike: boolean
1166    }>
1167  
1168    /**
1169     * Get word boundaries using Intl.Segmenter for proper Unicode word segmentation.
1170     * This correctly handles CJK (Chinese, Japanese, Korean) text where each character
1171     * is typically its own word, as well as scripts that use spaces between words.
1172     */
1173    public getWordBoundaries(): Array<{
1174      start: number
1175      end: number
1176      isWordLike: boolean
1177    }> {
1178      if (!this.wordBoundariesCache) {
1179        this.wordBoundariesCache = []
1180        for (const segment of getWordSegmenter().segment(this.text)) {
1181          this.wordBoundariesCache.push({
1182            start: segment.index,
1183            end: segment.index + segment.segment.length,
1184            isWordLike: segment.isWordLike ?? false,
1185          })
1186        }
1187      }
1188      return this.wordBoundariesCache
1189    }
1190  
1191    /**
1192     * Binary search for boundaries.
1193     * @param boundaries: Sorted array of boundaries
1194     * @param target: Target offset
1195     * @param findNext: If true, finds first boundary > target. If false, finds last boundary < target.
1196     * @returns The found boundary index, or appropriate default
1197     */
1198    private binarySearchBoundary(
1199      boundaries: number[],
1200      target: number,
1201      findNext: boolean,
1202    ): number {
1203      let left = 0
1204      let right = boundaries.length - 1
1205      let result = findNext ? this.text.length : 0
1206  
1207      while (left <= right) {
1208        const mid = Math.floor((left + right) / 2)
1209        const boundary = boundaries[mid]
1210        if (boundary === undefined) break
1211  
1212        if (findNext) {
1213          if (boundary > target) {
1214            result = boundary
1215            right = mid - 1
1216          } else {
1217            left = mid + 1
1218          }
1219        } else {
1220          if (boundary < target) {
1221            result = boundary
1222            left = mid + 1
1223          } else {
1224            right = mid - 1
1225          }
1226        }
1227      }
1228  
1229      return result
1230    }
1231  
1232    // Convert string index to display width
1233    public stringIndexToDisplayWidth(text: string, index: number): number {
1234      if (index <= 0) return 0
1235      if (index >= text.length) return stringWidth(text)
1236      return stringWidth(text.substring(0, index))
1237    }
1238  
1239    // Convert display width to string index
1240    public displayWidthToStringIndex(text: string, targetWidth: number): number {
1241      if (targetWidth <= 0) return 0
1242      if (!text) return 0
1243  
1244      // If the text matches our text, use the precomputed graphemes
1245      if (text === this.text) {
1246        return this.offsetAtDisplayWidth(targetWidth)
1247      }
1248  
1249      // Otherwise compute on the fly
1250      let currentWidth = 0
1251      let currentOffset = 0
1252  
1253      for (const { segment, index } of getGraphemeSegmenter().segment(text)) {
1254        const segmentWidth = stringWidth(segment)
1255  
1256        if (currentWidth + segmentWidth > targetWidth) {
1257          break
1258        }
1259  
1260        currentWidth += segmentWidth
1261        currentOffset = index + segment.length
1262      }
1263  
1264      return currentOffset
1265    }
1266  
1267    /**
1268     * Find the string offset that corresponds to a target display width.
1269     */
1270    private offsetAtDisplayWidth(targetWidth: number): number {
1271      if (targetWidth <= 0) return 0
1272  
1273      let currentWidth = 0
1274      const boundaries = this.getGraphemeBoundaries()
1275  
1276      // Iterate through grapheme boundaries
1277      for (let i = 0; i < boundaries.length - 1; i++) {
1278        const start = boundaries[i]
1279        const end = boundaries[i + 1]
1280        if (start === undefined || end === undefined) continue
1281        const segment = this.text.substring(start, end)
1282        const segmentWidth = stringWidth(segment)
1283  
1284        if (currentWidth + segmentWidth > targetWidth) {
1285          return start
1286        }
1287        currentWidth += segmentWidth
1288      }
1289  
1290      return this.text.length
1291    }
1292  
1293    private measureWrappedText(): WrappedLine[] {
1294      const wrappedText = wrapAnsi(this.text, this.columns, {
1295        hard: true,
1296        trim: false,
1297      })
1298  
1299      const wrappedLines: WrappedLine[] = []
1300      let searchOffset = 0
1301      let lastNewLinePos = -1
1302  
1303      const lines = wrappedText.split('\n')
1304      for (let i = 0; i < lines.length; i++) {
1305        const text = lines[i]!
1306        const isPrecededByNewline = (startOffset: number) =>
1307          i === 0 || (startOffset > 0 && this.text[startOffset - 1] === '\n')
1308  
1309        if (text.length === 0) {
1310          // For blank lines, find the next newline character after the last one
1311          lastNewLinePos = this.text.indexOf('\n', lastNewLinePos + 1)
1312  
1313          if (lastNewLinePos !== -1) {
1314            const startOffset = lastNewLinePos
1315            const endsWithNewline = true
1316  
1317            wrappedLines.push(
1318              new WrappedLine(
1319                text,
1320                startOffset,
1321                isPrecededByNewline(startOffset),
1322                endsWithNewline,
1323              ),
1324            )
1325          } else {
1326            // If we can't find another newline, this must be the end of text
1327            const startOffset = this.text.length
1328            wrappedLines.push(
1329              new WrappedLine(
1330                text,
1331                startOffset,
1332                isPrecededByNewline(startOffset),
1333                false,
1334              ),
1335            )
1336          }
1337        } else {
1338          // For non-blank lines, find the text in this.text
1339          const startOffset = this.text.indexOf(text, searchOffset)
1340  
1341          if (startOffset === -1) {
1342            throw new Error('Failed to find wrapped line in text')
1343          }
1344  
1345          searchOffset = startOffset + text.length
1346  
1347          // Check if this line ends with a newline in this.text
1348          const potentialNewlinePos = startOffset + text.length
1349          const endsWithNewline =
1350            potentialNewlinePos < this.text.length &&
1351            this.text[potentialNewlinePos] === '\n'
1352  
1353          if (endsWithNewline) {
1354            lastNewLinePos = potentialNewlinePos
1355          }
1356  
1357          wrappedLines.push(
1358            new WrappedLine(
1359              text,
1360              startOffset,
1361              isPrecededByNewline(startOffset),
1362              endsWithNewline,
1363            ),
1364          )
1365        }
1366      }
1367  
1368      return wrappedLines
1369    }
1370  
1371    public getWrappedText(): WrappedText {
1372      return this.wrappedLines.map(line =>
1373        line.isPrecededByNewline ? line.text : line.text.trimStart(),
1374      )
1375    }
1376  
1377    public getWrappedLines(): WrappedLine[] {
1378      return this.wrappedLines
1379    }
1380  
1381    private getLine(line: number): WrappedLine {
1382      const lines = this.wrappedLines
1383      return lines[Math.max(0, Math.min(line, lines.length - 1))]!
1384    }
1385  
1386    public getOffsetFromPosition(position: Position): number {
1387      const wrappedLine = this.getLine(position.line)
1388  
1389      // Handle blank lines specially
1390      if (wrappedLine.text.length === 0 && wrappedLine.endsWithNewline) {
1391        return wrappedLine.startOffset
1392      }
1393  
1394      // Account for leading whitespace
1395      const leadingWhitespace = wrappedLine.isPrecededByNewline
1396        ? 0
1397        : wrappedLine.text.length - wrappedLine.text.trimStart().length
1398  
1399      // Convert display column to string index
1400      const displayColumnWithLeading = position.column + leadingWhitespace
1401      const stringIndex = this.displayWidthToStringIndex(
1402        wrappedLine.text,
1403        displayColumnWithLeading,
1404      )
1405  
1406      // Calculate the actual offset
1407      const offset = wrappedLine.startOffset + stringIndex
1408  
1409      // For normal lines
1410      const lineEnd = wrappedLine.startOffset + wrappedLine.text.length
1411  
1412      // Don't allow going past the end of the current line into the next line
1413      // unless we're at the very end of the text
1414      let maxOffset = lineEnd
1415      const lineDisplayWidth = stringWidth(wrappedLine.text)
1416      if (wrappedLine.endsWithNewline && position.column > lineDisplayWidth) {
1417        // Allow positioning after the newline
1418        maxOffset = lineEnd + 1
1419      }
1420  
1421      return Math.min(offset, maxOffset)
1422    }
1423  
1424    public getLineLength(line: number): number {
1425      const wrappedLine = this.getLine(line)
1426      return stringWidth(wrappedLine.text)
1427    }
1428  
1429    public getPositionFromOffset(offset: number): Position {
1430      const lines = this.wrappedLines
1431      for (let line = 0; line < lines.length; line++) {
1432        const currentLine = lines[line]!
1433        const nextLine = lines[line + 1]
1434        if (
1435          offset >= currentLine.startOffset &&
1436          (!nextLine || offset < nextLine.startOffset)
1437        ) {
1438          // Calculate string position within the line
1439          const stringPosInLine = offset - currentLine.startOffset
1440  
1441          // Handle leading whitespace for wrapped lines
1442          let displayColumn: number
1443          if (currentLine.isPrecededByNewline) {
1444            // For lines preceded by newline, calculate display width directly
1445            displayColumn = this.stringIndexToDisplayWidth(
1446              currentLine.text,
1447              stringPosInLine,
1448            )
1449          } else {
1450            // For wrapped lines, we need to account for trimmed whitespace
1451            const leadingWhitespace =
1452              currentLine.text.length - currentLine.text.trimStart().length
1453            if (stringPosInLine < leadingWhitespace) {
1454              // Cursor is in the trimmed whitespace area, position at start
1455              displayColumn = 0
1456            } else {
1457              // Calculate display width from the trimmed text
1458              const trimmedText = currentLine.text.trimStart()
1459              const posInTrimmed = stringPosInLine - leadingWhitespace
1460              displayColumn = this.stringIndexToDisplayWidth(
1461                trimmedText,
1462                posInTrimmed,
1463              )
1464            }
1465          }
1466  
1467          return {
1468            line,
1469            column: Math.max(0, displayColumn),
1470          }
1471        }
1472      }
1473  
1474      // If we're past the last character, return the end of the last line
1475      const line = lines.length - 1
1476      const lastLine = this.wrappedLines[line]!
1477      return {
1478        line,
1479        column: stringWidth(lastLine.text),
1480      }
1481    }
1482  
1483    public get lineCount(): number {
1484      return this.wrappedLines.length
1485    }
1486  
1487    private withCache<T>(key: string, compute: () => T): T {
1488      const cached = this.navigationCache.get(key)
1489      if (cached !== undefined) return cached as T
1490  
1491      const result = compute()
1492      this.navigationCache.set(key, result as number)
1493      return result
1494    }
1495  
1496    nextOffset(offset: number): number {
1497      return this.withCache(`next:${offset}`, () => {
1498        const boundaries = this.getGraphemeBoundaries()
1499        return this.binarySearchBoundary(boundaries, offset, true)
1500      })
1501    }
1502  
1503    prevOffset(offset: number): number {
1504      if (offset <= 0) return 0
1505  
1506      return this.withCache(`prev:${offset}`, () => {
1507        const boundaries = this.getGraphemeBoundaries()
1508        return this.binarySearchBoundary(boundaries, offset, false)
1509      })
1510    }
1511  
1512    /**
1513     * Snap an arbitrary code-unit offset to the start of the containing grapheme.
1514     * If offset is already on a boundary, returns it unchanged.
1515     */
1516    snapToGraphemeBoundary(offset: number): number {
1517      if (offset <= 0) return 0
1518      if (offset >= this.text.length) return this.text.length
1519      const boundaries = this.getGraphemeBoundaries()
1520      // Binary search for largest boundary <= offset
1521      let lo = 0
1522      let hi = boundaries.length - 1
1523      while (lo < hi) {
1524        const mid = (lo + hi + 1) >> 1
1525        if (boundaries[mid]! <= offset) lo = mid
1526        else hi = mid - 1
1527      }
1528      return boundaries[lo]!
1529    }
1530  }