Filehigh importancesource

Cursor.ts

utils/Cursor.ts

No strong subsystem tag
1531
Lines
46663
Bytes
18
Exports
3
Imports
10
Keywords

What this is

This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.

Beginner explanation

This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.

How it is used

Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.

Expert explanation

Architecturally, this file intersects with general runtime concerns. It contains 1531 lines, 3 detected imports, and 18 detected exports.

Important relationships

Detected exports

  • pushToKillRing
  • getLastKill
  • getKillRingItem
  • getKillRingSize
  • clearKillRing
  • resetKillAccumulation
  • recordYank
  • canYankPop
  • yankPop
  • updateYankLength
  • resetYankState
  • VIM_WORD_CHAR_REGEX
  • WHITESPACE_REGEX
  • isVimWordChar
  • isVimWhitespace
  • isVimPunctuation
  • Cursor
  • MeasuredText

Keywords

textcursoroffsetlengthlinemeasuredtextstartboundarywordcolumn

Detected imports

  • ../ink/stringWidth.js
  • ../ink/wrapAnsi.js
  • ./intl.js

Source notes

This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.

Open parent directory

Full source

import { stringWidth } from '../ink/stringWidth.js'
import { wrapAnsi } from '../ink/wrapAnsi.js'
import {
  firstGrapheme,
  getGraphemeSegmenter,
  getWordSegmenter,
} from './intl.js'

/**
 * Kill ring for storing killed (cut) text that can be yanked (pasted) with Ctrl+Y.
 * This is global state that shares one kill ring across all input fields.
 *
 * Consecutive kills accumulate in the kill ring until the user types some
 * other key. Alt+Y cycles through previous kills after a yank.
 */
const KILL_RING_MAX_SIZE = 10
let killRing: string[] = []
let killRingIndex = 0
let lastActionWasKill = false

// Track yank state for yank-pop (alt-y)
let lastYankStart = 0
let lastYankLength = 0
let lastActionWasYank = false

export function pushToKillRing(
  text: string,
  direction: 'prepend' | 'append' = 'append',
): void {
  if (text.length > 0) {
    if (lastActionWasKill && killRing.length > 0) {
      // Accumulate with the most recent kill
      if (direction === 'prepend') {
        killRing[0] = text + killRing[0]
      } else {
        killRing[0] = killRing[0] + text
      }
    } else {
      // Add new entry to front of ring
      killRing.unshift(text)
      if (killRing.length > KILL_RING_MAX_SIZE) {
        killRing.pop()
      }
    }
    lastActionWasKill = true
    // Reset yank state when killing new text
    lastActionWasYank = false
  }
}

export function getLastKill(): string {
  return killRing[0] ?? ''
}

export function getKillRingItem(index: number): string {
  if (killRing.length === 0) return ''
  const normalizedIndex =
    ((index % killRing.length) + killRing.length) % killRing.length
  return killRing[normalizedIndex] ?? ''
}

export function getKillRingSize(): number {
  return killRing.length
}

export function clearKillRing(): void {
  killRing = []
  killRingIndex = 0
  lastActionWasKill = false
  lastActionWasYank = false
  lastYankStart = 0
  lastYankLength = 0
}

export function resetKillAccumulation(): void {
  lastActionWasKill = false
}

// Yank tracking for yank-pop
export function recordYank(start: number, length: number): void {
  lastYankStart = start
  lastYankLength = length
  lastActionWasYank = true
  killRingIndex = 0
}

export function canYankPop(): boolean {
  return lastActionWasYank && killRing.length > 1
}

export function yankPop(): {
  text: string
  start: number
  length: number
} | null {
  if (!lastActionWasYank || killRing.length <= 1) {
    return null
  }
  // Cycle to next item in kill ring
  killRingIndex = (killRingIndex + 1) % killRing.length
  const text = killRing[killRingIndex] ?? ''
  return { text, start: lastYankStart, length: lastYankLength }
}

export function updateYankLength(length: number): void {
  lastYankLength = length
}

export function resetYankState(): void {
  lastActionWasYank = false
}

/**
 * Text Processing Flow for Unicode Normalization:
 *
 * User Input (raw text, potentially mixed NFD/NFC)
 *     ↓
 * MeasuredText (normalizes to NFC + builds grapheme info)
 *     ↓
 * All cursor operations use normalized text/offsets
 *     ↓
 * Display uses normalized text from wrappedLines
 *
 * This flow ensures consistent Unicode handling:
 * - NFD/NFC normalization differences don't break cursor movement
 * - Grapheme clusters (like 👨‍👩‍👧‍👦) are treated as single units
 * - Display width calculations are accurate for CJK characters
 *
 * RULE: Once text enters MeasuredText, all operations
 * work on the normalized version.
 */

// Pre-compiled regex patterns for Vim word detection (avoid creating in hot loops)
export const VIM_WORD_CHAR_REGEX = /^[\p{L}\p{N}\p{M}_]$/u
export const WHITESPACE_REGEX = /\s/

// Exported helper functions for Vim character classification
export const isVimWordChar = (ch: string): boolean =>
  VIM_WORD_CHAR_REGEX.test(ch)
export const isVimWhitespace = (ch: string): boolean =>
  WHITESPACE_REGEX.test(ch)
export const isVimPunctuation = (ch: string): boolean =>
  ch.length > 0 && !isVimWhitespace(ch) && !isVimWordChar(ch)

type WrappedText = string[]
type Position = {
  line: number
  column: number
}

export class Cursor {
  readonly offset: number
  constructor(
    readonly measuredText: MeasuredText,
    offset: number = 0,
    readonly selection: number = 0,
  ) {
    // it's ok for the cursor to be 1 char beyond the end of the string
    this.offset = Math.max(0, Math.min(this.text.length, offset))
  }

