Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 133 additions & 2 deletions components/terminal/keywordHighlight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1417,7 +1417,9 @@ test("Enter dirty work continues after a queued full refresh", async () => {
handlers.data?.("\r");
setLineText(22, "redrawn without a keyword");
handlers.writeParsed?.();
await new Promise((resolve) => { setTimeout(resolve, 340); });
// Idle Enter suppresses decoration mutation until the Enter guard clears
// (~600ms) so prompt redraw cannot flash still-visible keywords.
await new Promise((resolve) => { setTimeout(resolve, 850); });
raf.flush();

const originalDisposed = originalDecoration.isDisposed;
Expand Down Expand Up @@ -1923,7 +1925,9 @@ test("Enter input still detects redraws away from the cursor", async () => {
handlers.data?.("\r");
setLineText(20, "redrawn without a keyword");
handlers.writeParsed?.();
await new Promise((resolve) => { setTimeout(resolve, 220); });
// Idle Enter defers decoration dispose/apply until the Enter guard clears.
await new Promise((resolve) => { setTimeout(resolve, 850); });
raf.flush();

assert.equal(originalDecoration.isDisposed, true);
highlighter.dispose();
Expand Down Expand Up @@ -2245,6 +2249,133 @@ test("idle Enter scroll before writeParsed does not rescan visible keywords", ()
}
});

test("idle Enter scroll before buffer dims update does not rescan", () => {
const raf = installAnimationFrameQueue();
try {
const {
term,
decorationStates,
handlers,
getTranslateCount,
resetTranslateCount,
refreshCalls,
resetRefreshCalls,
} = createFakeTerminal("hello DEPLOY world", { lineCount: 40 });
term.buffer.active.viewportY = 20;
term.buffer.active.baseY = 20;
term.buffer.active.cursorY = 2;
const highlighter = new KeywordHighlighter(term as never);
highlighter.setRules([{
id: "deploy",
label: "Deploy",
patterns: ["DEPLOY"],
color: "#F87171",
enabled: true,
}], true);
raf.flush();
const existingDecorations = [...decorationStates];
assert.ok(existingDecorations.length > 0);

const internals = highlighter as unknown as {
lastWriteAt: number;
lastRenderRange: { start: number; end: number } | null;
};
internals.lastWriteAt = performance.now() - 10_000;
internals.lastRenderRange = null;
resetTranslateCount();
resetRefreshCalls();

// Ubuntu RTT: onScroll can fire while length/baseY/cursor still match the
// last snapshot, so output-driven detection is false. Bottom-pinned Enter
// must still defer — requiring hasOutputDrivenViewportChange reopens flash.
handlers.data?.("\r");
handlers.scroll?.();

assert.equal(
getTranslateCount(),
0,
"Enter-pending bottom scroll without buffer-dim change must not rescan",
);
assert.deepEqual(
refreshCalls,
[],
"Enter-pending bottom scroll without buffer-dim change must not repaint",
);
assert.equal(
existingDecorations.filter(({ isDisposed }) => isDisposed).length,
0,
"Enter-pending bottom scroll must keep existing keyword decorations mounted",
);
highlighter.dispose();
} finally {
raf.restore();
}
});

test("idle Enter prompt redraw does not repaint existing keyword rows", async () => {
const raf = installAnimationFrameQueue();
try {
const {
term,
decorationStates,
handlers,
setLineText,
refreshCalls,
resetRefreshCalls,
} = createFakeTerminal("hello DEPLOY world", { lineCount: 40 });
term.buffer.active.viewportY = 20;
term.buffer.active.baseY = 20;
term.buffer.active.cursorY = 2;
const highlighter = new KeywordHighlighter(term as never);
highlighter.setRules([
{
id: "deploy",
label: "Deploy",
patterns: ["DEPLOY"],
color: "#F87171",
enabled: true,
},
{
id: "prompt",
label: "Prompt",
patterns: ["~", "#"],
color: "#60A5FA",
enabled: true,
},
], true);
raf.flush();
const existingDecorations = [...decorationStates];
assert.ok(existingDecorations.length > 0);
resetRefreshCalls();

handlers.data?.("\r");
term.buffer.active.viewportY += 1;
term.buffer.active.baseY += 1;
term.buffer.active.length += 1;
// New prompt line matches custom ~/# rules — applying those decorations
// makes xterm repaint the full viewport and flashes still-visible keywords.
setLineText(22, "user@host:~# ");
handlers.scroll?.();
handlers.writeParsed?.();
await new Promise((resolve) => { setTimeout(resolve, 220); });
raf.flush();

assert.equal(
existingDecorations.filter(({ isDisposed }) => isDisposed).length,
0,
"idle Enter must keep prior keyword decorations mounted",
);
assert.deepEqual(
refreshCalls,
[],
"idle Enter prompt redraw must not register decorations that force a viewport repaint",
);
highlighter.dispose();
} finally {
raf.restore();
}
});

test("Enter without write clears pending so later user scroll can highlight", async () => {
const raf = installAnimationFrameQueue();
try {
Expand Down
64 changes: 56 additions & 8 deletions components/terminal/keywordHighlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ export class KeywordHighlighter implements IDisposable {
private enterQueuedWriteCancellationPending = false;
private enterViewportScanInProgress = false;
private enterViewportScanNeedsRepeat = false;
/** True while idle-Enter output should not mutate decorations (xterm full-viewport repaint flash). */
private enterSuppressDecorationMutation = false;
/** Whether onWriteParsed has already observed the current Enter submission. */
private enterWriteParsedSeen = false;
/** Viewport browsing state before the latest scroll handler ran. */
private wasBrowsingScrollback = false;
private static readonly DIRTY_SCAN_PADDING = XTERM_PERFORMANCE_CONFIG.highlighting.dirtyScanPadding;
private static readonly INPUT_QUIET_MS = XTERM_PERFORMANCE_CONFIG.highlighting.inputQuietMs;
private static readonly WRITE_BURST_INTERVAL_MS = 28;
Expand Down Expand Up @@ -154,6 +160,11 @@ export class KeywordHighlighter implements IDisposable {
if (data.includes("\r") || data.includes("\n")) {
this.enterInputPending = true;
this.enterQueuedWriteCancellationPending = true;
// First prompt redraw after Enter must not register/dispose decorations:
// xterm repaints the full viewport on decoration mutation and flashes
// still-visible keyword highlights (custom ~/# rules make this obvious).
this.enterSuppressDecorationMutation = true;
this.enterWriteParsedSeen = false;
// Time-bound Enter protection even when no echo/write arrives (echo
// off, stalled PTY). onWriteParsed re-arms this on each write.
this.scheduleEnterInputIdleClear();
Expand Down Expand Up @@ -224,11 +235,24 @@ export class KeywordHighlighter implements IDisposable {
const inputProtectionActive = this.isInputProtectionActive(performance.now());
if (inputProtectionActive || this.enterInputPending) {
if (this.enterInputPending) {
if (this.enterViewportScanInProgress) {
if (this.enterWriteParsedSeen) {
// Sustained Enter output: allow decoration catch-up so new matches
// are not postponed until the stream goes idle. Do not re-add the
// whole viewport dirty range — that rewinds multi-frame scans.
this.enterSuppressDecorationMutation = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep suppression across split prompt writes

When a single idle Enter's echo and prompt arrive in two onWriteParsed batches, the second callback reaches this branch and clears suppression even though both writes belong to the same idle submission. The queued scan then disposes or registers prompt-row decorations—for example with custom ~/# rules—causing the exact full-viewport repaint flash this change is intended to prevent. A second callback alone is not a reliable signal of sustained command output, so suppression should remain until an actual output/quiet criterion is met.

Useful? React with 👍 / 👎.

this.updateWriteBurst();
this.enterViewportScanNeedsRepeat = true;
if (this.enterViewportScanInProgress) {
this.enterViewportScanNeedsRepeat = true;
} else {
const buffer = this.term.buffer.active;
this.addDirtyRange(buffer.viewportY, buffer.viewportY + this.term.rows - 1);
this.enterViewportScanInProgress = true;
}
} else {
this.enterWriteParsedSeen = true;
this.markDirtyFromWrite({ includeViewportProbe: false });
// Index the viewport while muted so multi-frame Enter continuation
// can finish; decoration mutate stays suppressed for idle prompt.
const buffer = this.term.buffer.active;
this.addDirtyRange(buffer.viewportY, buffer.viewportY + this.term.rows - 1);
this.enterViewportScanInProgress = true;
Expand Down Expand Up @@ -266,6 +290,7 @@ export class KeywordHighlighter implements IDisposable {
})
);
this.lastBufferSnapshot = this.readBufferSnapshot();
this.wasBrowsingScrollback = this.isBrowsingScrollback();
}

public setRules(rules: readonly RuntimeKeywordHighlightRule[], enabled: boolean) {
Expand Down Expand Up @@ -430,6 +455,8 @@ export class KeywordHighlighter implements IDisposable {
this.enterQueuedWriteCancellationPending = false;
this.enterViewportScanInProgress = false;
this.enterViewportScanNeedsRepeat = false;
this.enterSuppressDecorationMutation = false;
this.enterWriteParsedSeen = false;
if (hadDecorations) {
this.term.refresh(0, this.term.rows - 1);
}
Expand Down Expand Up @@ -715,16 +742,23 @@ export class KeywordHighlighter implements IDisposable {

private triggerViewportChangeRefresh() {
const isBrowsingScrollback = this.isBrowsingScrollback();
// End (or other jump-to-bottom) while Enter is pending: we were browsing
// scrollback and just landed on the bottom without waiting for echo.
// Distinguish that from idle-Enter echo still pinned at the bottom.
const returningToBottomFromScrollback =
this.wasBrowsingScrollback && !isBrowsingScrollback;
this.wasBrowsingScrollback = isBrowsingScrollback;
// Enter echo often emits onScroll before onWriteParsed. After an idle gap
// lastWriteAt looks stale and lastRenderRange is usually null (cleared by
// the previous write refresh), so the output-driven scroll path would
// synchronously rescan the viewport and flash keywords still on screen.
// Keep real scrollback browsing synchronous; only defer the bottom-pinned
// viewport movement that can be caused by the pending Enter echo.
// Keep real scrollback browsing synchronous; defer bottom-pinned Enter
// echo even when buffer dims have not updated yet (Ubuntu RTT). Do not
// require hasOutputDrivenViewportChange — that hole reopened the flash.
if (
this.enterInputPending
&& !isBrowsingScrollback
&& this.hasOutputDrivenViewportChange()
&& !returningToBottomFromScrollback
) {
if (this.pendingRefreshReason === "scroll") {
this.cancelQueuedRefreshSchedule();
Expand Down Expand Up @@ -1086,6 +1120,8 @@ export class KeywordHighlighter implements IDisposable {
this.enterInputIdleTimer = setTimeout(() => {
this.enterInputIdleTimer = null;
this.enterInputPending = false;
this.enterSuppressDecorationMutation = false;
this.enterWriteParsedSeen = false;
// Catch up any viewport motion deferred while Enter protection blocked
// scroll refresh (e.g. user scrolled during the post-Enter window).
this.markVisibleRangeDirty();
Expand Down Expand Up @@ -1519,13 +1555,17 @@ export class KeywordHighlighter implements IDisposable {
for (let lineY = start; lineY <= end; lineY++) {
const line = buffer.getLine(lineY);
if (!line) {
this.disposeLineDecorations(lineY);
if (!this.enterSuppressDecorationMutation) {
this.disposeLineDecorations(lineY);
}
continue;
}

const lineText = line.translateToString(true); // true = trim right whitespace
if (!lineText) {
this.disposeLineDecorations(lineY);
if (!this.enterSuppressDecorationMutation) {
this.disposeLineDecorations(lineY);
}
continue;
}

Expand All @@ -1536,7 +1576,9 @@ export class KeywordHighlighter implements IDisposable {
: this.scanWrappedLine(buffer, lineY, line, lineText, wrappedBlockCache)
: this.getCachedRanges(line, lineText);
if (cachedRanges.length === 0) {
this.disposeLineDecorations(lineY);
if (!this.enterSuppressDecorationMutation) {
this.disposeLineDecorations(lineY);
}
continue;
}

Expand All @@ -1553,6 +1595,12 @@ export class KeywordHighlighter implements IDisposable {
continue;
}

// Idle Enter: keep prior decorations mounted and skip new ones until the
// guard clears or sustained Enter output opts back into mutation.
if (this.enterSuppressDecorationMutation) {
continue;
}
Comment on lines +1638 to +1645

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply highlights when users browse scrollback during Enter

When a user presses Enter and immediately scrolls to previously unindexed scrollback, the synchronous scroll refresh reaches this guard and skips every new decoration because Enter suppression is still active. processScrollViewport nevertheless records the range as covered, so matching lines remain visibly unhighlighted until the 600 ms Enter guard plus debounce expires; suppression should preserve existing prompt-area decorations without blocking decoration creation for a real scrollback browse.

Useful? React with 👍 / 👎.


this.disposeLineDecorations(lineY, existing);
this.applyLineDecorations(lineY, cachedRanges, signature, cursorAbsoluteY);
}
Expand Down
Loading