From d4fa0917144ddfd666ddc4c37bcc841034556fcb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:22:29 -0700 Subject: [PATCH] perf(terminal): repaint only the rows an agent redraw touched (#18169) Forced foreground repaints asked xterm for rows 0..rows-1. xterm's render debouncer unions ranges, so one full-grid request widened every frame to a whole-viewport `_updateModel` cell walk even when the write changed five rows. Re-issue the repair over the parse's own dirty span instead, keeping the whole grid for viewport scroll, alternate-screen flips, and any write whose span cannot be observed. --- config/reliability-gates.jsonc | 84 ++++ .../pane-terminal-foreground-render-settle.ts | 82 +++- ...inal-foreground-repair-convergence.test.ts | 364 ++++++++++++++++++ .../terminal-parsed-dirty-rows.ts | 119 ++++++ 4 files changed, 635 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-parsed-dirty-rows.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 83de3128ecf..c36412c0383 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -13863,6 +13863,90 @@ "knownGaps": ["No manifest command yet.", "No Windows CJK/emoji repaint command is wired."], "demotionRule": "Cannot promote if the oracle is screenshot-only or environment-skipped." }, + { + "id": "terminal-render.foreground-repair-span", + "title": "A forced foreground repaint covers every row the write changed", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "renderer-unit", + "surfaces": [ + "foreground PTY output", + "in-place agent redraws", + "erase-in-line/display", + "alternate screen", + "scroll", + "wide glyphs" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": [], + "coverageNotes": "Renderer-unit convergence corpus over a real xterm parser, plus manual CDP pixel evidence on the macOS WebGL renderer. The repaint span is provider-independent because it is computed from xterm's parse, not from the transport; SSH/WSL/remote were not exercised live.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/2669", + "https://github.com/stablyai/orca/pull/4669", + "https://github.com/stablyai/orca/pull/8178" + ], + "invariant": "The row span Orca asks xterm to repaint after a forced foreground refresh must cover every viewport row whose rendered content changed during that write, plus the cursor row before and after it; when the span cannot be established — unobservable parse, viewport scroll, or a normal/alternate buffer flip — the whole viewport must be repainted.", + "oracle": "A real @xterm/headless parser replays an adversarial corpus (in-place bottom-row redraws, standalone CR overwrite, backspace, erase-in-line, erase-in-display above and below the cursor, full clear, wide CJK, emoji, combining marks, ZWJ sequences, scroll-region insert/delete, reverse index, DEC 2026 frames, alternate-screen enter and exit, viewport scroll, narrow panes). Each viewport row is serialized cell-by-cell with its attributes before and after the write, and every row that differs must fall inside the span the settle path requested. A vacuity guard asserts each case actually moves the screen.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts" + ], + "testFiles": [ + "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts", + "assertions": [ + "every viewport row whose serialized cells changed lies inside the requested repaint span", + "the cursor row before and after the write is inside the requested repaint span", + "viewport scroll and alternate-screen transitions still request the whole grid", + "an unobservable parse span falls back to the whole grid", + "an in-place bottom-row redraw narrows well below the full grid" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-02", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts", + "durationSeconds": 1, + "summary": "25 cases passed against a real xterm parser; paired CDP run on a 4-pane macOS WebGL dev build produced screenshots byte-identical to a forced full model rebuild." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "Renderer-unit convergence corpus" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "New deterministic gate; no soak history yet." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Narrowing the span to the cursor rows alone (dropping xterm's parse span) fails the claude-style in-place redraw and erase-in-display-above cases; reading buffer indices instead of viewport rows made the corpus vacuous and is now blocked by the changed-row guard." + }, + "performanceBudget": { + "required": true, + "evidence": "Measured on a focused, visible 4-pane macOS dev build under an agent-style in-place redraw load: rendered cells/s 157,708 -> 30,139 and forEachDecorationAtCell 320,868/s -> 60,652/s with render frames/s unchanged (59.8 -> 60.5)." + }, + "promotionCriteria": [ + "Add Windows DOM-renderer coverage for the synchronous repair branch.", + "Wire pixel or cell evidence for the alternate-screen and reflow paths into CI rather than manual CDP runs.", + "Keep a full-grid fallback assertion for every new span-narrowing condition." + ], + "knownGaps": [ + "No CI-wired pixel oracle; WebGL convergence evidence was collected manually over CDP.", + "Windows ConPTY synchronous repair path is covered only by the shared corpus, not on a Windows runner.", + "SSH/WSL/remote providers were not exercised live; the span is transport-independent by construction." + ], + "demotionRule": "Demote or block if a narrowing condition is added without a matching convergence case, if the corpus stops asserting that each case changes at least one row, or if a repaint regression is reported for in-place agent redraws." + }, { "id": "terminal-shell.windows-resolution-parity", "title": "Windows local and daemon providers resolve shells and startup commands consistently", diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts index 508fb7e1240..89198580cf3 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts @@ -1,9 +1,16 @@ import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' +import { + disposeParsedDirtyRows, + readParsedDirtyRowSpan, + resetParsedDirtyRows, + type ParsedDirtyRowSpan +} from './terminal-parsed-dirty-rows' import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' export type ForegroundTerminalOutputTarget = { buffer?: { active?: { + type?: string cursorY?: number baseY?: number viewportY?: number @@ -32,6 +39,8 @@ const pendingViewportSettleRefreshByTerminal = new WeakMap< >() type ViewportSnapshot = { + type: string | null + cursorY: number | null baseY: number | null viewportY: number | null } @@ -39,7 +48,8 @@ type ViewportSnapshot = { function refreshVisibleRows( terminal: ForegroundTerminalOutputTarget, synchronously: boolean, - shouldReleaseRenderPause?: () => boolean + shouldReleaseRenderPause?: () => boolean, + span?: ParsedDirtyRowSpan | null ): void { if (typeof terminal.rows !== 'number' || terminal.rows < 1) { return @@ -51,10 +61,14 @@ function refreshVisibleRows( if (shouldReleaseRenderPause?.() === true && forceRepaintThroughRenderPause(terminal)) { return } - const start = 0 - const end = Math.max(0, terminal.rows - 1) + const lastRow = Math.max(0, terminal.rows - 1) + // Why not always the whole grid: xterm's render debouncer unions ranges, so a + // 0..rows-1 repair request turns every frame into a full-viewport cell walk. + // `span` is the parse's own dirty rows; `null` keeps the whole-grid repaint. + const start = span ? Math.min(Math.max(span.start, 0), lastRow) : 0 + const end = span ? Math.min(Math.max(span.end, start), lastRow) : lastRow // Why: DOM-rendered Windows ConPTY rewrites need an immediate repair, while - // WebGL can merge this full-grid request into xterm's already-queued frame. + // WebGL can merge this request into xterm's already-queued frame. if (synchronously && typeof terminal._core?.refresh === 'function') { terminal._core.refresh(start, end, true) return @@ -70,20 +84,19 @@ function refreshVisibleRows( } function captureViewportSnapshot(terminal: ForegroundTerminalOutputTarget): ViewportSnapshot { + const active = terminal.buffer?.active return { - baseY: typeof terminal.buffer?.active?.baseY === 'number' ? terminal.buffer.active.baseY : null, - viewportY: - typeof terminal.buffer?.active?.viewportY === 'number' - ? terminal.buffer.active.viewportY - : null + type: typeof active?.type === 'string' ? active.type : null, + cursorY: typeof active?.cursorY === 'number' ? active.cursorY : null, + baseY: typeof active?.baseY === 'number' ? active.baseY : null, + viewportY: typeof active?.viewportY === 'number' ? active.viewportY : null } } function viewportChangedDuringWrite( - terminal: ForegroundTerminalOutputTarget, - beforeWrite: ViewportSnapshot + beforeWrite: ViewportSnapshot, + afterWrite: ViewportSnapshot ): boolean { - const afterWrite = captureViewportSnapshot(terminal) return ( afterWrite.baseY !== null && afterWrite.viewportY !== null && @@ -91,6 +104,39 @@ function viewportChangedDuringWrite( ) } +/** + * The rows this write's repair must cover: the parse's own dirty span widened by + * the cursor rows on both sides of the write. + * + * Why the cursor rows: xterm's WebGL model drops its cursor whenever an update + * pass excludes the cursor row, so a repair that skips it would blank the caret. + * Returns `null` — repaint everything — whenever the span is unknown, the + * viewport scrolled (dirty rows were recorded against the pre-scroll origin), or + * the write flipped between the normal and alternate buffer. + */ +function repairRowSpan( + terminal: ForegroundTerminalOutputTarget, + beforeWrite: ViewportSnapshot, + afterWrite: ViewportSnapshot +): ParsedDirtyRowSpan | null { + if (beforeWrite.type !== afterWrite.type || viewportChangedDuringWrite(beforeWrite, afterWrite)) { + return null + } + const parsed = readParsedDirtyRowSpan(terminal) + if (!parsed) { + return null + } + let { start, end } = parsed + for (const cursorY of [beforeWrite.cursorY, afterWrite.cursorY]) { + if (cursorY === null) { + return null + } + start = Math.min(start, cursorY) + end = Math.max(end, cursorY) + } + return { start, end } +} + function cancelScheduledViewportSettleRefresh(terminal: ForegroundTerminalOutputTarget): void { const pending = pendingViewportSettleRefreshByTerminal.get(terminal) if (!pending) { @@ -133,17 +179,19 @@ function settleForegroundRender( beforeWriteViewport: ViewportSnapshot, options: ForegroundTerminalWriteOptions ): void { + const afterWriteViewport = captureViewportSnapshot(terminal) refreshVisibleRows( terminal, options.shouldRefreshViewportSynchronously?.() ?? true, - options.shouldReleaseRenderPause + options.shouldReleaseRenderPause, + repairRowSpan(terminal, beforeWriteViewport, afterWriteViewport) ) // Why: when output advances the viewport, Chromium can paint the freshly // scrolled top row one frame later than xterm finishes parsing. Repaint once // more after the scroll settles so the user doesn't need to jiggle the window. if ( options.followupViewportRefresh || - viewportChangedDuringWrite(terminal, beforeWriteViewport) + viewportChangedDuringWrite(beforeWriteViewport, afterWriteViewport) ) { scheduleViewportSettleRefresh( terminal, @@ -161,6 +209,11 @@ export function writeForegroundTerminalChunk( const beforeWriteViewport = options.forceViewportRefresh ? captureViewportSnapshot(terminal) : null + if (beforeWriteViewport) { + // Why here and not in the callback: the span must cover only this write's + // parse, and xterm fires its dirty-row request between the two. + resetParsedDirtyRows(terminal) + } // Why guarded steps: this callback runs inside xterm's WriteBuffer loop, // where an escaping throw permanently wedges the terminal (see // xterm-write-callback-guard.ts). Guard settle and onParsed separately so a @@ -190,4 +243,5 @@ export function writeForegroundTerminalChunk( export function discardForegroundRenderSettle(terminal: ForegroundTerminalOutputTarget): void { cancelScheduledViewportSettleRefresh(terminal) + disposeParsedDirtyRows(terminal) } diff --git a/src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts b/src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts new file mode 100644 index 00000000000..de18152558f --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts @@ -0,0 +1,364 @@ +import { Terminal } from '@xterm/headless' +import { describe, expect, it } from 'vitest' + +import { + discardForegroundRenderSettle, + writeForegroundTerminalChunk, + type ForegroundTerminalOutputTarget +} from './pane-terminal-foreground-render-settle' + +/** + * Convergence oracle for the narrowed foreground repaint. + * + * Invariant (`terminal-geometry.visible-convergence`): the rows Orca asks xterm + * to repaint after a forced foreground refresh must cover every viewport row + * whose rendered content changed during that write, plus the cursor row on both + * sides of it. A renderer whose model was converged before the write is then + * still converged after it, so narrowing the span can never strand a stale cell. + * + * A `null` span means "repaint the whole viewport" and trivially converges; the + * corpus below also asserts which cases must stay full-grid. + */ + +type SpanRequest = { start: number; end: number } + +type Harness = { + terminal: Terminal + target: ForegroundTerminalOutputTarget + requests: SpanRequest[] +} + +function createHarness(cols = 80, rows = 24): Harness { + const terminal = new Terminal({ cols, rows, allowProposedApi: true }) + const requests: SpanRequest[] = [] + const target = terminal as unknown as ForegroundTerminalOutputTarget & { + refresh: (start: number, end: number) => void + } + target.refresh = (start: number, end: number) => { + requests.push({ start, end }) + } + return { terminal, target, requests } +} + +function serializeRow(terminal: Terminal, viewportRow: number): string { + // Why the offset: `getLine` indexes the whole buffer, so viewport row 0 is the + // line at `viewportY`. Comparing raw buffer indices would compare scrollback + // that no write can touch and make the oracle vacuous. + const line = terminal.buffer.active.getLine(terminal.buffer.active.viewportY + viewportRow) + if (!line) { + return '' + } + const parts: string[] = [] + for (let x = 0; x < terminal.cols; x++) { + const cell = line.getCell(x) + if (!cell) { + parts.push('~') + continue + } + parts.push( + [ + cell.getChars(), + cell.getWidth(), + cell.getFgColorMode(), + cell.getFgColor(), + cell.getBgColorMode(), + cell.getBgColor(), + cell.isBold(), + cell.isItalic(), + cell.isDim(), + cell.isUnderline(), + cell.isBlink(), + cell.isInverse(), + cell.isInvisible(), + cell.isStrikethrough(), + cell.isOverline() + ].join(':') + ) + } + return parts.join('|') +} + +function snapshotViewport(terminal: Terminal): string[] { + const rows: string[] = [] + for (let y = 0; y < terminal.rows; y++) { + rows.push(serializeRow(terminal, y)) + } + return rows +} + +function changedRows(before: string[], after: string[]): number[] { + const changed: number[] = [] + for (let y = 0; y < Math.max(before.length, after.length); y++) { + if (before[y] !== after[y]) { + changed.push(y) + } + } + return changed +} + +async function writeAndSettle(harness: Harness, data: string): Promise { + await new Promise((resolve) => { + const accepted = writeForegroundTerminalChunk(harness.target, data, { + forceViewportRefresh: true, + // Why: headless has no `_core.refresh`, so drive the public `refresh` path + // the WebGL/async branch uses in the app. + shouldRefreshViewportSynchronously: () => false, + onParsed: () => resolve() + }) + expect(accepted).toBe(true) + }) +} + +/** Seed the pane without measuring: plain writes, drained before the oracle runs. */ +async function seed(harness: Harness, data: string): Promise { + await new Promise((resolve) => { + harness.terminal.write(data, () => resolve()) + }) +} + +type Case = { + name: string + setup?: string + write: string + /** Whole-viewport repaint is required (scroll, buffer flip, unknown span). */ + expectFullGrid?: boolean + cols?: number + rows?: number +} + +const SCROLLBACK_SEED = `${Array.from({ length: 40 }, (_, i) => `line ${i} ${'lorem ipsum '.repeat(3)}`).join('\r\n')}\r\n\r\n\r\n\r\n` + +const CLAUDE_STYLE_REDRAW = `\x1b[?25l\x1b[4A\x1b[2K* Thinking… (12s)\r\n\x1b[2K > tool call 3\r\n\x1b[2K ${'#'.repeat(30)}\r\n\x1b[2K\r\n\x1b[?25h` + +const CASES: Case[] = [ + { + name: 'claude-style in-place redraw of the bottom rows', + setup: SCROLLBACK_SEED, + write: CLAUDE_STYLE_REDRAW + }, + { + name: 'standalone carriage-return overwrite of the current line', + setup: `${SCROLLBACK_SEED}some existing prompt text`, + write: '\rrewritten prompt' + }, + { + name: 'backspace erase', + setup: `${SCROLLBACK_SEED}abcdef`, + write: '\b\b\b \b\b\b' + }, + { + name: 'erase in line to end', + setup: `${SCROLLBACK_SEED}${'x'.repeat(70)}`, + write: '\x1b[20G\x1b[K' + }, + { + name: 'erase in line, whole line', + setup: `${SCROLLBACK_SEED}${'x'.repeat(70)}`, + write: '\x1b[2K' + }, + { + name: 'erase in display from mid-screen to end', + setup: `${SCROLLBACK_SEED}${Array.from({ length: 10 }, (_, i) => `row ${i} ${'y'.repeat(40)}`).join('\r\n')}`, + write: '\x1b[12;5H\x1b[J' + }, + { + name: 'erase in display, above cursor', + setup: `${SCROLLBACK_SEED}${Array.from({ length: 10 }, (_, i) => `row ${i} ${'y'.repeat(40)}`).join('\r\n')}`, + write: '\x1b[12;5H\x1b[1J' + }, + { + name: 'full clear then home', + setup: SCROLLBACK_SEED, + write: '\x1b[2J\x1b[H' + }, + { + name: 'wide CJK glyphs rewritten in place', + setup: `${SCROLLBACK_SEED}${'漢字テスト'.repeat(6)}`, + write: '\r\x1b[2K中文字符测试中文字符测试' + }, + { + name: 'emoji rewritten in place', + setup: `${SCROLLBACK_SEED}status: 🚀🚀🚀 building`, + write: '\r\x1b[2Kstatus: ✅ done 🎉' + }, + { + name: 'combining characters rewritten in place', + setup: `${SCROLLBACK_SEED}café naïve`, + write: '\r\x1b[2Kcafé́ é̀̂ done' + }, + { + name: 'zero-width-joiner sequence', + setup: `${SCROLLBACK_SEED}team: `, + write: '\r\x1b[2Kteam: \u{1F469}‍\u{1F4BB} \u{1F468}‍\u{1F373}' + }, + { + name: 'insert lines inside a scroll region', + setup: `${SCROLLBACK_SEED}${Array.from({ length: 12 }, (_, i) => `region ${i}`).join('\r\n')}`, + write: '\x1b[5;18r\x1b[8;1H\x1b[3L\x1b[r' + }, + { + name: 'delete lines inside a scroll region', + setup: `${SCROLLBACK_SEED}${Array.from({ length: 12 }, (_, i) => `region ${i}`).join('\r\n')}`, + write: '\x1b[5;18r\x1b[8;1H\x1b[3M\x1b[r' + }, + { + name: 'reverse index at the top of the screen scrolls content down', + setup: `${SCROLLBACK_SEED}${Array.from({ length: 12 }, (_, i) => `ri ${i}`).join('\r\n')}`, + write: '\x1b[1;1H\x1bM\x1bM' + }, + { + name: 'cursor jump then paint on a far row', + setup: `${SCROLLBACK_SEED}${Array.from({ length: 12 }, (_, i) => `jump ${i}`).join('\r\n')}`, + write: '\x1b[2;5Hpainted far away\x1b[K' + }, + { + name: 'DEC synchronized-output frame touching scattered rows', + setup: `${SCROLLBACK_SEED}${Array.from({ length: 20 }, (_, i) => `tui ${i}`).join('\r\n')}`, + write: + '\x1b[?2026h\x1b[?25l\x1b[1;2H\x1b[38;2;255;138;0m/ agent\x1b[0m\x1b[9;4Hbody row\x1b[K\x1b[23;2Hstream 0001\x1b[K\x1b[?25h\x1b[?2026l' + }, + { + name: 'newline past the bottom scrolls the viewport', + setup: SCROLLBACK_SEED, + write: '\x1b[2Kfresh output line\r\n', + expectFullGrid: true + }, + { + name: 'entering the alternate screen', + setup: SCROLLBACK_SEED, + write: '\x1b[?1049h\x1b[2J\x1b[H\x1b[Khello alt screen', + expectFullGrid: true + }, + { + name: 'leaving the alternate screen', + setup: `${SCROLLBACK_SEED}\x1b[?1049h\x1b[2J\x1b[Halt content\x1b[K`, + write: '\x1b[?1049l\x1b[2K', + expectFullGrid: true + }, + { + name: 'narrow pane, full-width in-place rewrite', + cols: 40, + rows: 12, + setup: 'z'.repeat(38), + write: `\r\x1b[2K${'q'.repeat(38)}` + } +] + +describe('foreground repaint convergence', () => { + for (const testCase of CASES) { + it(`covers every changed row: ${testCase.name}`, async () => { + const harness = createHarness(testCase.cols ?? 80, testCase.rows ?? 24) + if (testCase.setup) { + await seed(harness, testCase.setup) + } + const before = snapshotViewport(harness.terminal) + const cursorBefore = harness.terminal.buffer.active.cursorY + harness.requests.length = 0 + + await writeAndSettle(harness, testCase.write) + + const after = snapshotViewport(harness.terminal) + const cursorAfter = harness.terminal.buffer.active.cursorY + expect(harness.requests.length).toBeGreaterThan(0) + + const request = harness.requests[0]! + const isFullGrid = request.start === 0 && request.end === harness.terminal.rows - 1 + if (testCase.expectFullGrid) { + expect(isFullGrid).toBe(true) + } + + const dirty = changedRows(before, after) + // Guard against a vacuous oracle: every corpus entry must move the screen. + expect(dirty.length).toBeGreaterThan(0) + for (const row of dirty) { + expect( + row >= request.start && row <= request.end, + `row ${row} changed but repaint span was ${request.start}..${request.end}` + ).toBe(true) + } + // The cursor row must be repainted: xterm's WebGL model drops the caret + // whenever an update pass excludes it. + expect(cursorBefore).toBeGreaterThanOrEqual(request.start) + expect(cursorBefore).toBeLessThanOrEqual(request.end) + expect(cursorAfter).toBeGreaterThanOrEqual(request.start) + expect(cursorAfter).toBeLessThanOrEqual(request.end) + + discardForegroundRenderSettle(harness.target) + harness.terminal.dispose() + }) + } + + it('narrows an in-place bottom-row redraw well below the full grid', async () => { + const harness = createHarness(80, 40) + await seed(harness, SCROLLBACK_SEED) + harness.requests.length = 0 + await writeAndSettle(harness, CLAUDE_STYLE_REDRAW) + const request = harness.requests[0]! + // Bottom-anchored redraw: the span ends on the cursor row but stays a few + // rows tall instead of the 40-row grid the old repair requested. + expect(request.end - request.start + 1).toBeLessThanOrEqual(8) + expect(request.start).toBeGreaterThan(0) + discardForegroundRenderSettle(harness.target) + harness.terminal.dispose() + }) + + it('widens the repair to the cursor rows on both sides of the write', () => { + // Why a double: xterm's own tracker always happens to include the cursor + // row, so only a controlled parse span can prove Orca adds it itself. The + // WebGL model drops the caret when an update pass excludes the cursor row. + const requests: SpanRequest[] = [] + let fire: (event: { start: number; end: number } | undefined) => void = () => {} + const active = { type: 'normal', cursorY: 2, baseY: 0, viewportY: 0 } + const target: ForegroundTerminalOutputTarget = { + rows: 24, + buffer: { active }, + refresh: (start, end) => requests.push({ start, end }), + write: (_data, callback) => { + fire({ start: 8, end: 9 }) + active.cursorY = 17 + callback?.() + }, + _core: { + _inputHandler: { + onRequestRefreshRows: (listener) => { + fire = listener + return { dispose: () => {} } + } + } + } + } as unknown as ForegroundTerminalOutputTarget + writeForegroundTerminalChunk(target, 'x', { + forceViewportRefresh: true, + shouldRefreshViewportSynchronously: () => false + }) + expect(requests).toEqual([{ start: 2, end: 17 }]) + }) + + it('repaints the whole viewport when the parse span cannot be observed', async () => { + const requests: SpanRequest[] = [] + const target: ForegroundTerminalOutputTarget = { + rows: 24, + buffer: { + active: { type: 'normal', cursorY: 3, baseY: 0, viewportY: 0 } + }, + refresh: (start, end) => requests.push({ start, end }), + write: (_data, callback) => callback?.() + } + writeForegroundTerminalChunk(target, 'x', { + forceViewportRefresh: true, + shouldRefreshViewportSynchronously: () => false + }) + expect(requests).toEqual([{ start: 0, end: 23 }]) + }) + + it('keeps the follow-up settle repaint on the whole viewport', async () => { + const harness = createHarness(80, 24) + await seed(harness, SCROLLBACK_SEED) + harness.requests.length = 0 + await writeAndSettle(harness, 'scrolling output\r\n') + // Primary repaint is full-grid because the viewport scrolled. + expect(harness.requests[0]).toEqual({ start: 0, end: 23 }) + discardForegroundRenderSettle(harness.target) + harness.terminal.dispose() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-parsed-dirty-rows.ts b/src/renderer/src/lib/pane-manager/terminal-parsed-dirty-rows.ts new file mode 100644 index 00000000000..0b0185035c3 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-parsed-dirty-rows.ts @@ -0,0 +1,119 @@ +/** + * The viewport row span xterm itself marked dirty while parsing the writes made + * since the last reset. + * + * Why: xterm's InputHandler already tracks exactly which viewport rows a parse + * touched and asks the terminal to repaint them (`onRequestRefreshRows`). Orca's + * foreground settle re-issues that repaint so an in-place agent redraw is painted + * now instead of a frame later. Re-issuing it as `0..rows-1` widened every + * repaint to the whole grid — xterm's render debouncer unions ranges, so one + * full-grid request turns a five-row frame into a whole-viewport `_updateModel` + * pass over every cell. Observing the parse's own dirty span keeps the repair + * and drops the widening. + */ +export type ParsedDirtyRowSpan = { start: number; end: number } + +type RequestRefreshRowsEvent = { start: number; end: number } | undefined + +type ParsedDirtyRowSource = { + _core?: { + _inputHandler?: { + onRequestRefreshRows?: (listener: (event: RequestRefreshRowsEvent) => void) => { + dispose: () => void + } + } + } +} + +type ParsedDirtyRowTracker = { + start: number + end: number + observed: boolean + wholeViewport: boolean + dispose: () => void +} + +// `null` marks a terminal whose parse spans cannot be observed, so callers keep +// the full-grid behavior instead of narrowing on an absent signal. +const trackersByTerminal = new WeakMap() + +function attachTracker(terminal: object): ParsedDirtyRowTracker | null { + const existing = trackersByTerminal.get(terminal) + if (existing !== undefined) { + return existing + } + const subscribe = (terminal as ParsedDirtyRowSource)._core?._inputHandler?.onRequestRefreshRows + const inputHandler = (terminal as ParsedDirtyRowSource)._core?._inputHandler + if (typeof subscribe !== 'function' || !inputHandler) { + trackersByTerminal.set(terminal, null) + return null + } + const tracker: ParsedDirtyRowTracker = { + start: 0, + end: 0, + observed: false, + wholeViewport: false, + dispose: () => {} + } + try { + const subscription = subscribe.call(inputHandler, (event) => { + if (!event) { + // xterm asks for a whole-viewport repaint by firing `undefined`. + tracker.wholeViewport = true + tracker.observed = true + return + } + if (!tracker.observed) { + tracker.start = event.start + tracker.end = event.end + tracker.observed = true + return + } + tracker.start = Math.min(tracker.start, event.start) + tracker.end = Math.max(tracker.end, event.end) + }) + tracker.dispose = () => subscription.dispose() + } catch { + trackersByTerminal.set(terminal, null) + return null + } + trackersByTerminal.set(terminal, tracker) + return tracker +} + +/** Start (or reset) parse-span observation for the write that is about to run. */ +export function resetParsedDirtyRows(terminal: object): void { + const tracker = attachTracker(terminal) + if (!tracker) { + return + } + tracker.observed = false + tracker.wholeViewport = false + tracker.start = 0 + tracker.end = 0 +} + +/** + * The union of parse spans since the last reset, or `null` when the span is + * unknown (unobservable terminal, no parse seen, or an xterm full-refresh + * request) and the caller must repaint the whole viewport. + */ +export function readParsedDirtyRowSpan(terminal: object): ParsedDirtyRowSpan | null { + const tracker = trackersByTerminal.get(terminal) + if (!tracker || !tracker.observed || tracker.wholeViewport) { + return null + } + return { start: tracker.start, end: tracker.end } +} + +export function disposeParsedDirtyRows(terminal: object): void { + const tracker = trackersByTerminal.get(terminal) + if (tracker) { + try { + tracker.dispose() + } catch { + // A disposed terminal has already torn its emitters down. + } + } + trackersByTerminal.delete(terminal) +}