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.
This commit is contained in:
Neil
2026-09-02 13:22:29 -07:00
committed by GitHub
parent 6b1cbe54a1
commit d4fa091714
4 changed files with 635 additions and 14 deletions
+84
View File
@@ -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",
@@ -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)
}
@@ -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 '<missing>'
}
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<void> {
await new Promise<void>((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<void> {
await new Promise<void>((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()
})
})
@@ -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<object, ParsedDirtyRowTracker | null>()
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)
}