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