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
66 changes: 66 additions & 0 deletions components/terminal/keywordHighlight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2083,6 +2083,72 @@ test("pressing Enter does not repaint after keyword markers move", async () => {
}
});

test("idle Enter scroll before writeParsed does not rescan visible keywords", () => {
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);

// Ordinary write refreshes clear lastRenderRange. An idle prompt then has no
// scroll coverage hint, so Enter echo onScroll (before writeParsed, Ubuntu RTT)
// would otherwise take the immediate user-scroll path and rescan the viewport.
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();

handlers.data?.("\r");
term.buffer.active.viewportY += 1;
term.buffer.active.baseY += 1;
term.buffer.active.length += 1;
handlers.scroll?.();

assert.equal(
getTranslateCount(),
0,
"Enter-pending scroll before writeParsed must not rescan visible keywords",
);
assert.deepEqual(
refreshCalls,
[],
"Enter-pending scroll before writeParsed must not force a keyword repaint",
);
assert.equal(
existingDecorations.filter(({ isDisposed }) => isDisposed).length,
0,
"Enter-pending scroll must keep existing keyword decorations mounted",
);
highlighter.dispose();
} finally {
raf.restore();
}
});

test("long-line pressure avoids scanning across a whole soft-wrapped logical line", () => {
const raf = installAnimationFrameQueue();
try {
Expand Down
25 changes: 25 additions & 0 deletions components/terminal/keywordHighlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ export class KeywordHighlighter implements IDisposable {
clearTimeout(this.enterInputIdleTimer);
this.enterInputIdleTimer = null;
}
// Drop any pending user-scroll refresh so Enter echo cannot finish a
// scroll pass that rescans/repaints still-visible keyword decorations
// before onWriteParsed owns the write path (Ubuntu RTT).
if (this.pendingRefreshReason === "scroll") {
this.cancelQueuedRefreshSchedule();
this.pendingRefreshReason = "write";
}
}
}),
// When new data is written, refresh on the next frame so highlights land
Expand Down Expand Up @@ -706,6 +713,20 @@ export class KeywordHighlighter implements IDisposable {
}

private triggerViewportChangeRefresh() {
// 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 user-scroll path would synchronously
// rescan the viewport and flash keywords still on screen. While Enter
// output is pending, skip scroll refresh but mark dirty so writeParsed /
// idle-clear can catch up (including user scroll during the window).
if (this.enterInputPending) {
if (this.pendingRefreshReason === "scroll") {
this.cancelQueuedRefreshSchedule();
this.pendingRefreshReason = "write";
}
this.markVisibleRangeDirty();
return;
Comment on lines +733 to +734

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 Bound Enter-pending scroll suppression without output

When the remote PTY produces no write after Enter—for example, with echo disabled or a stalled connection—onWriteParsed never runs. enterInputPending is set by onData, but its only clear timer is armed inside onWriteParsed, so this new early return suppresses every subsequent user-scroll refresh indefinitely. Newly revealed scrollback lines therefore remain unscanned and unhighlighted until unrelated output arrives; arm a fallback timer from onData or otherwise time-bound this guard.

Useful? React with 👍 / 👎.

}
const now = performance.now();
const buffer = this.term.buffer.active;
const isBrowsingScrollback = buffer.viewportY < buffer.baseY;
Expand Down Expand Up @@ -1061,6 +1082,10 @@ export class KeywordHighlighter implements IDisposable {
this.enterInputIdleTimer = setTimeout(() => {
this.enterInputIdleTimer = null;
this.enterInputPending = false;
// Catch up any viewport motion deferred while Enter protection blocked
// scroll refresh (e.g. user scrolled during the post-Enter window).
this.markVisibleRangeDirty();
this.triggerRefresh("debounced", "write");

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 Honor bulk-output pressure before Enter catch-up

When Enter launches output that trips large-output pressure while scrollback is saturated, the pressure state intentionally remains active for two largeOutputQuietMs windows (about 960 ms), and scheduleBulkPressureCatchUp() polls until pressure.largeOutput is false before scanning. This new Enter idle timer fires at 600 ms and schedules a normal write refresh anyway; under the configured large-output debounce that can execute at about 880 ms, reintroducing keyword scans/decoration work during the protected bulk window. Please route this catch-up through the bulk-pressure path or skip scheduling while output pressure is still active.

Useful? React with 👍 / 👎.

}, KeywordHighlighter.WRITE_PRUNE_IDLE_MS);
}

Expand Down