From bec72babd89329f8b46476974da2216fd03918c9 Mon Sep 17 00:00:00 2001 From: IronRookieCoder Date: Thu, 14 May 2026 11:53:23 +0800 Subject: [PATCH] fix(cursor): handle ANSI wrapped line offset recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- src/utils/Cursor.ts | 206 +++++++++++++++++++++++++++-- src/utils/__tests__/Cursor.test.ts | 42 ++++++ 2 files changed, 239 insertions(+), 9 deletions(-) diff --git a/src/utils/Cursor.ts b/src/utils/Cursor.ts index e9373d2ad..db95d5dee 100644 --- a/src/utils/Cursor.ts +++ b/src/utils/Cursor.ts @@ -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(), diff --git a/src/utils/__tests__/Cursor.test.ts b/src/utils/__tests__/Cursor.test.ts index dbb3d8011..1aed3513b 100644 --- a/src/utils/__tests__/Cursor.test.ts +++ b/src/utils/__tests__/Cursor.test.ts @@ -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, + ) + }) +})