fix(cursor): handle ANSI wrapped line offset recovery

## Bug 详情
issues.md #41 记录 ctrl+g 编辑长文本后偶现 TextInput 渲染报错,堆栈指向 MeasuredText.measureWrappedText 抛出 Failed to find wrapped line in text,导致输入组件崩溃并影响 ctrl+c 退出。

## 根因
measureWrappedText 依赖 wrapAnsi 输出的 wrapped line 通过 indexOf 反查原文偏移。wrapAnsi 在视觉换行处可能插入 ANSI reset/reopen 或 OSC 控制序列,使 wrapped line 不再是原文的字面切片,反查失败后直接抛错。

## 修复方案
保留原有 indexOf 快路径;当 wrapped line 无法字面匹配时,按原文顺序和显示宽度恢复 start/end offset,并在显示宽度与字符串 offset 的双向转换中跳过 ANSI/OSC 控制序列,避免光标定位落入控制序列内部。

## 变更要点
- 为 MeasuredText 增加 wrapped line fallback offset 恢复逻辑
- 增加 ANSI/OSC 控制序列识别与跳过逻辑
- 修正包含控制序列文本的 display width 与 string offset 双向转换
- 补充 ANSI reset 和 OSC 长文本换行回归测试

## 自测
- bun test src/utils/__tests__/Cursor.test.ts
- bunx biome lint src/utils/Cursor.ts src/utils/__tests__/Cursor.test.ts
- 手动验证 ANSI/OSC 边界 smoke 通过

Refs: #41
This commit is contained in:
IronRookieCoder 2026-05-14 11:53:23 +08:00
parent 5c2e1918ca
commit bec72babd8
2 changed files with 239 additions and 9 deletions

View File