  static fromText(
    text: string,
    columns: number,
    offset: number = 0,
    selection: number = 0,
  ): Cursor {
    // make MeasuredText on less than columns width, to account for cursor
    return new Cursor(new MeasuredText(text, columns - 1), offset, selection)
  }

  getViewportStartLine(maxVisibleLines?: number): number {
    if (maxVisibleLines === undefined || maxVisibleLines <= 0) return 0
    const { line } = this.getPosition()
    const allLines = this.measuredText.getWrappedText()
    if (allLines.length <= maxVisibleLines) return 0
    const half = Math.floor(maxVisibleLines / 2)
    let startLine = Math.max(0, line - half)
    const endLine = Math.min(allLines.length, startLine + maxVisibleLines)
    if (endLine - startLine < maxVisibleLines) {
      startLine = Math.max(0, endLine - maxVisibleLines)
    }
    return startLine
  }

  getViewportCharOffset(maxVisibleLines?: number): number {
    const startLine = this.getViewportStartLine(maxVisibleLines)
    if (startLine === 0) return 0
    const wrappedLines = this.measuredText.getWrappedLines()
    return wrappedLines[startLine]?.startOffset ?? 0
  }

  getViewportCharEnd(maxVisibleLines?: number): number {
    const startLine = this.getViewportStartLine(maxVisibleLines)
    const allLines = this.measuredText.getWrappedLines()
    if (maxVisibleLines === undefined || maxVisibleLines <= 0)
      return this.text.length
    const endLine = Math.min(allLines.length, startLine + maxVisibleLines)
    if (endLine >= allLines.length) return this.text.length
    return allLines[endLine]?.startOffset ?? this.text.length
  }

  render(
    cursorChar: string,
    mask: string,
    invert: (text: string) => string,
    ghostText?: { text: string; dim: (text: string) => string },
    maxVisibleLines?: number,
  ) {
    const { line, column } = this.getPosition()
    const allLines = this.measuredText.getWrappedText()

    const startLine = this.getViewportStartLine(maxVisibleLines)
    const endLine =
      maxVisibleLines !== undefined && maxVisibleLines > 0
        ? Math.min(allLines.length, startLine + maxVisibleLines)
        : allLines.length

    return allLines
      .slice(startLine, endLine)
      .map((text, i) => {
        const currentLine = i + startLine
        let displayText = text
        if (mask) {
          const graphemes = Array.from(getGraphemeSegmenter().segment(text))
          if (currentLine === allLines.length - 1) {
            // Last line: mask all but the trailing 6 chars so the user can
            // confirm they pasted the right thing without exposing the full token
            const visibleCount = Math.min(6, graphemes.length)
            const maskCount = graphemes.length - visibleCount
            const splitOffset =
              graphemes.length > visibleCount ? graphemes[maskCount]!.index : 0
            displayText = mask.repeat(maskCount) + text.slice(splitOffset)
          } else {
            // Earlier wrapped lines: fully mask. Previously only the last line
            // was masked, leaking the start of the token on narrow terminals
            // where the pasted OAuth code wraps across multiple lines.
            displayText = mask.repeat(graphemes.length)
          }
        }
        // looking for the line with the cursor
        if (line !== currentLine) return displayText.trimEnd()

        // Split the line into before/at/after cursor in a single pass over the
        // graphemes, accumulating display width until we reach the cursor column.
        // This replaces a two-pass approach (displayWidthToStringIndex + a second
        // segmenter pass) — the intermediate stringIndex from that approach is
        // always a grapheme boundary, so the "cursor in the middle of a
        // multi-codepoint character" branch was unreachable.
        let beforeCursor = ''
        let atCursor = cursorChar
        let afterCursor = ''
        let currentWidth = 0
        let cursorFound = false

        for (const { segment } of getGraphemeSegmenter().segment(displayText)) {
          if (cursorFound) {
            afterCursor += segment
            continue
          }
          const nextWidth = currentWidth + stringWidth(segment)
          if (nextWidth > column) {
            atCursor = segment
            cursorFound = true
          } else {
            currentWidth = nextWidth
            beforeCursor += segment
          }
        }

        // Only invert the cursor if we have a cursor character to show
        // When ghost text is present and cursor is at end, show first ghost char in cursor
        let renderedCursor: string
        let ghostSuffix = ''
        if (
          ghostText &&
          currentLine === allLines.length - 1 &&
          this.isAtEnd() &&
          ghostText.text.length > 0
        ) {
          // First ghost character goes in the inverted cursor (grapheme-safe)
          const firstGhostChar =
            firstGrapheme(ghostText.text) || ghostText.text[0]!
          renderedCursor = cursorChar ? invert(firstGhostChar) : firstGhostChar
          // Rest of ghost text is dimmed after cursor
          const ghostRest = ghostText.text.slice(firstGhostChar.length)
          if (ghostRest.length > 0) {
            ghostSuffix = ghostText.dim(ghostRest)
          }
        } else {
          renderedCursor = cursorChar ? invert(atCursor) : atCursor
        }

        return (
          beforeCursor + renderedCursor + ghostSuffix + afterCursor.trimEnd()
        )
      })
      .join('\n')
  }

  left(): Cursor {
    if (this.offset === 0) return this

    const chip = this.imageRefEndingAt(this.offset)
    if (chip) return new Cursor(this.measuredText, chip.start)

    const prevOffset = this.measuredText.prevOffset(this.offset)
    return new Cursor(this.measuredText, prevOffset)
  }

  right(): Cursor {
    if (this.offset >= this.text.length) return this

    const chip = this.imageRefStartingAt(this.offset)
    if (chip) return new Cursor(this.measuredText, chip.end)

    const nextOffset = this.measuredText.nextOffset(this.offset)
    return new Cursor(this.measuredText, Math.min(nextOffset, this.text.length))
  }

