mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user