@ -1232,7 +1232,9 @@ export class MeasuredText {
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))
if (!this.hasAnsiSequence(text))
return stringWidth(text.substring(0, index))
return this.stringIndexToDisplayWidthWithAnsi(text, index)
}
// Convert display width to string index
@ -1245,6 +1247,10 @@ export class MeasuredText {
return this.offsetAtDisplayWidth(targetWidth)
}
if (this.hasAnsiSequence(text)) {
return this.offsetAtDisplayWidthInText(text, targetWidth)
}
// Otherwise compute on the fly
let currentWidth = 0
let currentOffset = 0
@ -1269,6 +1275,10 @@ export class MeasuredText {
private offsetAtDisplayWidth(targetWidth: number): number {
if (targetWidth <= 0) return 0
if (this.hasAnsiSequence(this.text)) {
return this.offsetAtDisplayWidthInText(this.text, targetWidth)
}
let currentWidth = 0
const boundaries = this.getGraphemeBoundaries()
@ -1289,13 +1299,67 @@ export class MeasuredText {
return this.text.length
}
private stringIndexToDisplayWidthWithAnsi(
text: string,
index: number,
): number {
const endIndex = Math.min(index, text.length)
let offset = 0
let currentWidth = 0
while (offset < endIndex) {
const ansiEnd = this.getAnsiSequenceEndInText(text, offset)
if (ansiEnd !== null) {
if (ansiEnd > endIndex) break
offset = ansiEnd
continue
}
const segment = this.getGraphemeAtOffsetInText(text, offset)
if (segment.length === 0) break
currentWidth += stringWidth(segment)
offset += segment.length
}
return currentWidth
}
private offsetAtDisplayWidthInText(
text: string,
targetWidth: number,
): number {
if (targetWidth <= 0) return 0
let offset = 0
let currentWidth = 0
while (offset < text.length) {
const ansiEnd = this.getAnsiSequenceEndInText(text, offset)
if (ansiEnd !== null) {
offset = ansiEnd
continue
}
const segment = this.getGraphemeAtOffsetInText(text, offset)
if (segment.length === 0) break
const segmentWidth = stringWidth(segment)
if (currentWidth + segmentWidth > targetWidth) break
currentWidth += segmentWidth
offset += segment.length
}
return offset
}
private measureWrappedText(): WrappedLine[] {
// Protect tabs from being expanded by npm wrap-ansi in Node.js dist builds.
// Bun.wrapAnsi preserves tabs, but npm wrap-ansi expands them to spaces,
// which breaks the indexOf-based offset recovery below.
const TAB_PLACEHOLDER_CANDIDATES = ['\u2060', '\u2061', '\u2062', '\u2063']
const tabPlaceholder = TAB_PLACEHOLDER_CANDIDATES.find(
(ch) => !this.text.includes(ch),
ch => !this.text.includes(ch),
)
const hasTabs = this.text.includes('\t')
@ -1353,17 +1417,31 @@ export class MeasuredText {
)
}
} else {
// For non-blank lines, find the text in this.text
const startOffset = this.text.indexOf(text, searchOffset)
// For non-blank lines, find the text in this.text. wrap-ansi may add
// style reset/reopen sequences at visual wrap boundaries, so the
// wrapped line can be a display-equivalent string that is not a literal
// slice of the input. Fall back to advancing through the original text
// by display width instead of crashing the input renderer.
let startOffset = this.text.indexOf(text, searchOffset)
let lineText = text
if (startOffset === -1) {
throw new Error('Failed to find wrapped line in text')
startOffset = this.findFallbackStartOffset(
searchOffset,
lastNewLinePos,
)
const endOffset = this.findOffsetAtDisplayWidth(
startOffset,
stringWidth(text),
)
lineText = this.text.slice(startOffset, endOffset)
searchOffset = endOffset
} else {
searchOffset = startOffset + text.length
}
searchOffset = startOffset + text.length
// Check if this line ends with a newline in this.text
const potentialNewlinePos = startOffset + text.length
const potentialNewlinePos = startOffset + lineText.length
const endsWithNewline =
potentialNewlinePos < this.text.length &&
this.text[potentialNewlinePos] === '\n'
@ -1374,7 +1452,7 @@ export class MeasuredText {
wrappedLines.push(
new WrappedLine(
text,
lineText,
startOffset,
isPrecededByNewline(startOffset),
endsWithNewline,
@ -1386,6 +1464,116 @@ export class MeasuredText {
return wrappedLines
}
private findFallbackStartOffset(
searchOffset: number,
lastNewLinePos: number,
): number {
let startOffset = Math.max(searchOffset, lastNewLinePos + 1)
while (startOffset < this.text.length && this.text[startOffset] === '\n') {
startOffset++
}
return startOffset
}
private findOffsetAtDisplayWidth(
startOffset: number,
targetWidth: number,
): number {
if (targetWidth <= 0) return startOffset
let offset = startOffset
let currentWidth = 0
while (offset < this.text.length) {
if (this.text[offset] === '\n') break
const ansiEnd = this.getAnsiSequenceEndInText(this.text, offset)
if (ansiEnd !== null) {
offset = ansiEnd
continue
}
const segment = this.getGraphemeAtOffset(offset)
if (segment.length === 0) break
currentWidth += stringWidth(segment)
offset += segment.length
if (currentWidth >= targetWidth) break
}
return offset
}
private getGraphemeAtOffset(offset: number): string {
return this.getGraphemeAtOffsetInText(this.text, offset)
}
private getGraphemeAtOffsetInText(text: string, offset: number): string {
const iterator = getGraphemeSegmenter()
.segment(text.slice(offset))
[Symbol.iterator]()
const next = iterator.next()
return next.done ? '' : next.value.segment
}
private hasAnsiSequence(text: string): boolean {
return (
text.includes('\x1b') || text.includes('\x9b') || text.includes('\x9d')
)
}
private getAnsiSequenceEndInText(
text: string,
offset: number,
): number | null {
const first = text.charCodeAt(offset)
if (first === 0x9b) {
return this.findControlSequenceEnd(text, offset + 1)
}
if (first === 0x9d) {
return this.findStringControlEnd(text, offset + 1)
}
if (first !== 0x1b) return null
const second = text.charCodeAt(offset + 1)
if (Number.isNaN(second)) return null
if (second === 0x5b) {
return this.findControlSequenceEnd(text, offset + 2)
}
if (second === 0x5d) {
return this.findStringControlEnd(text, offset + 2)
}
return Math.min(offset + 2, text.length)
}
private findControlSequenceEnd(text: string, offset: number): number {
let i = offset
while (i < text.length) {
const code = text.charCodeAt(i)
if (code >= 0x40 && code <= 0x7e) return i + 1
i++
}
return text.length
}
private findStringControlEnd(text: string, offset: number): number {
let i = offset
while (i < text.length) {
const code = text.charCodeAt(i)
if (code === 0x07) return i + 1
if (code === 0x1b && text.charCodeAt(i + 1) === 0x5c) {
return Math.min(i + 2, text.length)
}
i++
}
return text.length
}
public getWrappedText(): WrappedText {
return this.wrappedLines.map(line =>
line.isPrecededByNewline ? line.text : line.text.trimStart(),

View File

@ -86,4 +86,46 @@ describe('MeasuredText tab protection', () => {
})
})
describe('MeasuredText wrapped line recovery', () => {
test('styled long input does not throw when wrap-ansi inserts reset codes', () => {
const cursor = Cursor.fromText(
'foo\x1b[31mbarbaz\x1b[39mqux',
6,
'foo\x1b[31mbarbaz\x1b[39mqux'.length,
)
expect(() => cursor.getPosition()).not.toThrow()
expect(() => cursor.render(' ', '', text => text)).not.toThrow()
expect(cursor.getPosition().line).toBeGreaterThan(0)
})
test('styled long input keeps end offset on final wrapped line', () => {
const text = 'foo\x1b[31mbarbaz\x1b[39mqux'
const cursor = Cursor.fromText(text, 6, text.length)
const position = cursor.getPosition()
expect(cursor.measuredText.getOffsetFromPosition(position)).toBe(
text.length,
)
})
test('styled fallback line with reset code maps final position to EOF', () => {
const text = 'hello\x1b[31m世界abc\x1b[39mdef'
const cursor = Cursor.fromText(text, 6, text.length)
const position = cursor.getPosition()
expect(cursor.measuredText.getOffsetFromPosition(position)).toBe(
text.length,
)
})
test('OSC styled fallback line maps final position to EOF', () => {
const text = 'foo\x1b]8;;https://example.com\x07barbaz\x1b]8;;;;\x07qux'
const cursor = Cursor.fromText(text, 4, text.length)
const position = cursor.getPosition()
expect(cursor.measuredText.getOffsetFromPosition(position)).toBe(
text.length,
)
})
})