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:
parent
5c2e1918ca
commit
bec72babd8
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user