  /**
   * If an [Image #N] chip ends at `offset`, return its bounds. Used by left()
   * to hop the cursor over the chip instead of stepping into it.
   */
  imageRefEndingAt(offset: number): { start: number; end: number } | null {
    const m = this.text.slice(0, offset).match(/\[Image #\d+\]$/)
    return m ? { start: offset - m[0].length, end: offset } : null
  }

  imageRefStartingAt(offset: number): { start: number; end: number } | null {
    const m = this.text.slice(offset).match(/^\[Image #\d+\]/)
    return m ? { start: offset, end: offset + m[0].length } : null
  }

  /**
   * If offset lands strictly inside an [Image #N] chip, snap it to the given
   * boundary. Used by word-movement methods so Ctrl+W / Alt+D never leave a
   * partial chip.
   */
  snapOutOfImageRef(offset: number, toward: 'start' | 'end'): number {
    const re = /\[Image #\d+\]/g
    let m
    while ((m = re.exec(this.text)) !== null) {
      const start = m.index
      const end = start + m[0].length
      if (offset > start && offset < end) {
        return toward === 'start' ? start : end
      }
    }
    return offset
  }

  up(): Cursor {
    const { line, column } = this.getPosition()
    if (line === 0) {
      return this
    }

    const prevLine = this.measuredText.getWrappedText()[line - 1]
    if (prevLine === undefined) {
      return this
    }

    const prevLineDisplayWidth = stringWidth(prevLine)
    if (column > prevLineDisplayWidth) {
      const newOffset = this.getOffset({
        line: line - 1,
        column: prevLineDisplayWidth,
      })
      return new Cursor(this.measuredText, newOffset, 0)
    }

    const newOffset = this.getOffset({ line: line - 1, column })
    return new Cursor(this.measuredText, newOffset, 0)
  }

  down(): Cursor {
    const { line, column } = this.getPosition()
    if (line >= this.measuredText.lineCount - 1) {
      return this
    }

    // If there is no next line, stay on the current line,
    // and let the caller handle it (e.g. for prompt input,
    // we move to the next history entry)
    const nextLine = this.measuredText.getWrappedText()[line + 1]
    if (nextLine === undefined) {
      return this
    }

    // If the current column is past the end of the next line,
    // move to the end of the next line
    const nextLineDisplayWidth = stringWidth(nextLine)
    if (column > nextLineDisplayWidth) {
      const newOffset = this.getOffset({
        line: line + 1,
        column: nextLineDisplayWidth,
      })
      return new Cursor(this.measuredText, newOffset, 0)
    }

    // Otherwise, move to the same column on the next line
    const newOffset = this.getOffset({
      line: line + 1,
      column,
    })
    return new Cursor(this.measuredText, newOffset, 0)
  }

  /**
   * Move to the start of the current line (column 0).
   * This is the raw version used internally by startOfLine.
   */
  private startOfCurrentLine(): Cursor {
    const { line } = this.getPosition()
    return new Cursor(
      this.measuredText,
      this.getOffset({
        line,
        column: 0,
      }),
      0,
    )
  }

  startOfLine(): Cursor {
    const { line, column } = this.getPosition()

    // If already at start of line and not at first line, move to previous line
    if (column === 0 && line > 0) {
      return new Cursor(
        this.measuredText,
        this.getOffset({
          line: line - 1,
          column: 0,
        }),
        0,
      )
    }

    return this.startOfCurrentLine()
  }

  firstNonBlankInLine(): Cursor {
    const { line } = this.getPosition()
    const lineText = this.measuredText.getWrappedText()[line] || ''

    const match = lineText.match(/^\s*\S/)
    const column = match?.index ? match.index + match[0].length - 1 : 0
    const offset = this.getOffset({ line, column })

    return new Cursor(this.measuredText, offset, 0)
  }

  endOfLine(): Cursor {
    const { line } = this.getPosition()
    const column = this.measuredText.getLineLength(line)
    const offset = this.getOffset({ line, column })
    return new Cursor(this.measuredText, offset, 0)
  }

  // Helper methods for finding logical line boundaries
  private findLogicalLineStart(fromOffset: number = this.offset): number {
    const prevNewline = this.text.lastIndexOf('\n', fromOffset - 1)
    return prevNewline === -1 ? 0 : prevNewline + 1
  }

  private findLogicalLineEnd(fromOffset: number = this.offset): number {
    const nextNewline = this.text.indexOf('\n', fromOffset)
    return nextNewline === -1 ? this.text.length : nextNewline
  }

  // Helper to get logical line bounds for current position
  private getLogicalLineBounds(): { start: number; end: number } {
    return {
      start: this.findLogicalLineStart(),
      end: this.findLogicalLineEnd(),
    }
  }

  // Helper to create cursor with preserved column, clamped to line length
  // Snaps to grapheme boundary to avoid landing mid-grapheme
  private createCursorWithColumn(
    lineStart: number,
    lineEnd: number,
    targetColumn: number,
  ): Cursor {
    const lineLength = lineEnd - lineStart
    const clampedColumn = Math.min(targetColumn, lineLength)
    const rawOffset = lineStart + clampedColumn
    const offset = this.measuredText.snapToGraphemeBoundary(rawOffset)
    return new Cursor(this.measuredText, offset, 0)
  }

  endOfLogicalLine(): Cursor {
    return new Cursor(this.measuredText, this.findLogicalLineEnd(), 0)
  }

  startOfLogicalLine(): Cursor {
    return new Cursor(this.measuredText, this.findLogicalLineStart(), 0)
  }

  firstNonBlankInLogicalLine(): Cursor {
    const { start, end } = this.getLogicalLineBounds()
    const lineText = this.text.slice(start, end)
    const match = lineText.match(/\S/)
    const offset = start + (match?.index ?? 0)
    return new Cursor(this.measuredText, offset, 0)
  }

  upLogicalLine(): Cursor {
    const { start: currentStart } = this.getLogicalLineBounds()

    // At first line - stay at beginning
    if (currentStart === 0) {
      return new Cursor(this.measuredText, 0, 0)
    }

    // Calculate target column position
    const currentColumn = this.offset - currentStart

    // Find previous line bounds
    const prevLineEnd = currentStart - 1
    const prevLineStart = this.findLogicalLineStart(prevLineEnd)

    return this.createCursorWithColumn(
      prevLineStart,
      prevLineEnd,
      currentColumn,
    )
  }

  downLogicalLine(): Cursor {
    const { start: currentStart, end: currentEnd } = this.getLogicalLineBounds()

    // At last line - stay at end
    if (currentEnd >= this.text.length) {
      return new Cursor(this.measuredText, this.text.length, 0)
    }

    // Calculate target column position
    const currentColumn = this.offset - currentStart

    // Find next line bounds
    const nextLineStart = currentEnd + 1
    const nextLineEnd = this.findLogicalLineEnd(nextLineStart)

    return this.createCursorWithColumn(
      nextLineStart,
      nextLineEnd,
      currentColumn,
    )
  }

  // Vim word vs WORD movements:
  // - word (lowercase w/b/e): sequences of letters, digits, and underscores
  // - WORD (uppercase W/B/E): sequences of non-whitespace characters
  // For example, in "hello-world!", word movements see 3 words: "hello", "world", and nothing
  // But WORD movements see 1 WORD: "hello-world!"

  nextWord(): Cursor {
    if (this.isAtEnd()) {
      return this
    }

    // Use Intl.Segmenter for proper word boundary detection (including CJK)
    const wordBoundaries = this.measuredText.getWordBoundaries()

    // Find the next word start boundary after current position
    for (const boundary of wordBoundaries) {
      if (boundary.isWordLike && boundary.start > this.offset) {
        return new Cursor(this.measuredText, boundary.start)
      }
    }

    // If no next word found, go to end
    return new Cursor(this.measuredText, this.text.length)
  }

  endOfWord(): Cursor {
    if (this.isAtEnd()) {
      return this
    }

    // Use Intl.Segmenter for proper word boundary detection (including CJK)
    const wordBoundaries = this.measuredText.getWordBoundaries()

    // Find the current word boundary we're in
    for (const boundary of wordBoundaries) {
      if (!boundary.isWordLike) continue

      // If we're inside this word but NOT at the last character
      if (this.offset >= boundary.start && this.offset < boundary.end - 1) {
        // Move to end of this word (last character position)
        return new Cursor(this.measuredText, boundary.end - 1)
      }

      // If we're at the last character of a word (end - 1), find the next word's end
      if (this.offset === boundary.end - 1) {
        // Find next word
        for (const nextBoundary of wordBoundaries) {
          if (nextBoundary.isWordLike && nextBoundary.start > this.offset) {
            return new Cursor(this.measuredText, nextBoundary.end - 1)
          }
        }
        return this
      }
    }

    // If not in a word, find the next word and go to its end
    for (const boundary of wordBoundaries) {
      if (boundary.isWordLike && boundary.start > this.offset) {
        return new Cursor(this.measuredText, boundary.end - 1)
      }
    }

    return this
  }

  prevWord(): Cursor {
    if (this.isAtStart()) {
      return this
    }

    // Use Intl.Segmenter for proper word boundary detection (including CJK)
    const wordBoundaries = this.measuredText.getWordBoundaries()

    // Find the previous word start boundary before current position
    // We need to iterate in reverse to find the previous word
    let prevWordStart: number | null = null

    for (const boundary of wordBoundaries) {
      if (!boundary.isWordLike) continue

      // If we're at or after the start of this word, but this word starts before us
      if (boundary.start < this.offset) {
        // If we're inside this word (not at the start), go to its start
        if (this.offset > boundary.start && this.offset <= boundary.end) {
          return new Cursor(this.measuredText, boundary.start)
        }
        // Otherwise, remember this as a candidate for previous word
        prevWordStart = boundary.start
      }
    }

    if (prevWordStart !== null) {
      return new Cursor(this.measuredText, prevWordStart)
    }

    return new Cursor(this.measuredText, 0)
  }

  // Vim-specific word methods
  // In Vim, a "word" is either:
  // 1. A sequence of word characters (letters, digits, underscore) - including Unicode
  // 2. A sequence of non-blank, non-word characters (punctuation/symbols)

  nextVimWord(): Cursor {
    if (this.isAtEnd()) {
      return this
    }

    let pos = this.offset
    const advance = (p: number): number => this.measuredText.nextOffset(p)

    const currentGrapheme = this.graphemeAt(pos)
    if (!currentGrapheme) {
      return this
    }

    if (isVimWordChar(currentGrapheme)) {
      while (pos < this.text.length && isVimWordChar(this.graphemeAt(pos))) {
        pos = advance(pos)
      }
    } else if (isVimPunctuation(currentGrapheme)) {
      while (pos < this.text.length && isVimPunctuation(this.graphemeAt(pos))) {
        pos = advance(pos)
      }
    }

    while (
      pos < this.text.length &&
      WHITESPACE_REGEX.test(this.graphemeAt(pos))
    ) {
      pos = advance(pos)
    }

    return new Cursor(this.measuredText, pos)
  }

  endOfVimWord(): Cursor {
    if (this.isAtEnd()) {
      return this
    }

    const text = this.text
    let pos = this.offset
    const advance = (p: number): number => this.measuredText.nextOffset(p)

    if (this.graphemeAt(pos) === '') {
      return this
    }

    pos = advance(pos)

    while (pos < text.length && WHITESPACE_REGEX.test(this.graphemeAt(pos))) {
      pos = advance(pos)
    }

    if (pos >= text.length) {
      return new Cursor(this.measuredText, text.length)
    }

    const charAtPos = this.graphemeAt(pos)
    if (isVimWordChar(charAtPos)) {
      while (pos < text.length) {
        const nextPos = advance(pos)
        if (nextPos >= text.length || !isVimWordChar(this.graphemeAt(nextPos)))
          break
        pos = nextPos
      }
    } else if (isVimPunctuation(charAtPos)) {
      while (pos < text.length) {
        const nextPos = advance(pos)
        if (
          nextPos >= text.length ||
          !isVimPunctuation(this.graphemeAt(nextPos))
        )
          break
        pos = nextPos
      }
    }

    return new Cursor(this.measuredText, pos)
  }

  prevVimWord(): Cursor {
    if (this.isAtStart()) {
      return this
    }

    let pos = this.offset
    const retreat = (p: number): number => this.measuredText.prevOffset(p)

    pos = retreat(pos)

    while (pos > 0 && WHITESPACE_REGEX.test(this.graphemeAt(pos))) {
      pos = retreat(pos)
    }

    // At position 0 with whitespace means no previous word exists, go to start
    if (pos === 0 && WHITESPACE_REGEX.test(this.graphemeAt(0))) {
      return new Cursor(this.measuredText, 0)
    }

    const charAtPos = this.graphemeAt(pos)
    if (isVimWordChar(charAtPos)) {
      while (pos > 0) {
        const prevPos = retreat(pos)
        if (!isVimWordChar(this.graphemeAt(prevPos))) break
        pos = prevPos
      }
    } else if (isVimPunctuation(charAtPos)) {
      while (pos > 0) {
        const prevPos = retreat(pos)
        if (!isVimPunctuation(this.graphemeAt(prevPos))) break
        pos = prevPos
      }
    }

    return new Cursor(this.measuredText, pos)
  }

  nextWORD(): Cursor {
    // eslint-disable-next-line @typescript-eslint/no-this-alias
    let nextCursor: Cursor = this
    // If we're on a non-whitespace character, move to the next whitespace
    while (!nextCursor.isOverWhitespace() && !nextCursor.isAtEnd()) {
      nextCursor = nextCursor.right()
    }
    // now move to the next non-whitespace character
    while (nextCursor.isOverWhitespace() && !nextCursor.isAtEnd()) {
      nextCursor = nextCursor.right()
    }
    return nextCursor
  }

  endOfWORD(): Cursor {
    if (this.isAtEnd()) {
      return this
    }

    // eslint-disable-next-line @typescript-eslint/no-this-alias
    let cursor: Cursor = this

    // Check if we're already at the end of a WORD
    // (current character is non-whitespace, but next character is whitespace or we're at the end)
    const atEndOfWORD =
      !cursor.isOverWhitespace() &&
      (cursor.right().isOverWhitespace() || cursor.right().isAtEnd())

    if (atEndOfWORD) {
      // We're already at the end of a WORD, move to the next WORD
      cursor = cursor.right()
      return cursor.endOfWORD()
    }

    // If we're on a whitespace character, find the next WORD
    if (cursor.isOverWhitespace()) {
      cursor = cursor.nextWORD()
    }

    // Now move to the end of the current WORD
    while (!cursor.right().isOverWhitespace() && !cursor.isAtEnd()) {
      cursor = cursor.right()
    }

    return cursor
  }

  prevWORD(): Cursor {
    // eslint-disable-next-line @typescript-eslint/no-this-alias
    let cursor: Cursor = this

    // if we are already at the beginning of a WORD, step off it
    if (cursor.left().isOverWhitespace()) {
      cursor = cursor.left()
    }

    // Move left over any whitespace characters
    while (cursor.isOverWhitespace() && !cursor.isAtStart()) {
      cursor = cursor.left()
    }

    // If we're over a non-whitespace character, move to the start of this WORD
    if (!cursor.isOverWhitespace()) {
      while (!cursor.left().isOverWhitespace() && !cursor.isAtStart()) {
        cursor = cursor.left()
      }
    }

    return cursor
  }

  modifyText(end: Cursor, insertString: string = ''): Cursor {
    const startOffset = this.offset
    const endOffset = end.offset

    const newText =
      this.text.slice(0, startOffset) +
      insertString +
      this.text.slice(endOffset)

    return Cursor.fromText(
      newText,
      this.columns,
      startOffset + insertString.normalize('NFC').length,
    )
  }

  insert(insertString: string): Cursor {
    const newCursor = this.modifyText(this, insertString)
    return newCursor
  }

  del(): Cursor {
    if (this.isAtEnd()) {
      return this
    }
    return this.modifyText(this.right())
  }

  backspace(): Cursor {
    if (this.isAtStart()) {
      return this
    }
    return this.left().modifyText(this)
  }

  deleteToLineStart(): { cursor: Cursor; killed: string } {
    // If cursor is right after a newline (at start of line), delete just that
    // newline — symmetric with deleteToLineEnd's newline handling. This lets
    // repeated ctrl+u clear across lines.
    if (this.offset > 0 && this.text[this.offset - 1] === '\n') {
      return { cursor: this.left().modifyText(this), killed: '\n' }
    }

    // Use startOfLine() so that at column 0 of a wrapped visual line,
    // the cursor moves to the previous visual line's start instead of
    // getting stuck.
    const startCursor = this.startOfLine()
    const killed = this.text.slice(startCursor.offset, this.offset)
    return { cursor: startCursor.modifyText(this), killed }
  }

  deleteToLineEnd(): { cursor: Cursor; killed: string } {
    // If cursor is on a newline character, delete just that character
    if (this.text[this.offset] === '\n') {
      return { cursor: this.modifyText(this.right()), killed: '\n' }
    }

    const endCursor = this.endOfLine()
    const killed = this.text.slice(this.offset, endCursor.offset)
    return { cursor: this.modifyText(endCursor), killed }
  }

  deleteToLogicalLineEnd(): Cursor {
    // If cursor is on a newline character, delete just that character
    if (this.text[this.offset] === '\n') {
      return this.modifyText(this.right())
    }

    return this.modifyText(this.endOfLogicalLine())
  }

  deleteWordBefore(): { cursor: Cursor; killed: string } {
    if (this.isAtStart()) {
      return { cursor: this, killed: '' }
    }
    const target = this.snapOutOfImageRef(this.prevWord().offset, 'start')
    const prevWordCursor = new Cursor(this.measuredText, target)
    const killed = this.text.slice(prevWordCursor.offset, this.offset)
    return { cursor: prevWordCursor.modifyText(this), killed }
  }

  /**
   * Deletes a token before the cursor if one exists.
   * Supports pasted text refs: [Pasted text #1], [Pasted text #1 +10 lines],
   * [...Truncated text #1 +10 lines...]
   *
   * Note: @mentions are NOT tokenized since users may want to correct typos
   * in file paths. Use Ctrl/Cmd+backspace for word-deletion on mentions.
   *
   * Returns null if no token found at cursor position.
   * Only triggers when cursor is at end of token (followed by whitespace or EOL).
   */
  deleteTokenBefore(): Cursor | null {
    // Cursor at chip.start is the "selected" state — backspace deletes the
    // chip forward, not the char before it.
    const chipAfter = this.imageRefStartingAt(this.offset)
    if (chipAfter) {
      const end =
        this.text[chipAfter.end] === ' ' ? chipAfter.end + 1 : chipAfter.end
      return this.modifyText(new Cursor(this.measuredText, end))
    }

    if (this.isAtStart()) {
      return null
    }

    // Only trigger if cursor is at a word boundary (whitespace or end of string after cursor)
    const charAfter = this.text[this.offset]
    if (charAfter !== undefined && !/\s/.test(charAfter)) {
      return null
    }

    const textBefore = this.text.slice(0, this.offset)

    // Check for pasted/truncated text refs: [Pasted text #1] or [...Truncated text #1 +50 lines...]
    const pasteMatch = textBefore.match(
      /(^|\s)\[(Pasted text #\d+(?: \+\d+ lines)?|Image #\d+|\.\.\.Truncated text #\d+ \+\d+ lines\.\.\.)\]$/,
    )
    if (pasteMatch) {
      const matchStart = pasteMatch.index! + pasteMatch[1]!.length
      return new Cursor(this.measuredText, matchStart).modifyText(this)
    }

    return null
  }

  deleteWordAfter(): Cursor {
    if (this.isAtEnd()) {
      return this
    }

    const target = this.snapOutOfImageRef(this.nextWord().offset, 'end')
    return this.modifyText(new Cursor(this.measuredText, target))
  }

  private graphemeAt(pos: number): string {
    if (pos >= this.text.length) return ''
    const nextOff = this.measuredText.nextOffset(pos)
    return this.text.slice(pos, nextOff)
  }

  private isOverWhitespace(): boolean {
    const currentChar = this.text[this.offset] ?? ''
    return /\s/.test(currentChar)
  }

  equals(other: Cursor): boolean {
    return (
      this.offset === other.offset && this.measuredText === other.measuredText
    )
  }

  isAtStart(): boolean {
    return this.offset === 0
  }
  isAtEnd(): boolean {
    return this.offset >= this.text.length
  }

  startOfFirstLine(): Cursor {
    // Go to the very beginning of the text (first character of first line)
    return new Cursor(this.measuredText, 0, 0)
  }

  startOfLastLine(): Cursor {
    // Go to the beginning of the last line
    const lastNewlineIndex = this.text.lastIndexOf('\n')

    if (lastNewlineIndex === -1) {
      // If there are no newlines, the text is a single line
      return this.startOfLine()
    }

    // Position after the last newline character
    return new Cursor(this.measuredText, lastNewlineIndex + 1, 0)
  }

  goToLine(lineNumber: number): Cursor {
    // Go to the beginning of the specified logical line (1-indexed, like vim)
    // Uses logical lines (separated by \n), not wrapped display lines
    const lines = this.text.split('\n')
    const targetLine = Math.min(Math.max(0, lineNumber - 1), lines.length - 1)
    let offset = 0
    for (let i = 0; i < targetLine; i++) {
      offset += (lines[i]?.length ?? 0) + 1 // +1 for newline
    }
    return new Cursor(this.measuredText, offset, 0)
  }

  endOfFile(): Cursor {
    return new Cursor(this.measuredText, this.text.length, 0)
  }

  public get text(): string {
    return this.measuredText.text
  }

  private get columns(): number {
    return this.measuredText.columns + 1
  }

  getPosition(): Position {
    return this.measuredText.getPositionFromOffset(this.offset)
  }

  private getOffset(position: Position): number {
    return this.measuredText.getOffsetFromPosition(position)
  }

  /**
   * Find a character using vim f/F/t/T semantics.
   *
   * @param char - The character to find
   * @param type - 'f' (forward to), 'F' (backward to), 't' (forward till), 'T' (backward till)
   * @param count - Find the Nth occurrence
   * @returns The target offset, or null if not found
   */
  findCharacter(
    char: string,
    type: 'f' | 'F' | 't' | 'T',
    count: number = 1,
  ): number | null {
    const text = this.text
    const forward = type === 'f' || type === 't'
    const till = type === 't' || type === 'T'
    let found = 0

    if (forward) {
      let pos = this.measuredText.nextOffset(this.offset)
      while (pos < text.length) {
        const grapheme = this.graphemeAt(pos)
        if (grapheme === char) {
          found++
          if (found === count) {
            return till
              ? Math.max(this.offset, this.measuredText.prevOffset(pos))
              : pos
          }
        }
        pos = this.measuredText.nextOffset(pos)
      }
    } else {
      if (this.offset === 0) return null
      let pos = this.measuredText.prevOffset(this.offset)
      while (pos >= 0) {
        const grapheme = this.graphemeAt(pos)
        if (grapheme === char) {
          found++
          if (found === count) {
            return till
              ? Math.min(this.offset, this.measuredText.nextOffset(pos))
              : pos
          }
        }
        if (pos === 0) break
        pos = this.measuredText.prevOffset(pos)
      }
    }

    return null
  }
}

class WrappedLine {
  constructor(
    public readonly text: string,
    public readonly startOffset: number,
    public readonly isPrecededByNewline: boolean,
    public readonly endsWithNewline: boolean = false,
  ) {}

  equals(other: WrappedLine): boolean {
    return this.text === other.text && this.startOffset === other.startOffset
  }

  get length(): number {
    return this.text.length + (this.endsWithNewline ? 1 : 0)
  }
}

export class MeasuredText {
  private _wrappedLines?: WrappedLine[]
  public readonly text: string
  private navigationCache: Map<string, number>
  private graphemeBoundaries?: number[]

  constructor(
    text: string,
    readonly columns: number,
  ) {
    this.text = text.normalize('NFC')
    this.navigationCache = new Map()
  }

  /**
   * Lazily computes and caches wrapped lines.
   * This expensive operation is deferred until actually needed.
   */
  private get wrappedLines(): WrappedLine[] {
    if (!this._wrappedLines) {
      this._wrappedLines = this.measureWrappedText()
    }
    return this._wrappedLines
  }

  private getGraphemeBoundaries(): number[] {
    if (!this.graphemeBoundaries) {
      this.graphemeBoundaries = []
      for (const { index } of getGraphemeSegmenter().segment(this.text)) {
        this.graphemeBoundaries.push(index)
      }
      // Add the end of text as a boundary
      this.graphemeBoundaries.push(this.text.length)
    }
    return this.graphemeBoundaries
  }

  private wordBoundariesCache?: Array<{
    start: number
    end: number
    isWordLike: boolean
  }>

  /**
   * Get word boundaries using Intl.Segmenter for proper Unicode word segmentation.
   * This correctly handles CJK (Chinese, Japanese, Korean) text where each character
   * is typically its own word, as well as scripts that use spaces between words.
   */
  public getWordBoundaries(): Array<{
    start: number
    end: number
    isWordLike: boolean
  }> {
    if (!this.wordBoundariesCache) {
      this.wordBoundariesCache = []
      for (const segment of getWordSegmenter().segment(this.text)) {
        this.wordBoundariesCache.push({
          start: segment.index,
          end: segment.index + segment.segment.length,
          isWordLike: segment.isWordLike ?? false,
        })
      }
    }
    return this.wordBoundariesCache
  }

  /**
   * Binary search for boundaries.
   * @param boundaries: Sorted array of boundaries
   * @param target: Target offset
   * @param findNext: If true, finds first boundary > target. If false, finds last boundary < target.
   * @returns The found boundary index, or appropriate default
   */
  private binarySearchBoundary(
    boundaries: number[],
    target: number,
    findNext: boolean,
  ): number {
    let left = 0
    let right = boundaries.length - 1
    let result = findNext ? this.text.length : 0

    while (left <= right) {
      const mid = Math.floor((left + right) / 2)
      const boundary = boundaries[mid]
      if (boundary === undefined) break

      if (findNext) {
        if (boundary > target) {
          result = boundary
          right = mid - 1
        } else {
          left = mid + 1
        }
      } else {
        if (boundary < target) {
          result = boundary
          left = mid + 1
        } else {
          right = mid - 1
        }
      }
    }

    return result
  }

  // Convert string index to display width
  public stringIndexToDisplayWidth(text: string, index: number): number {
    if (index <= 0) return 0
    if (index >= text.length) return stringWidth(text)
    return stringWidth(text.substring(0, index))
  }

  // Convert display width to string index
  public displayWidthToStringIndex(text: string, targetWidth: number): number {
    if (targetWidth <= 0) return 0
    if (!text) return 0

    // If the text matches our text, use the precomputed graphemes
    if (text === this.text) {
      return this.offsetAtDisplayWidth(targetWidth)
    }

    // Otherwise compute on the fly
    let currentWidth = 0
    let currentOffset = 0

    for (const { segment, index } of getGraphemeSegmenter().segment(text)) {
      const segmentWidth = stringWidth(segment)

      if (currentWidth + segmentWidth > targetWidth) {
        break
      }

      currentWidth += segmentWidth
      currentOffset = index + segment.length
    }

    return currentOffset
  }

  /**
   * Find the string offset that corresponds to a target display width.
   */
  private offsetAtDisplayWidth(targetWidth: number): number {
    if (targetWidth <= 0) return 0

    let currentWidth = 0
    const boundaries = this.getGraphemeBoundaries()

    // Iterate through grapheme boundaries
    for (let i = 0; i < boundaries.length - 1; i++) {
      const start = boundaries[i]
      const end = boundaries[i + 1]
      if (start === undefined || end === undefined) continue
      const segment = this.text.substring(start, end)
      const segmentWidth = stringWidth(segment)

      if (currentWidth + segmentWidth > targetWidth) {
        return start
      }
      currentWidth += segmentWidth
    }

    return this.text.length
  }

  private measureWrappedText(): WrappedLine[] {
    const wrappedText = wrapAnsi(this.text, this.columns, {
      hard: true,
      trim: false,
    })

    const wrappedLines: WrappedLine[] = []
    let searchOffset = 0
    let lastNewLinePos = -1

    const lines = wrappedText.split('\n')
    for (let i = 0; i < lines.length; i++) {
      const text = lines[i]!
      const isPrecededByNewline = (startOffset: number) =>
        i === 0 || (startOffset > 0 && this.text[startOffset - 1] === '\n')

      if (text.length === 0) {
        // For blank lines, find the next newline character after the last one
        lastNewLinePos = this.text.indexOf('\n', lastNewLinePos + 1)

        if (lastNewLinePos !== -1) {
          const startOffset = lastNewLinePos
          const endsWithNewline = true

          wrappedLines.push(
            new WrappedLine(
              text,
              startOffset,
              isPrecededByNewline(startOffset),
              endsWithNewline,
            ),
          )
        } else {
          // If we can't find another newline, this must be the end of text
          const startOffset = this.text.length
          wrappedLines.push(
            new WrappedLine(
              text,
              startOffset,
              isPrecededByNewline(startOffset),
              false,
            ),
          )
        }
      } else {
        // For non-blank lines, find the text in this.text
        const startOffset = this.text.indexOf(text, searchOffset)

        if (startOffset === -1) {
          throw new Error('Failed to find wrapped line in text')
        }

        searchOffset = startOffset + text.length

        // Check if this line ends with a newline in this.text
        const potentialNewlinePos = startOffset + text.length
        const endsWithNewline =
          potentialNewlinePos < this.text.length &&
          this.text[potentialNewlinePos] === '\n'

        if (endsWithNewline) {
          lastNewLinePos = potentialNewlinePos
        }

        wrappedLines.push(
          new WrappedLine(
            text,
            startOffset,
            isPrecededByNewline(startOffset),
            endsWithNewline,
          ),
        )
      }
    }

    return wrappedLines
  }

  public getWrappedText(): WrappedText {
    return this.wrappedLines.map(line =>
      line.isPrecededByNewline ? line.text : line.text.trimStart(),
    )
  }

  public getWrappedLines(): WrappedLine[] {
    return this.wrappedLines
  }

  private getLine(line: number): WrappedLine {
    const lines = this.wrappedLines
    return lines[Math.max(0, Math.min(line, lines.length - 1))]!
  }

  public getOffsetFromPosition(position: Position): number {
    const wrappedLine = this.getLine(position.line)

    // Handle blank lines specially
    if (wrappedLine.text.length === 0 && wrappedLine.endsWithNewline) {
      return wrappedLine.startOffset
    }

    // Account for leading whitespace
    const leadingWhitespace = wrappedLine.isPrecededByNewline
      ? 0
      : wrappedLine.text.length - wrappedLine.text.trimStart().length

    // Convert display column to string index
    const displayColumnWithLeading = position.column + leadingWhitespace
    const stringIndex = this.displayWidthToStringIndex(
      wrappedLine.text,
      displayColumnWithLeading,
    )

    // Calculate the actual offset
    const offset = wrappedLine.startOffset + stringIndex

    // For normal lines
    const lineEnd = wrappedLine.startOffset + wrappedLine.text.length

    // Don't allow going past the end of the current line into the next line
    // unless we're at the very end of the text
    let maxOffset = lineEnd
    const lineDisplayWidth = stringWidth(wrappedLine.text)
    if (wrappedLine.endsWithNewline && position.column > lineDisplayWidth) {
      // Allow positioning after the newline
      maxOffset = lineEnd + 1
    }

    return Math.min(offset, maxOffset)
  }

  public getLineLength(line: number): number {
    const wrappedLine = this.getLine(line)
    return stringWidth(wrappedLine.text)
  }

  public getPositionFromOffset(offset: number): Position {
    const lines = this.wrappedLines
    for (let line = 0; line < lines.length; line++) {
      const currentLine = lines[line]!
      const nextLine = lines[line + 1]
      if (
        offset >= currentLine.startOffset &&
        (!nextLine || offset < nextLine.startOffset)
      ) {
        // Calculate string position within the line
        const stringPosInLine = offset - currentLine.startOffset

        // Handle leading whitespace for wrapped lines
        let displayColumn: number
        if (currentLine.isPrecededByNewline) {
          // For lines preceded by newline, calculate display width directly
          displayColumn = this.stringIndexToDisplayWidth(
            currentLine.text,
            stringPosInLine,
          )
        } else {
          // For wrapped lines, we need to account for trimmed whitespace
          const leadingWhitespace =
            currentLine.text.length - currentLine.text.trimStart().length
          if (stringPosInLine < leadingWhitespace) {
            // Cursor is in the trimmed whitespace area, position at start
            displayColumn = 0
          } else {
            // Calculate display width from the trimmed text
            const trimmedText = currentLine.text.trimStart()
            const posInTrimmed = stringPosInLine - leadingWhitespace
            displayColumn = this.stringIndexToDisplayWidth(
              trimmedText,
              posInTrimmed,
            )
          }
        }

        return {
          line,
          column: Math.max(0, displayColumn),
        }
      }
    }

    // If we're past the last character, return the end of the last line
    const line = lines.length - 1
    const lastLine = this.wrappedLines[line]!
    return {
      line,
      column: stringWidth(lastLine.text),
    }
  }

  public get lineCount(): number {
    return this.wrappedLines.length
  }

  private withCache<T>(key: string, compute: () => T): T {
    const cached = this.navigationCache.get(key)
    if (cached !== undefined) return cached as T

    const result = compute()
    this.navigationCache.set(key, result as number)
    return result
  }

  nextOffset(offset: number): number {
    return this.withCache(`next:${offset}`, () => {
      const boundaries = this.getGraphemeBoundaries()
      return this.binarySearchBoundary(boundaries, offset, true)
    })
  }

  prevOffset(offset: number): number {
    if (offset <= 0) return 0

    return this.withCache(`prev:${offset}`, () => {
      const boundaries = this.getGraphemeBoundaries()
      return this.binarySearchBoundary(boundaries, offset, false)
    })
  }

  /**
   * Snap an arbitrary code-unit offset to the start of the containing grapheme.
   * If offset is already on a boundary, returns it unchanged.
   */
  snapToGraphemeBoundary(offset: number): number {
    if (offset <= 0) return 0
    if (offset >= this.text.length) return this.text.length
    const boundaries = this.getGraphemeBoundaries()
    // Binary search for largest boundary <= offset
    let lo = 0
    let hi = boundaries.length - 1
    while (lo < hi) {
      const mid = (lo + hi + 1) >> 1
      if (boundaries[mid]! <= offset) lo = mid
      else hi = mid - 1
    }
    return boundaries[lo]!
  }
}