fix(terminal): sync foreground renderer refresh

This commit is contained in:
Jinwoo Hong
2026-05-17 15:58:31 -04:00
parent ad1a38ec2a
commit 34ec3d63cf
5 changed files with 562 additions and 4 deletions
@@ -0,0 +1,343 @@
import { _electron as electron } from '@stablyai/playwright-test'
import { execSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const repoRoot = process.cwd()
const mainPath = path.join(repoRoot, 'out', 'main', 'index.js')
const outputDir = path.join(repoRoot, 'tmp', 'terminal-cursor-diagnostics')
function createDiagnosticRepo() {
const repoPath = mkdtempSync(path.join(os.tmpdir(), 'orca-cursor-diagnostic-repo-'))
execSync('git init', { cwd: repoPath, stdio: 'ignore' })
execSync('git config user.email "cursor-diagnostic@test.local"', { cwd: repoPath, stdio: 'ignore' })
execSync('git config user.name "Cursor Diagnostic"', { cwd: repoPath, stdio: 'ignore' })
writeFileSync(path.join(repoPath, 'README.md'), '# Cursor diagnostic\n')
execSync('git add -A', { cwd: repoPath, stdio: 'ignore' })
execSync('git commit -m "Initial diagnostic repo"', { cwd: repoPath, stdio: 'ignore' })
return repoPath
}
function createUserDataDir() {
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-cursor-diagnostic-'))
writeFileSync(
path.join(userDataDir, 'orca-data.json'),
`${JSON.stringify(
{
settings: {
telemetry: {
optedIn: true,
installId: '00000000-0000-4000-8000-000000000000',
existedBeforeTelemetryRelease: false
},
terminalGpuAcceleration: 'off'
},
onboarding: {
closedAt: 1,
outcome: 'completed',
lastCompletedStep: 4
}
},
null,
2
)}\n`
)
return userDataDir
}
async function addRepoAndOpenTerminal(page, repoPath) {
console.log('[cursor-diagnostic] waiting for renderer')
await page.waitForLoadState('domcontentloaded')
await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
console.log('[cursor-diagnostic] seeding diagnostic worktree')
await page.evaluate((repoPathArg) => {
const store = window.__store
if (!store) {
throw new Error('window.__store unavailable')
}
const repoId = 'cursor-diagnostic-repo'
const worktreeId = `${repoId}::${repoPathArg}`
store.setState({
workspaceSessionReady: true,
repos: [
{
id: repoId,
path: repoPathArg,
displayName: 'Cursor Diagnostic',
badgeColor: '#64748b',
addedAt: Date.now(),
connectionId: null
}
],
worktreesByRepo: {
[repoId]: [
{
id: worktreeId,
repoId,
path: repoPathArg,
head: 'diagnostic',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true,
displayName: 'Cursor Diagnostic',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: Date.now()
}
]
},
activeView: 'terminal',
activeWorktreeId: worktreeId,
activeTabType: 'terminal'
})
store.getState().createTab(worktreeId)
store.getState().setActiveWorktree(worktreeId)
}, repoPath)
console.log('[cursor-diagnostic] waiting for terminal pane')
await page.waitForFunction(
() => {
const store = window.__store
const tabId = store?.getState().activeTabId
return Boolean(tabId && window.__paneManagers?.get(tabId)?.getPanes?.().length)
},
null,
{ timeout: 30_000 }
)
await page.waitForFunction(
() => Boolean(document.querySelector('.xterm-helper-textarea')),
null,
{ timeout: 15_000 }
)
console.log('[cursor-diagnostic] terminal pane ready')
}
async function focusTerminal(page) {
await page.evaluate(() => {
const textarea = document.querySelector('.xterm-helper-textarea')
textarea?.focus()
})
}
async function installSampler(page) {
await page.evaluate(() => {
const globalScope = window
if (globalScope.__orcaCursorDiagnostic?.running) {
return
}
const store = window.__store
const tabId = store?.getState().activeTabId
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const terminal = pane?.terminal
if (!pane || !terminal) {
throw new Error('active terminal pane not available')
}
const isHiddenByAncestor = (element) => {
let node = element
while (node && node !== pane.container) {
const style = window.getComputedStyle(node)
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return true
}
node = node.parentElement
}
return false
}
const renderService = terminal._core?._renderService
const cell = renderService?.dimensions?.css?.cell ?? { width: 0, height: 0 }
const container = pane.container.getBoundingClientRect()
const xterm = pane.container.querySelector('.xterm')
const screen = pane.container.querySelector('.xterm-screen')
const samples = []
const sample = () => {
const cursorCandidates = [
...pane.container.querySelectorAll(
'.xterm-cursor, .xterm-cursor-layer .xterm-cursor, .xterm-cursor-layer *'
)
]
.map((element) => {
const rect = element.getBoundingClientRect()
const style = window.getComputedStyle(element)
const nearestXterm = element.closest('.xterm')
return {
className: element.className,
nearestXtermClassName: nearestXterm?.className ?? '',
parentClassName: element.parentElement?.className ?? '',
hiddenByAncestor: isHiddenByAncestor(element),
left: rect.left - container.left,
top: rect.top - container.top,
width: rect.width,
height: rect.height,
display: style.display,
visibility: style.visibility,
opacity: style.opacity
}
})
.filter(
(rect) =>
rect.width > 0 &&
rect.height > 0 &&
rect.display !== 'none' &&
rect.visibility !== 'hidden' &&
rect.opacity !== '0' &&
!rect.hiddenByAncestor
)
const textarea = pane.container
.querySelector('.xterm-helper-textarea')
?.getBoundingClientRect()
samples.push({
time: performance.now(),
cursorX: terminal.buffer.active.cursorX,
cursorY: terminal.buffer.active.cursorY,
baseY: terminal.buffer.active.baseY,
viewportY: terminal.buffer.active.viewportY,
cellWidth: cell.width,
cellHeight: cell.height,
containerWidth: container.width,
xtermClassName: xterm?.className ?? '',
screenClassName: screen?.className ?? '',
cursorCandidates,
textarea: textarea
? {
left: textarea.left - container.left,
top: textarea.top - container.top,
width: textarea.width,
height: textarea.height
}
: null
})
if (globalScope.__orcaCursorDiagnostic?.running) {
globalScope.__orcaCursorDiagnostic.raf = requestAnimationFrame(sample)
}
}
globalScope.__orcaCursorDiagnostic = { running: true, samples, raf: 0 }
sample()
})
}
async function stopSampler(page) {
return page.evaluate(() => {
const diagnostic = window.__orcaCursorDiagnostic
if (!diagnostic) {
return []
}
diagnostic.running = false
cancelAnimationFrame(diagnostic.raf)
return diagnostic.samples
})
}
function summarizeSamples(samples) {
const withVisualCursor = samples.filter((sample) => sample.cursorCandidates.length > 0)
const hiddenCursorSampleCount = samples.length - withVisualCursor.length
const cellWidth = withVisualCursor.find((sample) => sample.cellWidth > 0)?.cellWidth ?? 0
const anomalies = []
for (const sample of withVisualCursor) {
const cursor = sample.cursorCandidates[0]
if (!cellWidth) {
continue
}
const visualColumn = Math.round(cursor.left / cellWidth)
const delta = visualColumn - sample.cursorX
if (Math.abs(delta) > 3) {
anomalies.push({
time: sample.time,
cursorX: sample.cursorX,
visualColumn,
delta,
cursorLeft: cursor.left,
candidateCount: sample.cursorCandidates.length
})
}
}
return {
sampleCount: samples.length,
visualCursorSampleCount: withVisualCursor.length,
hiddenCursorSampleCount,
cellWidth,
anomalyCount: anomalies.length,
firstAnomalies: anomalies.slice(0, 25),
cursorClasses: [
...new Set(
withVisualCursor.flatMap((sample) =>
sample.cursorCandidates.map((candidate) => String(candidate.className))
)
)
]
}
}
async function driveArrowLeftRepro(page) {
console.log('[cursor-diagnostic] driving ArrowLeft repro')
await focusTerminal(page)
await page.evaluate(() => window.__terminalOutputSchedulerDebug?.reset?.())
await page.keyboard.press('Control+u')
await page.keyboard.type(
`ORCA_CURSOR_DIAGNOSTIC_${'abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789_'.repeat(3)}`,
{ delay: 1 }
)
await installSampler(page)
for (let i = 0; i < 140; i += 1) {
await page.keyboard.press('ArrowLeft', { delay: 2 })
}
await page.waitForTimeout(250)
const samples = await stopSampler(page)
const scheduler = await page.evaluate(
() => window.__terminalOutputSchedulerDebug?.snapshot?.() ?? null
)
console.log(`[cursor-diagnostic] collected ${samples.length} frame samples`)
return { samples, scheduler }
}
async function main() {
if (!existsSync(mainPath)) {
throw new Error('out/main/index.js does not exist; run pnpm run build:electron-vite first')
}
mkdirSync(outputDir, { recursive: true })
const userDataDir = createUserDataDir()
const diagnosticRepo = createDiagnosticRepo()
const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env
void _unused
const app = await electron.launch({
args: [mainPath],
env: {
...cleanEnv,
NODE_ENV: 'development',
ORCA_E2E_USER_DATA_DIR: userDataDir,
ORCA_E2E_HEADFUL: '1'
}
})
try {
console.log('[cursor-diagnostic] launched Electron')
const page = await app.firstWindow({ timeout: 120_000 })
await addRepoAndOpenTerminal(page, diagnosticRepo)
const { samples, scheduler } = await driveArrowLeftRepro(page)
const summary = summarizeSamples(samples)
summary.scheduler = scheduler
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
const samplesPath = path.join(outputDir, `cursor-samples-${stamp}.json`)
const summaryPath = path.join(outputDir, `cursor-summary-${stamp}.json`)
writeFileSync(samplesPath, `${JSON.stringify(samples, null, 2)}\n`)
writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`)
console.log(JSON.stringify({ samplesPath, summaryPath, summary }, null, 2))
if (summary.anomalyCount > 0) {
throw new Error(`cursor diagnostic failed with ${summary.anomalyCount} visible anomalies`)
}
} finally {
await app.close().catch(() => {})
rmSync(userDataDir, { recursive: true, force: true })
rmSync(diagnosticRepo, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
+133
View File
@@ -0,0 +1,133 @@
import pty from 'node-pty'
import { mkdirSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const mode = process.argv[2] ?? 'powershell'
const outputDir = path.join(process.cwd(), 'tmp', 'terminal-cursor-diagnostics')
mkdirSync(outputDir, { recursive: true })
function resolveSpawn(modeName) {
if (modeName === 'powershell') {
return {
file: 'powershell.exe',
args: ['-NoLogo'],
initialDelayMs: 1200
}
}
if (modeName === 'cmd') {
return {
file: 'cmd.exe',
args: [],
initialDelayMs: 700
}
}
if (modeName === 'wsl') {
return {
file: 'wsl.exe',
args: ['--', 'bash', '-li'],
initialDelayMs: 1500
}
}
throw new Error(`unknown mode: ${modeName}`)
}
function summarize(data) {
const esc = String.fromCharCode(27)
const bel = String.fromCharCode(7)
const patterns = [
['cursorLeft', new RegExp(`${esc}\\[[0-9;?]*D`, 'g')],
['cursorRight', new RegExp(`${esc}\\[[0-9;?]*C`, 'g')],
['cursorPosition', new RegExp(`${esc}\\[[0-9;?]*[HfG]`, 'g')],
['erase', new RegExp(`${esc}\\[[0-9;?]*[JK]`, 'g')],
['cursorHide', new RegExp(`${esc}\\[\\?25l`, 'g')],
['cursorShow', new RegExp(`${esc}\\[\\?25h`, 'g')],
['sgr', new RegExp(`${esc}\\[[0-9;:]*m`, 'g')],
['osc', new RegExp(`${esc}\\].*?(?:${bel}|${esc}\\\\)`, 'gs')]
]
return Object.fromEntries(
patterns.map(([name, pattern]) => [name, [...data.matchAll(pattern)].length])
)
}
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms))
}
async function main() {
if (process.platform !== 'win32' && mode !== 'wsl') {
console.warn('[record-pty-redraw] native Windows modes are most useful on Windows')
}
const spawn = resolveSpawn(mode)
const chunks = []
const startedAt = Date.now()
const env = {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'OrcaDiagnostic'
}
const term = pty.spawn(spawn.file, spawn.args, {
name: 'xterm-256color',
cols: 100,
rows: 30,
cwd: process.cwd(),
env
})
term.onData((data) => {
chunks.push({ t: Date.now() - startedAt, data })
})
term.onExit(({ exitCode }) => {
console.log(`[record-pty-redraw] child exited: ${exitCode}`)
})
await sleep(spawn.initialDelayMs)
const input = `ORCA_CURSOR_DIAGNOSTIC_${'abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789_'.repeat(3)}`
term.write('\x15')
await sleep(100)
term.write(input)
await sleep(250)
const beforeArrowsIndex = chunks.length
for (let i = 0; i < 140; i += 1) {
term.write('\x1b[D')
await sleep(8)
}
await sleep(700)
term.write('\x03')
await sleep(200)
try {
term.kill()
} catch {}
const allData = chunks.map((chunk) => chunk.data).join('')
const arrowData = chunks
.slice(beforeArrowsIndex)
.map((chunk) => chunk.data)
.join('')
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
const rawPath = path.join(outputDir, `pty-${mode}-${stamp}.raw.txt`)
const jsonPath = path.join(outputDir, `pty-${mode}-${stamp}.json`)
const summary = {
mode,
platform: process.platform,
release: os.release(),
chunkCount: chunks.length,
totalBytes: Buffer.byteLength(allData),
arrowChunkCount: chunks.length - beforeArrowsIndex,
arrowBytes: Buffer.byteLength(arrowData),
allSequences: summarize(allData),
arrowSequences: summarize(arrowData),
largestChunks: [...chunks]
.sort((a, b) => Buffer.byteLength(b.data) - Buffer.byteLength(a.data))
.slice(0, 10)
.map((chunk) => ({ t: chunk.t, bytes: Buffer.byteLength(chunk.data) }))
}
writeFileSync(rawPath, allData)
writeFileSync(jsonPath, `${JSON.stringify({ summary, chunks }, null, 2)}\n`)
console.log(JSON.stringify({ rawPath, jsonPath, summary }, null, 2))
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -970,7 +970,10 @@ describe('connectPanePty', () => {
expect(capturedDataCallback.current).not.toBeNull()
capturedDataCallback.current?.('visible split output\r\n')
expect(pane.terminal.write).toHaveBeenCalledWith('visible split output\r\n')
expect(pane.terminal.write).toHaveBeenCalledWith(
'visible split output\r\n',
expect.any(Function)
)
})
it('marks panes that receive Arabic output for DOM rendering', async () => {
@@ -993,7 +996,10 @@ describe('connectPanePty', () => {
capturedDataCallback.current?.('Arabic: السلام عليكم\r\n')
expect(manager.markPaneHasComplexScriptOutput).toHaveBeenCalledWith(1)
expect(pane.terminal.write).toHaveBeenCalledWith('Arabic: السلام عليكم\r\n')
expect(pane.terminal.write).toHaveBeenCalledWith(
'Arabic: السلام عليكم\r\n',
expect.any(Function)
)
})
it('reattaches via daemon sessionId when an in-session PTY is live', async () => {
@@ -8,6 +8,22 @@ function createTerminal() {
}
}
function createForegroundTerminal() {
return {
buffer: {
active: {
cursorY: 7
}
},
rows: 24,
refresh: vi.fn(),
_core: {
refresh: vi.fn()
},
write: vi.fn((_data: string, callback?: () => void) => callback?.())
}
}
async function loadScheduler() {
vi.resetModules()
return import('./pane-terminal-output-scheduler')
@@ -24,7 +40,22 @@ describe('pane terminal output scheduler', () => {
writeTerminalOutput(terminal, 'foreground', { foreground: true })
expect(terminal.write).toHaveBeenCalledWith('foreground')
expect(terminal.write).toHaveBeenCalledWith('foreground', expect.any(Function))
})
it('synchronously refreshes visible rows after foreground parse', async () => {
const { writeTerminalOutput } = await loadScheduler()
const terminal = createForegroundTerminal()
terminal.write.mockImplementation((_data: string, callback?: () => void) => {
// Simulate xterm parsing the write before it calls the completion callback.
terminal.buffer.active.cursorY = 3
callback?.()
})
writeTerminalOutput(terminal, 'foreground', { foreground: true })
expect(terminal._core.refresh).toHaveBeenCalledWith(0, 23, true)
expect(terminal.refresh).not.toHaveBeenCalled()
})
it('coalesces background output until the shared drain runs', async () => {
@@ -1,6 +1,17 @@
import { e2eConfig } from '@/lib/e2e-config'
type TerminalOutputTarget = {
element?: HTMLElement | null
buffer?: {
active?: {
cursorY?: number
}
}
_core?: {
refresh?(start: number, end: number, sync?: boolean): void
}
refresh?(start: number, end: number): void
rows?: number
write(data: string, callback?: () => void): void
}
@@ -122,6 +133,40 @@ function writeQueuedChunk(entry: QueueEntry): boolean {
return true
}
function refreshVisibleRowsNow(terminal: TerminalOutputTarget): void {
if (typeof terminal.rows !== 'number' || terminal.rows < 1) {
return
}
const start = 0
const end = Math.max(0, terminal.rows - 1)
try {
// Why: xterm's normal DOM renderer refresh is debounced. On Windows
// ConPTY, PowerShell edit bursts can expose one-frame stale cursor and
// style rows unless the foreground viewport is painted with the parsed
// terminal state before Chromium's next frame.
if (typeof terminal._core?.refresh === 'function') {
terminal._core.refresh(start, end, true)
return
}
terminal.refresh?.(start, end)
} catch {
// Ignore disposed terminals; PTY output can race pane teardown.
}
}
function settleForegroundRender(terminal: TerminalOutputTarget): void {
refreshVisibleRowsNow(terminal)
}
function writeForegroundChunk(terminal: TerminalOutputTarget, data: string): void {
try {
terminal.write(data, () => settleForegroundRender(terminal))
} catch {
settleForegroundRender(terminal)
}
}
function drainQueuedOutput(): void {
drainTimer = null
let writes = 0
@@ -167,7 +212,7 @@ export function writeTerminalOutput(
if (debugEnabled) {
debugState.foregroundWriteCount++
}
terminal.write(data)
writeForegroundChunk(terminal, data)
return
}