diff --git a/components/terminal/keywordHighlight.test.ts b/components/terminal/keywordHighlight.test.ts index ed3b5b557..5417cdaa9 100644 --- a/components/terminal/keywordHighlight.test.ts +++ b/components/terminal/keywordHighlight.test.ts @@ -1121,7 +1121,13 @@ test("Enter-driven scroll does not dispose nearby keyword decorations", async () test("user scroll during Enter keeps prior highlights mounted", async () => { const raf = installAnimationFrameQueue(); try { - const { term, decorationStates, handlers } = createFakeTerminal("hello DEPLOY world", { + const { + term, + decorationStates, + handlers, + getTranslatedLineIndexes, + resetTranslateCount, + } = createFakeTerminal("hello DEPLOY world", { lineCount: 80, }); term.rows = 3; @@ -1143,8 +1149,15 @@ test("user scroll during Enter keeps prior highlights mounted", async () => { handlers.data?.("\r"); handlers.writeParsed?.(); term.buffer.active.viewportY = 10; + resetTranslateCount(); handlers.scroll?.(); + assert.ok( + getTranslatedLineIndexes().some((lineY) => lineY >= 10 && lineY < 20), + "scrollback browsing during Enter should synchronously scan newly revealed lines", + ); + raf.flush(); + assert.equal( existingDecorations.filter(({ isDisposed }) => isDisposed).length, 0, @@ -1156,6 +1169,89 @@ test("user scroll during Enter keeps prior highlights mounted", async () => { } }); +test("Enter does not defer a user scroll back to the bottom", () => { + const raf = installAnimationFrameQueue(); + try { + const { + term, + handlers, + getTranslatedLineIndexes, + resetTranslateCount, + } = createFakeTerminal("hello DEPLOY world", { lineCount: 80 }); + term.rows = 3; + 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(); + + handlers.data?.("\r"); + term.buffer.active.viewportY = 10; + handlers.scroll?.(); + resetTranslateCount(); + + // Pressing End while the Enter guard is active returns to the bottom + // without changing the output position or waiting for a remote write. + term.buffer.active.viewportY = 20; + handlers.scroll?.(); + + assert.ok( + getTranslatedLineIndexes().some((lineY) => lineY >= 20), + "scrolling back to the bottom during Enter should scan synchronously", + ); + highlighter.dispose(); + } finally { + raf.restore(); + } +}); + +test("output during Enter does not cancel an active scrollback browse", () => { + const raf = installAnimationFrameQueue(); + try { + const { term, handlers } = createFakeTerminal("hello DEPLOY world", { lineCount: 80 }); + 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(); + + handlers.data?.("\r"); + term.buffer.active.viewportY = 10; + const internals = highlighter as unknown as { + pendingRefreshReason: "scroll" | "write" | "full"; + }; + // Model a scroll refresh that is pending while the user is browsing + // scrollback, then let remote output move the bottom of the buffer. + internals.pendingRefreshReason = "scroll"; + term.buffer.active.baseY += 1; + term.buffer.active.length += 1; + handlers.writeParsed?.(); + + assert.equal( + internals.pendingRefreshReason, + "scroll", + "remote output must not reclassify an active scrollback browse as Enter output", + ); + highlighter.dispose(); + } finally { + raf.restore(); + } +}); + test("large output delays keyword highlight scans until output quiets", async () => { const raf = installAnimationFrameQueue(); try { @@ -2083,6 +2179,129 @@ 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("Enter without write clears pending so later user scroll can highlight", async () => { + const raf = installAnimationFrameQueue(); + try { + const { + term, + handlers, + getTranslateCount, + resetTranslateCount, + } = 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 internals = highlighter as unknown as { + enterInputPending: boolean; + lastWriteAt: number; + lastRenderRange: { start: number; end: number } | null; + }; + internals.lastWriteAt = performance.now() - 10_000; + internals.lastRenderRange = null; + + // Enter with no echo/writeParsed (echo off / stalled PTY). + handlers.data?.("\r"); + assert.equal(internals.enterInputPending, true); + + await new Promise((resolve) => { setTimeout(resolve, 700); }); + assert.equal( + internals.enterInputPending, + false, + "Enter protection must time out when no write arrives", + ); + + // User browsing scrollback after the timed-out Enter guard. + resetTranslateCount(); + term.buffer.active.viewportY = 10; + handlers.scroll?.(); + raf.flush(); + + assert.ok( + getTranslateCount() > 0, + "user scroll after timed-out Enter must scan newly revealed scrollback", + ); + highlighter.dispose(); + } finally { + raf.restore(); + } +}); + test("long-line pressure avoids scanning across a whole soft-wrapped logical line", () => { const raf = installAnimationFrameQueue(); try { diff --git a/components/terminal/keywordHighlight.ts b/components/terminal/keywordHighlight.ts index 6a2ff96af..fe4584cdd 100644 --- a/components/terminal/keywordHighlight.ts +++ b/components/terminal/keywordHighlight.ts @@ -134,6 +134,7 @@ export class KeywordHighlighter implements IDisposable { private static readonly WRITE_BURST_DEBOUNCE_MS = 180; private static readonly WRITE_BURST_IMMEDIATE_MIN_INTERVAL_MS = 48; private static readonly WRITE_BURST_HIGHLIGHT_PAUSE_MS = 260; + private static readonly ENTER_INPUT_GUARD_MS = 600; private static readonly WRITE_PRUNE_IDLE_MS = 600; constructor(term: XTerm) { @@ -153,9 +154,15 @@ export class KeywordHighlighter implements IDisposable { if (data.includes("\r") || data.includes("\n")) { this.enterInputPending = true; this.enterQueuedWriteCancellationPending = true; - if (this.enterInputIdleTimer) { - clearTimeout(this.enterInputIdleTimer); - this.enterInputIdleTimer = null; + // Time-bound Enter protection even when no echo/write arrives (echo + // off, stalled PTY). onWriteParsed re-arms this on each write. + this.scheduleEnterInputIdleClear(); + // 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.isBrowsingScrollback()) { + this.cancelQueuedRefreshSchedule(); + this.pendingRefreshReason = "write"; } } }), @@ -165,11 +172,12 @@ export class KeywordHighlighter implements IDisposable { if (this.enterInputPending) { this.scheduleEnterInputIdleClear(); } + const isBrowsingScrollback = this.isBrowsingScrollback(); const outputDrivenPendingScroll = this.pendingRefreshReason === "scroll" + && !isBrowsingScrollback && ( - this.hasOutputPositionChangedSinceLastSnapshot() - || this.hasDecorationMarkerShiftSinceLastRefresh() + this.hasOutputDrivenViewportChange() ); const cancelQueuedWriteForEnter = this.enterQueuedWriteCancellationPending @@ -706,9 +714,26 @@ export class KeywordHighlighter implements IDisposable { } private triggerViewportChangeRefresh() { + const isBrowsingScrollback = this.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. + if ( + this.enterInputPending + && !isBrowsingScrollback + && this.hasOutputDrivenViewportChange() + ) { + if (this.pendingRefreshReason === "scroll") { + this.cancelQueuedRefreshSchedule(); + this.pendingRefreshReason = "write"; + } + this.markVisibleRangeDirty(); + return; + } const now = performance.now(); - const buffer = this.term.buffer.active; - const isBrowsingScrollback = buffer.viewportY < buffer.baseY; const isOutputDrivenViewportChange = !isBrowsingScrollback && this.lastWriteAt > 0 && @@ -1061,7 +1086,23 @@ export class KeywordHighlighter implements IDisposable { this.enterInputIdleTimer = setTimeout(() => { this.enterInputIdleTimer = null; this.enterInputPending = false; - }, KeywordHighlighter.WRITE_PRUNE_IDLE_MS); + // 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"); + }, KeywordHighlighter.ENTER_INPUT_GUARD_MS); + } + + private isBrowsingScrollback(): boolean { + const buffer = this.term.buffer.active; + return buffer.viewportY < buffer.baseY; + } + + private hasOutputDrivenViewportChange(): boolean { + return ( + this.hasOutputPositionChangedSinceLastSnapshot() + || this.hasDecorationMarkerShiftSinceLastRefresh() + ); } private mergeRefreshReason(current: RefreshReason, next: RefreshReason): RefreshReason {