mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 08:02:52 +00:00
fix(terminal): sync foreground renderer refresh
This commit is contained in:
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user