diff --git a/config/scripts/diagnose-terminal-cursor-flicker.mjs b/config/scripts/diagnose-terminal-cursor-flicker.mjs new file mode 100644 index 00000000000..cfb189b6db5 --- /dev/null +++ b/config/scripts/diagnose-terminal-cursor-flicker.mjs @@ -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) +}) diff --git a/config/scripts/record-pty-redraw.mjs b/config/scripts/record-pty-redraw.mjs new file mode 100644 index 00000000000..85d2c1c969e --- /dev/null +++ b/config/scripts/record-pty-redraw.mjs @@ -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) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 5cb40457f98..62db78cd67a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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 () => { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index 11ecafa89b0..655fc6dd659 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -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 () => { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 8e1f02a581c..09482c2f364 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -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 }