From cd12b04647b29cb1c8cd1f6f7e5059e326911b2f Mon Sep 17 00:00:00 2001 From: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:07:33 +0000 Subject: [PATCH 1/5] fix(#2879): automated Cursor CLI fix --- components/terminal/keywordHighlight.test.ts | 135 ++++++++++++++++++- components/terminal/keywordHighlight.ts | 64 +++++++-- 2 files changed, 189 insertions(+), 10 deletions(-) diff --git a/components/terminal/keywordHighlight.test.ts b/components/terminal/keywordHighlight.test.ts index 5417cdaa9..3be697094 100644 --- a/components/terminal/keywordHighlight.test.ts +++ b/components/terminal/keywordHighlight.test.ts @@ -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; @@ -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(); @@ -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 { diff --git a/components/terminal/keywordHighlight.ts b/components/terminal/keywordHighlight.ts index fe4584cdd..0408e2aea 100644 --- a/components/terminal/keywordHighlight.ts +++ b/components/terminal/keywordHighlight.ts @@ -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; @@ -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(); @@ -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; 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; @@ -266,6 +290,7 @@ export class KeywordHighlighter implements IDisposable { }) ); this.lastBufferSnapshot = this.readBufferSnapshot(); + this.wasBrowsingScrollback = this.isBrowsingScrollback(); } public setRules(rules: readonly RuntimeKeywordHighlightRule[], enabled: boolean) { @@ -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); } @@ -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(); @@ -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(); @@ -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; } @@ -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; } @@ -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; + } + this.disposeLineDecorations(lineY, existing); this.applyLineDecorations(lineY, cachedRanges, signature, cursorAbsoluteY); } From 9cd756f44ba9419add6012af2816b55d18de6894 Mon Sep 17 00:00:00 2001 From: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:49:20 +0000 Subject: [PATCH 2/5] fix: address Codex review on PR #2931 --- components/terminal/keywordHighlight.test.ts | 68 ++++++++++++++++++++ components/terminal/keywordHighlight.ts | 39 +++++++---- 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/components/terminal/keywordHighlight.test.ts b/components/terminal/keywordHighlight.test.ts index 3be697094..8e18eacfb 100644 --- a/components/terminal/keywordHighlight.test.ts +++ b/components/terminal/keywordHighlight.test.ts @@ -2376,6 +2376,74 @@ test("idle Enter prompt redraw does not repaint existing keyword rows", async () } }); +test("idle Enter keeps suppression across split echo and prompt writes", 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"); + // Batch 1: newline echo advances the buffer. + term.buffer.active.viewportY += 1; + term.buffer.active.baseY += 1; + term.buffer.active.length += 1; + handlers.scroll?.(); + handlers.writeParsed?.(); + await new Promise((resolve) => { setTimeout(resolve, 40); }); + raf.flush(); + + // Batch 2: prompt redraw for the same idle Enter (custom ~/# matches). + setLineText(22, "user@host:~# "); + handlers.writeParsed?.(); + await new Promise((resolve) => { setTimeout(resolve, 220); }); + raf.flush(); + + assert.equal( + existingDecorations.filter(({ isDisposed }) => isDisposed).length, + 0, + "split idle-Enter writes must keep prior keyword decorations mounted", + ); + assert.deepEqual( + refreshCalls, + [], + "a second writeParsed alone must not register prompt decorations that flash the viewport", + ); + highlighter.dispose(); + } finally { + raf.restore(); + } +}); + test("Enter without write clears pending so later user scroll can highlight", async () => { const raf = installAnimationFrameQueue(); try { diff --git a/components/terminal/keywordHighlight.ts b/components/terminal/keywordHighlight.ts index 0408e2aea..b6a22d976 100644 --- a/components/terminal/keywordHighlight.ts +++ b/components/terminal/keywordHighlight.ts @@ -125,8 +125,13 @@ export class KeywordHighlighter implements IDisposable { 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; + /** + * onWriteParsed count for the current Enter submission. Echo + prompt often + * arrive as two batches; a second callback alone is not sustained output. + */ + private enterWriteParsedCount = 0; + /** Lift idle-Enter decoration mute after this many writeParsed callbacks. */ + private static readonly ENTER_SUPPRESS_LIFT_WRITE_COUNT = 3; /** Viewport browsing state before the latest scroll handler ran. */ private wasBrowsingScrollback = false; private static readonly DIRTY_SCAN_PADDING = XTERM_PERFORMANCE_CONFIG.highlighting.dirtyScanPadding; @@ -164,7 +169,7 @@ export class KeywordHighlighter implements IDisposable { // 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; + this.enterWriteParsedCount = 0; // Time-bound Enter protection even when no echo/write arrives (echo // off, stalled PTY). onWriteParsed re-arms this on each write. this.scheduleEnterInputIdleClear(); @@ -235,12 +240,15 @@ export class KeywordHighlighter implements IDisposable { const inputProtectionActive = this.isInputProtectionActive(performance.now()); if (inputProtectionActive || this.enterInputPending) { if (this.enterInputPending) { - 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; + if (this.enterWriteParsedCount > 0) { + this.enterWriteParsedCount += 1; this.updateWriteBurst(); + // Idle Enter often splits echo and prompt across two writeParsed + // batches. Keep decoration mute until sustained output (burst or + // enough follow-up writes) or the Enter idle guard clears. + if (this.shouldLiftEnterDecorationSuppression()) { + this.enterSuppressDecorationMutation = false; + } if (this.enterViewportScanInProgress) { this.enterViewportScanNeedsRepeat = true; } else { @@ -249,7 +257,7 @@ export class KeywordHighlighter implements IDisposable { this.enterViewportScanInProgress = true; } } else { - this.enterWriteParsedSeen = true; + this.enterWriteParsedCount = 1; this.markDirtyFromWrite({ includeViewportProbe: false }); // Index the viewport while muted so multi-frame Enter continuation // can finish; decoration mutate stays suppressed for idle prompt. @@ -456,7 +464,7 @@ export class KeywordHighlighter implements IDisposable { this.enterViewportScanInProgress = false; this.enterViewportScanNeedsRepeat = false; this.enterSuppressDecorationMutation = false; - this.enterWriteParsedSeen = false; + this.enterWriteParsedCount = 0; if (hadDecorations) { this.term.refresh(0, this.term.rows - 1); } @@ -1121,7 +1129,7 @@ export class KeywordHighlighter implements IDisposable { this.enterInputIdleTimer = null; this.enterInputPending = false; this.enterSuppressDecorationMutation = false; - this.enterWriteParsedSeen = false; + this.enterWriteParsedCount = 0; // Catch up any viewport motion deferred while Enter protection blocked // scroll refresh (e.g. user scrolled during the post-Enter window). this.markVisibleRangeDirty(); @@ -1129,6 +1137,15 @@ export class KeywordHighlighter implements IDisposable { }, KeywordHighlighter.ENTER_INPUT_GUARD_MS); } + /** True when follow-up Enter writes look like real command output, not a split prompt. */ + private shouldLiftEnterDecorationSuppression(): boolean { + if (!this.enterSuppressDecorationMutation) return false; + if (this.enterWriteParsedCount >= KeywordHighlighter.ENTER_SUPPRESS_LIFT_WRITE_COUNT) { + return true; + } + return this.isWriteBurstActive(performance.now()); + } + private isBrowsingScrollback(): boolean { const buffer = this.term.buffer.active; return buffer.viewportY < buffer.baseY; From 7408e960cc956cb23913cb41c9bdb482728e7f84 Mon Sep 17 00:00:00 2001 From: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:20:02 +0000 Subject: [PATCH 3/5] fix: address Codex review on PR #2931 --- components/terminal/keywordHighlight.test.ts | 43 ++++++++++++++++---- components/terminal/keywordHighlight.ts | 35 ++++++++-------- 2 files changed, 53 insertions(+), 25 deletions(-) diff --git a/components/terminal/keywordHighlight.test.ts b/components/terminal/keywordHighlight.test.ts index 8e18eacfb..43566f8a9 100644 --- a/components/terminal/keywordHighlight.test.ts +++ b/components/terminal/keywordHighlight.test.ts @@ -1589,15 +1589,38 @@ test("continuous Enter output still refreshes highlights periodically", async () handlers.data?.("\r"); setLineText(22, "DEPLOY"); - for (let index = 0; index < 20; index += 1) { - handlers.writeParsed?.(); - raf.flush(); - await new Promise((resolve) => { setTimeout(resolve, 50); }); + const internals = highlighter as unknown as { + lastRefreshTime: number; + lastUserInputAt: number; + }; + // Sustained output must be a write burst, not spaced callbacks that look + // like a multi-batch prompt redraw. + internals.lastUserInputAt = Number.NEGATIVE_INFINITY; + const originalPerformance = globalThis.performance; + let simulatedNow = 0; + Object.defineProperty(globalThis, "performance", { + configurable: true, + value: { + now: () => simulatedNow, + }, + }); + try { + for (let index = 0; index < 12; index += 1) { + simulatedNow += 10; + internals.lastRefreshTime = Number.NEGATIVE_INFINITY; + handlers.writeParsed?.(); + raf.flush(); + } + } finally { + Object.defineProperty(globalThis, "performance", { + configurable: true, + value: originalPerformance, + }); } assert.ok( decorationStates.some(({ isDisposed }) => !isDisposed), - "ongoing output should not postpone new highlights until the stream stops", + "ongoing write-burst output should not postpone new highlights until the stream stops", ); highlighter.dispose(); } finally { @@ -2422,9 +2445,15 @@ test("idle Enter keeps suppression across split echo and prompt writes", async ( await new Promise((resolve) => { setTimeout(resolve, 40); }); raf.flush(); - // Batch 2: prompt redraw for the same idle Enter (custom ~/# matches). + // Batch 2: prompt text for the same idle Enter (custom ~/# matches). setLineText(22, "user@host:~# "); handlers.writeParsed?.(); + await new Promise((resolve) => { setTimeout(resolve, 40); }); + raf.flush(); + + // Batch 3: trailing mode/control write — still the same prompt redraw, + // not sustained command output. + handlers.writeParsed?.(); await new Promise((resolve) => { setTimeout(resolve, 220); }); raf.flush(); @@ -2436,7 +2465,7 @@ test("idle Enter keeps suppression across split echo and prompt writes", async ( assert.deepEqual( refreshCalls, [], - "a second writeParsed alone must not register prompt decorations that flash the viewport", + "a three-batch idle prompt must not register decorations that flash the viewport", ); highlighter.dispose(); } finally { diff --git a/components/terminal/keywordHighlight.ts b/components/terminal/keywordHighlight.ts index b6a22d976..241b2486a 100644 --- a/components/terminal/keywordHighlight.ts +++ b/components/terminal/keywordHighlight.ts @@ -126,12 +126,11 @@ export class KeywordHighlighter implements IDisposable { /** True while idle-Enter output should not mutate decorations (xterm full-viewport repaint flash). */ private enterSuppressDecorationMutation = false; /** - * onWriteParsed count for the current Enter submission. Echo + prompt often - * arrive as two batches; a second callback alone is not sustained output. + * Whether onWriteParsed has already observed the current Enter submission. + * Prompt redraw can span several writeParsed batches (control sequences, + * prompt text, mode sets); callback count is not a sustained-output signal. */ - private enterWriteParsedCount = 0; - /** Lift idle-Enter decoration mute after this many writeParsed callbacks. */ - private static readonly ENTER_SUPPRESS_LIFT_WRITE_COUNT = 3; + 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; @@ -169,7 +168,7 @@ export class KeywordHighlighter implements IDisposable { // xterm repaints the full viewport on decoration mutation and flashes // still-visible keyword highlights (custom ~/# rules make this obvious). this.enterSuppressDecorationMutation = true; - this.enterWriteParsedCount = 0; + 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(); @@ -240,12 +239,10 @@ export class KeywordHighlighter implements IDisposable { const inputProtectionActive = this.isInputProtectionActive(performance.now()); if (inputProtectionActive || this.enterInputPending) { if (this.enterInputPending) { - if (this.enterWriteParsedCount > 0) { - this.enterWriteParsedCount += 1; + if (this.enterWriteParsedSeen) { this.updateWriteBurst(); - // Idle Enter often splits echo and prompt across two writeParsed - // batches. Keep decoration mute until sustained output (burst or - // enough follow-up writes) or the Enter idle guard clears. + // Multi-batch prompt redraw is still not command output. Keep + // decoration mute until a write burst or the Enter idle guard. if (this.shouldLiftEnterDecorationSuppression()) { this.enterSuppressDecorationMutation = false; } @@ -257,7 +254,7 @@ export class KeywordHighlighter implements IDisposable { this.enterViewportScanInProgress = true; } } else { - this.enterWriteParsedCount = 1; + 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. @@ -464,7 +461,7 @@ export class KeywordHighlighter implements IDisposable { this.enterViewportScanInProgress = false; this.enterViewportScanNeedsRepeat = false; this.enterSuppressDecorationMutation = false; - this.enterWriteParsedCount = 0; + this.enterWriteParsedSeen = false; if (hadDecorations) { this.term.refresh(0, this.term.rows - 1); } @@ -1129,7 +1126,7 @@ export class KeywordHighlighter implements IDisposable { this.enterInputIdleTimer = null; this.enterInputPending = false; this.enterSuppressDecorationMutation = false; - this.enterWriteParsedCount = 0; + 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(); @@ -1137,12 +1134,14 @@ export class KeywordHighlighter implements IDisposable { }, KeywordHighlighter.ENTER_INPUT_GUARD_MS); } - /** True when follow-up Enter writes look like real command output, not a split prompt. */ + /** + * True when follow-up Enter writes look like sustained command output. + * WriteParsed callback count alone cannot prove that — a split prompt may + * need three (or more) batches — so only a write burst lifts mute early. + * Otherwise the Enter idle guard clears suppression after quiet. + */ private shouldLiftEnterDecorationSuppression(): boolean { if (!this.enterSuppressDecorationMutation) return false; - if (this.enterWriteParsedCount >= KeywordHighlighter.ENTER_SUPPRESS_LIFT_WRITE_COUNT) { - return true; - } return this.isWriteBurstActive(performance.now()); } From 60820acf9a72c631bee329e277bf3a598754c9a8 Mon Sep 17 00:00:00 2001 From: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:57:44 +0000 Subject: [PATCH 4/5] fix: address Codex review on PR #2931 --- components/terminal/keywordHighlight.test.ts | 155 +++++++++++++++++++ components/terminal/keywordHighlight.ts | 22 ++- 2 files changed, 174 insertions(+), 3 deletions(-) diff --git a/components/terminal/keywordHighlight.test.ts b/components/terminal/keywordHighlight.test.ts index 43566f8a9..950e4144f 100644 --- a/components/terminal/keywordHighlight.test.ts +++ b/components/terminal/keywordHighlight.test.ts @@ -1628,6 +1628,67 @@ test("continuous Enter output still refreshes highlights periodically", async () } }); +test("slow post-Enter output still applies highlights after the Enter guard", () => { + const raf = installAnimationFrameQueue(); + try { + const { term, decorationStates, handlers, setLineText } = createFakeTerminal("no match", { + 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(); + assert.equal(decorationStates.filter(({ isDisposed }) => !isDisposed).length, 0); + + const internals = highlighter as unknown as { + lastRefreshTime: number; + lastUserInputAt: number; + }; + const originalPerformance = globalThis.performance; + let simulatedNow = 1_000; + Object.defineProperty(globalThis, "performance", { + configurable: true, + value: { + now: () => simulatedNow, + }, + }); + try { + handlers.data?.("\r"); + setLineText(22, "DEPLOY"); + internals.lastUserInputAt = Number.NEGATIVE_INFINITY; + // Intervals above WRITE_BURST_INTERVAL_MS never reach the burst + // threshold, and each write rearms the real idle timer. + for (let index = 0; index < 10; index += 1) { + simulatedNow += 80; + internals.lastRefreshTime = Number.NEGATIVE_INFINITY; + handlers.writeParsed?.(); + raf.flush(); + } + } finally { + Object.defineProperty(globalThis, "performance", { + configurable: true, + value: originalPerformance, + }); + } + + assert.ok( + decorationStates.some(({ isDisposed }) => !isDisposed), + "steady non-bursty output should highlight after the Enter window, not wait for a pause", + ); + highlighter.dispose(); + } finally { + raf.restore(); + } +}); + test("recent user input delays keyword highlight scans until typing is quiet", async () => { const raf = installAnimationFrameQueue(); try { @@ -2473,6 +2534,100 @@ test("idle Enter keeps suppression across split echo and prompt writes", async ( } }); +test("pre-Enter write burst does not lift idle-Enter decoration suppression", () => { + 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(); + + const internals = highlighter as unknown as { + recentWriteBurst: number; + lastWriteAt: number; + lastBurstDecayAt: number; + }; + const originalPerformance = globalThis.performance; + let simulatedNow = 1_000; + Object.defineProperty(globalThis, "performance", { + configurable: true, + value: { + now: () => simulatedNow, + }, + }); + try { + internals.recentWriteBurst = 8; + internals.lastWriteAt = simulatedNow; + internals.lastBurstDecayAt = simulatedNow; + + handlers.data?.("\r"); + term.buffer.active.viewportY += 1; + term.buffer.active.baseY += 1; + term.buffer.active.length += 1; + handlers.scroll?.(); + simulatedNow += 5; + handlers.writeParsed?.(); + raf.flush(); + + setLineText(22, "user@host:~# "); + simulatedNow += 5; + handlers.writeParsed?.(); + raf.flush(); + + simulatedNow += 5; + handlers.writeParsed?.(); + raf.flush(); + } finally { + Object.defineProperty(globalThis, "performance", { + configurable: true, + value: originalPerformance, + }); + } + + assert.equal( + existingDecorations.filter(({ isDisposed }) => isDisposed).length, + 0, + "stale pre-Enter burst must not dispose still-visible keyword decorations", + ); + assert.deepEqual( + refreshCalls, + [], + "stale pre-Enter burst must not register prompt decorations that flash the viewport", + ); + highlighter.dispose(); + } finally { + raf.restore(); + } +}); + test("Enter without write clears pending so later user scroll can highlight", async () => { const raf = installAnimationFrameQueue(); try { diff --git a/components/terminal/keywordHighlight.ts b/components/terminal/keywordHighlight.ts index 241b2486a..20517e9a3 100644 --- a/components/terminal/keywordHighlight.ts +++ b/components/terminal/keywordHighlight.ts @@ -131,6 +131,8 @@ export class KeywordHighlighter implements IDisposable { * prompt text, mode sets); callback count is not a sustained-output signal. */ private enterWriteParsedSeen = false; + /** performance.now() when the current Enter started suppressing mutations. */ + private enterSuppressionStartedAt = 0; /** Viewport browsing state before the latest scroll handler ran. */ private wasBrowsingScrollback = false; private static readonly DIRTY_SCAN_PADDING = XTERM_PERFORMANCE_CONFIG.highlighting.dirtyScanPadding; @@ -169,6 +171,12 @@ export class KeywordHighlighter implements IDisposable { // still-visible keyword highlights (custom ~/# rules make this obvious). this.enterSuppressDecorationMutation = true; this.enterWriteParsedSeen = false; + this.enterSuppressionStartedAt = performance.now(); + // Pre-Enter burst must not lift mute on a split prompt. Keep + // lastWriteAt so output-driven scroll detection still works before + // the first post-Enter updateWriteBurst. + this.recentWriteBurst = 0; + this.lastBurstDecayAt = 0; // Time-bound Enter protection even when no echo/write arrives (echo // off, stalled PTY). onWriteParsed re-arms this on each write. this.scheduleEnterInputIdleClear(); @@ -462,6 +470,7 @@ export class KeywordHighlighter implements IDisposable { this.enterViewportScanNeedsRepeat = false; this.enterSuppressDecorationMutation = false; this.enterWriteParsedSeen = false; + this.enterSuppressionStartedAt = 0; if (hadDecorations) { this.term.refresh(0, this.term.rows - 1); } @@ -1127,6 +1136,7 @@ export class KeywordHighlighter implements IDisposable { this.enterInputPending = false; this.enterSuppressDecorationMutation = false; this.enterWriteParsedSeen = false; + this.enterSuppressionStartedAt = 0; // Catch up any viewport motion deferred while Enter protection blocked // scroll refresh (e.g. user scrolled during the post-Enter window). this.markVisibleRangeDirty(); @@ -1137,12 +1147,18 @@ export class KeywordHighlighter implements IDisposable { /** * True when follow-up Enter writes look like sustained command output. * WriteParsed callback count alone cannot prove that — a split prompt may - * need three (or more) batches — so only a write burst lifts mute early. - * Otherwise the Enter idle guard clears suppression after quiet. + * need three (or more) batches — so a write burst lifts mute early. + * Slow streams never burst and keep rearming the idle timer; after the + * Enter window, prompt redraw is done and mutation is safe. */ private shouldLiftEnterDecorationSuppression(): boolean { if (!this.enterSuppressDecorationMutation) return false; - return this.isWriteBurstActive(performance.now()); + const now = performance.now(); + if (this.isWriteBurstActive(now)) return true; + return ( + this.enterSuppressionStartedAt > 0 + && now - this.enterSuppressionStartedAt >= KeywordHighlighter.ENTER_INPUT_GUARD_MS + ); } private isBrowsingScrollback(): boolean { From d1cd072502124666bb094dd73cd3e6b5a4fdbe39 Mon Sep 17 00:00:00 2001 From: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:26:22 +0000 Subject: [PATCH 5/5] fix: address Codex review on PR #2931 --- components/terminal/keywordHighlight.test.ts | 4 ++++ components/terminal/keywordHighlight.ts | 17 ++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/components/terminal/keywordHighlight.test.ts b/components/terminal/keywordHighlight.test.ts index 950e4144f..ed48dd875 100644 --- a/components/terminal/keywordHighlight.test.ts +++ b/components/terminal/keywordHighlight.test.ts @@ -1156,6 +1156,10 @@ test("user scroll during Enter keeps prior highlights mounted", async () => { getTranslatedLineIndexes().some((lineY) => lineY >= 10 && lineY < 20), "scrollback browsing during Enter should synchronously scan newly revealed lines", ); + assert.ok( + decorationStates.some((state) => !state.isDisposed && state.line >= 10 && state.line < 13), + "scrollback browsing during Enter should apply highlights on newly revealed lines", + ); raf.flush(); assert.equal( diff --git a/components/terminal/keywordHighlight.ts b/components/terminal/keywordHighlight.ts index 20517e9a3..6047e7350 100644 --- a/components/terminal/keywordHighlight.ts +++ b/components/terminal/keywordHighlight.ts @@ -1584,6 +1584,11 @@ export class KeywordHighlighter implements IDisposable { if (end < start) return; const buffer = this.term.buffer.active; const pressure = getTerminalOutputPressure(this.term); + // Idle Enter must not mutate prompt-area decorations (xterm full-viewport + // flash). A real scrollback browse still needs new decorations: the scroll + // path records lastRenderRange even when this guard skips apply. + const allowEnterBrowseDecorations = this.enterSuppressDecorationMutation + && this.isBrowsingScrollback(); for (let lineY = start; lineY <= end; lineY++) { const line = buffer.getLine(lineY); if (!line) { @@ -1627,10 +1632,16 @@ 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. + // Idle Enter: keep prior decorations mounted. Scrollback browse still + // creates decorations on unindexed lines so matches are visible before + // the 600ms guard; lines that already have decorations stay untouched. if (this.enterSuppressDecorationMutation) { - continue; + if (!allowEnterBrowseDecorations) { + continue; + } + if (existing && existing.decorations.length > 0) { + continue; + } } this.disposeLineDecorations(lineY, existing);