π 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)
pushToKillRinggetLastKillgetKillRingItemgetKillRingSizeclearKillRingresetKillAccumulationrecordYankcanYankPopyankPopupdateYankLengthresetYankStateVIM_WORD_CHAR_REGEXWHITESPACE_REGEXisVimWordCharisVimWhitespaceisVimPunctuationCursorMeasuredText
π₯οΈ 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]!
}
}