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:
can1357
2026-05-12 10:37:15 +02:00
parent b4b6536838
commit 093c13877a
3 changed files with 72 additions and 29 deletions
+20 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -602,7 +602,7 @@ describe("TUI terminal-state regressions", () => {
} finally {
tui.stop();
}
});
}, 15_000);
});
describe("scrollback integrity", () => {