fix(tui): normalized per-line output to prevent terminal style/link bleed
- Updated markdown cache keys with terminal capability and style-probe values so stale entries invalidated when image protocol, hyperlink mode, theme, or bgColor output changed. - Trimmed dangling inline style prefixes in markdown rendering so trailing unmatched SGR escapes were removed before emission. - Added per-line terminal resets and OSC8 closures in TUI output processing, including truncation, so rendered diffs match bytes actually written to the terminal.
This commit is contained in:
@@ -196,7 +196,18 @@ export class Markdown implements Component {
|
||||
// L2: module-level LRU — survives component disposal/recreation across
|
||||
// session-tree navigations. Key encodes every dimension that affects the
|
||||
// render output so different configurations never collide.
|
||||
const cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}`;
|
||||
// Encode terminal capability state and theme/style function output samples
|
||||
// so that capability shifts (image protocol changes, hyperlink toggle) or
|
||||
// caller-supplied theme/bgColor functions that mutate their output without
|
||||
// changing object identity invalidate the cache entry.
|
||||
// bgColor probe uses \x01 (single non-printable byte): chalk/ANSI wrappers
|
||||
// pass arbitrary bytes through verbatim, so this is safe and minimizes the
|
||||
// risk of clashing with a function that returns text verbatim.
|
||||
// theme.heading is used as the representative theme probe — it's required
|
||||
// by MarkdownTheme and is one of the most styling-sensitive entries.
|
||||
const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
|
||||
const headingProbe = this.#theme.heading("");
|
||||
const cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
|
||||
const cached = renderCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
// Populate L1 so subsequent calls from this instance are O(1) map lookup.
|
||||
@@ -606,6 +617,14 @@ export class Markdown implements Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Strip dangling re-opened-default SGR prefix left over from the last inline
|
||||
// token (strong/em/codespan/link/del/etc.) so the emitted line self-terminates
|
||||
// at its last styled segment instead of carrying an unmatched SGR open into
|
||||
// the next line. Matches upstream behavior.
|
||||
while (stylePrefix && result.endsWith(stylePrefix)) {
|
||||
result = result.slice(0, -stylePrefix.length);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+51
-27
@@ -19,6 +19,13 @@ import {
|
||||
} from "./utils";
|
||||
|
||||
const SEGMENT_RESET = "\x1b[0m";
|
||||
/**
|
||||
* Per-line terminator written at the end of every non-image line. Closes both
|
||||
* SGR state and any in-flight OSC 8 hyperlink so styles/links cannot bleed
|
||||
* across lines in scrollback. Applied by {@link TUI.#applyLineResets} before
|
||||
* diffing so `#previousLines` mirrors what was actually written.
|
||||
*/
|
||||
const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
|
||||
|
||||
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
||||
type InputListener = (data: string) => InputListenerResult;
|
||||
@@ -997,6 +1004,26 @@ export class TUI extends Container {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the per-line terminator ({@link LINE_TERMINATOR}) to every
|
||||
* non-image line and normalize for terminal rendering. Mutates the input
|
||||
* array in place so downstream diffing/storage sees exactly the bytes
|
||||
* written to the terminal — without this, the diff cache disagrees with
|
||||
* emitted output and OSC 8 hyperlink state can leak across lines.
|
||||
*/
|
||||
#applyLineResets(lines: string[]): string[] {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (TERMINAL.isImageLine(line)) continue;
|
||||
const normalized = normalizeTerminalOutput(line);
|
||||
// Only close OSC 8 hyperlinks when the line actually opened one;
|
||||
// emitting `\x1b]8;;\x07` on every line just feeds the terminal's OSC
|
||||
// parser for no reason (measurable cost in xterm.js parse loop).
|
||||
lines[i] = normalized + (normalized.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
#doRender(): void {
|
||||
if (this.#stopped) return;
|
||||
const width = this.terminal.columns;
|
||||
@@ -1021,6 +1048,12 @@ export class TUI extends Container {
|
||||
// Extract cursor position (marker must be found before diff comparison)
|
||||
const cursorPos = this.#extractCursorPosition(newLines, height);
|
||||
|
||||
// Terminate every non-image line so #previousLines mirrors emitted bytes
|
||||
// (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
|
||||
// because the marker is embedded mid-line, and before any diff/full render
|
||||
// path so cache comparisons stay byte-accurate.
|
||||
newLines = this.#applyLineResets(newLines);
|
||||
|
||||
// Width changed - need full re-render (line wrapping changes)
|
||||
const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
|
||||
const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
|
||||
@@ -1031,11 +1064,11 @@ export class TUI extends Container {
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
// Skip clearing scrollback (3J) in multiplexers — users actively navigate scrollback history
|
||||
if (clear) buffer += isMultiplexer ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
|
||||
const reset = SEGMENT_RESET;
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
const line = newLines[i];
|
||||
buffer += TERMINAL.isImageLine(line) ? line : normalizeTerminalOutput(line) + reset;
|
||||
// Lines were pre-terminated/normalized by #applyLineResets; image
|
||||
// lines were left untouched there.
|
||||
buffer += newLines[i];
|
||||
}
|
||||
this.#cursorRow = Math.max(0, newLines.length - 1);
|
||||
const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, this.#cursorRow);
|
||||
@@ -1170,29 +1203,13 @@ export class TUI extends Container {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if firstChanged is above what was previously visible
|
||||
const previousContentViewportTop = Math.max(0, this.#previousLines.length - height);
|
||||
if (firstChanged < previousContentViewportTop) {
|
||||
const newViewportTop = Math.max(0, newLines.length - height);
|
||||
if (newViewportTop < previousContentViewportTop) {
|
||||
// Viewport needs to shift up — can only be done with a full redraw
|
||||
logRedraw(`viewport shift up (new=${newViewportTop} < prev=${previousContentViewportTop})`);
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
// Viewport is stable or shifting down — skip invisible above-viewport changes
|
||||
firstChanged = previousContentViewportTop;
|
||||
if (lastChanged < firstChanged) {
|
||||
// All changes are above the viewport — nothing visible to update
|
||||
this.#cursorRow = Math.max(0, newLines.length - 1);
|
||||
this.#maxLinesRendered = newLines.length;
|
||||
this.#viewportTopRow = Math.max(0, newLines.length - height);
|
||||
this.#writeCursorPosition(cursorPos, newLines.length);
|
||||
this.#previousLines = newLines;
|
||||
this.#previousWidth = width;
|
||||
this.#previousHeight = height;
|
||||
return;
|
||||
}
|
||||
// Differential rendering can only touch what was actually visible.
|
||||
// Any change above the previous viewport requires a full redraw so terminal
|
||||
// scrollback ends up consistent with the new transcript state.
|
||||
if (firstChanged < prevViewportTop) {
|
||||
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render from first changed line to end
|
||||
@@ -1247,8 +1264,15 @@ export class TUI extends Container {
|
||||
}
|
||||
}
|
||||
truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
|
||||
// Re-append the terminator: truncateToWidth removes trailing
|
||||
// content past the visible-width budget, which may also drop the
|
||||
// terminator appended by #applyLineResets. Match the conditional
|
||||
// OSC 8 close strategy used there.
|
||||
truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
|
||||
}
|
||||
buffer += isImage ? truncatedLine : normalizeTerminalOutput(truncatedLine) + SEGMENT_RESET;
|
||||
// Non-image lines are pre-terminated/normalized by #applyLineResets;
|
||||
// truncated lines re-append LINE_TERMINATOR above.
|
||||
buffer += truncatedLine;
|
||||
}
|
||||
|
||||
// Track where cursor ended up after rendering
|
||||
|
||||
@@ -602,7 +602,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
describe("scrollback integrity", () => {
|
||||
|
||||
Reference in New Issue
Block a user