πŸ“„ File detail

utils/Cursor.ts

🧩 .tsπŸ“ 1,531 linesπŸ’Ύ 46,663 bytesπŸ“ text
← Back to All Files

🎯 Use case

This file lives under β€œutils/”, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, …). On the API surface it exposes pushToKillRing, getLastKill, getKillRingItem, getKillRingSize, and clearKillRing (and more) β€” mainly functions, hooks, or classes. It composes internal code from ink and intl (relative imports).

Generated from folder role, exports, dependency roots, and inline comments β€” not hand-reviewed for every path.

🧠 Inline summary

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

πŸ“€ Exports (heuristic)

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

πŸ–₯️ Source preview

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]!
  }
}