Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
47 kB · 1530 lines
TypeScript
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531import { 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 = 10let killRing: string[] = []let killRingIndex = 0let lastActionWasKill = false
// Track yank state for yank-pop (alt-y)let lastYankStart = 0let lastYankLength = 0let 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-popexport 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}_]$/uexport const WHITESPACE_REGEX = /\s/
// Exported helper functions for Vim character classificationexport 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]! }}