mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 16:02:49 +00:00
Stabilize OpenCode terminal rendering for release
This commit is contained in:
@@ -12,7 +12,6 @@ const mocks = vi.hoisted(() => ({
|
||||
getTerminalOutputEpoch: vi.fn(() => 0),
|
||||
handleTerminalFileDrop: vi.fn(),
|
||||
requestTerminalBacklogRecovery: vi.fn(),
|
||||
setActiveTerminalOutputTarget: vi.fn(),
|
||||
restoreScrollState: vi.fn(),
|
||||
restoreScrollStateAfterLayout: vi.fn()
|
||||
}))
|
||||
@@ -57,8 +56,7 @@ vi.mock('./pane-helpers', () => ({
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
|
||||
flushTerminalOutput: mocks.flushTerminalOutput,
|
||||
requestTerminalBacklogRecovery: mocks.requestTerminalBacklogRecovery,
|
||||
setActiveTerminalOutputTarget: mocks.setActiveTerminalOutputTarget
|
||||
requestTerminalBacklogRecovery: mocks.requestTerminalBacklogRecovery
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-scroll', () => ({
|
||||
@@ -230,12 +228,11 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
})
|
||||
|
||||
it('reports the active local PTY to the main output scheduler', () => {
|
||||
const terminal = { name: 'terminal-a' }
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1, terminal }]),
|
||||
getPanes: vi.fn(() => [{ id: 1, terminal: { name: 'terminal-a' } }]),
|
||||
resumeRendering: vi.fn(),
|
||||
suspendRendering: vi.fn(),
|
||||
getActivePane: vi.fn(() => ({ id: 1, terminal }))
|
||||
getActivePane: vi.fn(() => ({ id: 1, terminal: { name: 'terminal-a' } }))
|
||||
}
|
||||
const transport = { getPtyId: vi.fn(() => 'pty-active') }
|
||||
const paneTransports = new Map([[1, transport]])
|
||||
@@ -257,7 +254,6 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
})
|
||||
|
||||
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-active', true)
|
||||
expect(mocks.setActiveTerminalOutputTarget).toHaveBeenCalledWith(terminal, true)
|
||||
})
|
||||
|
||||
it('restores from the pre-hide scroll state when hidden layout changes the viewport', () => {
|
||||
|
||||
@@ -12,8 +12,7 @@ import type { PtyTransport } from './pty-transport'
|
||||
import { handleTerminalFileDrop } from './terminal-drop-handler'
|
||||
import {
|
||||
flushTerminalOutput,
|
||||
requestTerminalBacklogRecovery,
|
||||
setActiveTerminalOutputTarget
|
||||
requestTerminalBacklogRecovery
|
||||
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event'
|
||||
import { surfaceStaleAgentRow } from './stale-agent-row'
|
||||
@@ -138,32 +137,17 @@ export function useTerminalPaneGlobalEffects({
|
||||
|
||||
useEffect(() => {
|
||||
const manager = managerRef.current
|
||||
const syncActiveOutputTargets = (activePaneId: number | null): void => {
|
||||
for (const pane of manager?.getPanes() ?? []) {
|
||||
setActiveTerminalOutputTarget(pane.terminal, pane.id === activePaneId)
|
||||
}
|
||||
for (const [paneId, transport] of paneTransportsRef.current) {
|
||||
const ptyId = transport.getPtyId()
|
||||
if (!ptyId || ptyId.startsWith('remote:')) {
|
||||
continue
|
||||
}
|
||||
window.api.pty.setActiveRendererPty?.(ptyId, paneId === activePaneId)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isActive || !isVisible || !manager) {
|
||||
syncActiveOutputTargets(null)
|
||||
const activePane = isActive && isVisible ? manager?.getActivePane() : null
|
||||
const ptyId = activePane
|
||||
? (paneTransportsRef.current.get(activePane.id)?.getPtyId() ?? null)
|
||||
: null
|
||||
if (!ptyId || ptyId.startsWith('remote:')) {
|
||||
return
|
||||
}
|
||||
|
||||
const activePane = manager.getActivePane()
|
||||
const activePaneId = activePane?.id ?? null
|
||||
// Why: active output hints must clear every pane when a tab hides; split
|
||||
// focus can change after this effect's active-pane snapshot.
|
||||
syncActiveOutputTargets(activePaneId)
|
||||
return () => {
|
||||
syncActiveOutputTargets(null)
|
||||
}
|
||||
// Why: main uses this as a scheduler hint only, so the foreground pane's
|
||||
// renderer output gets first chance at the bounded ACK reserve.
|
||||
window.api.pty.setActiveRendererPty?.(ptyId, true)
|
||||
return () => window.api.pty.setActiveRendererPty?.(ptyId, false)
|
||||
}, [isActive, isVisible, managerRef, paneTransportsRef])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,31 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
setActiveTerminalOutputTarget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
|
||||
setActiveTerminalOutputTarget: mocks.setActiveTerminalOutputTarget
|
||||
}))
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
reportActiveRendererPtyForPane,
|
||||
shouldDetachPaneTransportOnUnmount,
|
||||
splitPaneWithOneShotStartup,
|
||||
suppressIntentionalPaneCloseExit
|
||||
} from './use-terminal-pane-lifecycle'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(globalThis as unknown as { window: unknown }).window = {
|
||||
api: {
|
||||
pty: {
|
||||
setActiveRendererPty: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('splitPaneWithOneShotStartup', () => {
|
||||
it('only exposes startup to the intentional split and clears it afterwards', () => {
|
||||
const deps: { startup?: { command: string; env?: Record<string, string> } | null } = {
|
||||
@@ -167,52 +146,3 @@ describe('suppressIntentionalPaneCloseExit', () => {
|
||||
expect(suppressPtyExit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reportActiveRendererPtyForPane', () => {
|
||||
it('marks the active visible pane as the renderer output target', () => {
|
||||
const terminalA = { name: 'terminal-a' }
|
||||
const terminalB = { name: 'terminal-b' }
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [
|
||||
{ id: 1, terminal: terminalA },
|
||||
{ id: 2, terminal: terminalB }
|
||||
])
|
||||
}
|
||||
const paneTransports = new Map([
|
||||
[1, { getPtyId: vi.fn(() => 'pty-1') }],
|
||||
[2, { getPtyId: vi.fn(() => 'remote:env@@pty-2') }]
|
||||
])
|
||||
|
||||
reportActiveRendererPtyForPane(paneTransports as never, manager as never, 2, true)
|
||||
|
||||
expect(mocks.setActiveTerminalOutputTarget).toHaveBeenCalledWith(terminalA, false)
|
||||
expect(mocks.setActiveTerminalOutputTarget).toHaveBeenCalledWith(terminalB, true)
|
||||
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-1', false)
|
||||
expect(window.api.pty.setActiveRendererPty).not.toHaveBeenCalledWith(
|
||||
'remote:env@@pty-2',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('clears every renderer output target while hidden or inactive', () => {
|
||||
const terminalA = { name: 'terminal-a' }
|
||||
const terminalB = { name: 'terminal-b' }
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [
|
||||
{ id: 1, terminal: terminalA },
|
||||
{ id: 2, terminal: terminalB }
|
||||
])
|
||||
}
|
||||
const paneTransports = new Map([
|
||||
[1, { getPtyId: vi.fn(() => 'pty-1') }],
|
||||
[2, { getPtyId: vi.fn(() => 'pty-2') }]
|
||||
])
|
||||
|
||||
reportActiveRendererPtyForPane(paneTransports as never, manager as never, 2, false)
|
||||
|
||||
expect(mocks.setActiveTerminalOutputTarget).toHaveBeenCalledWith(terminalA, false)
|
||||
expect(mocks.setActiveTerminalOutputTarget).toHaveBeenCalledWith(terminalB, false)
|
||||
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-1', false)
|
||||
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-2', false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,7 +61,6 @@ import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-str
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
|
||||
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
|
||||
import { setActiveTerminalOutputTarget } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import {
|
||||
@@ -94,22 +93,16 @@ function extractUncHost(value: string | undefined): string | null {
|
||||
return match?.[1] || null
|
||||
}
|
||||
|
||||
export function reportActiveRendererPtyForPane(
|
||||
function reportActiveRendererPtyForPane(
|
||||
paneTransports: Map<number, PtyTransport>,
|
||||
manager: PaneManager | null,
|
||||
activePaneId: number | null,
|
||||
activeAllowed: boolean
|
||||
activePaneId: number | null
|
||||
): void {
|
||||
const activeTargetPaneId = activeAllowed ? activePaneId : null
|
||||
for (const pane of manager?.getPanes() ?? []) {
|
||||
setActiveTerminalOutputTarget(pane.terminal, activeTargetPaneId === pane.id)
|
||||
}
|
||||
for (const [paneId, transport] of paneTransports) {
|
||||
const ptyId = transport.getPtyId()
|
||||
if (!ptyId || ptyId.startsWith('remote:')) {
|
||||
continue
|
||||
}
|
||||
window.api.pty.setActiveRendererPty?.(ptyId, activeTargetPaneId === paneId)
|
||||
window.api.pty.setActiveRendererPty?.(ptyId, activePaneId === paneId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -829,9 +822,6 @@ export function useTerminalPaneLifecycle({
|
||||
queueResizeAll(true)
|
||||
},
|
||||
onPaneClosed: (paneId, closedPane) => {
|
||||
if (closedPane?.terminal) {
|
||||
setActiveTerminalOutputTarget(closedPane.terminal, false)
|
||||
}
|
||||
const linkProviderDisposable = linkProviderDisposablesRef.current.get(paneId)
|
||||
if (linkProviderDisposable) {
|
||||
linkProviderDisposable.dispose()
|
||||
@@ -891,7 +881,6 @@ export function useTerminalPaneLifecycle({
|
||||
mouseHideDisposablesRef.current.delete(paneId)
|
||||
}
|
||||
const transport = paneTransportsRef.current.get(paneId)
|
||||
const closingPtyId = transport?.getPtyId() ?? null
|
||||
const panePtyBinding = panePtyBindings.get(paneId)
|
||||
if (panePtyBinding) {
|
||||
panePtyBinding.dispose()
|
||||
@@ -914,9 +903,6 @@ export function useTerminalPaneLifecycle({
|
||||
transport,
|
||||
useAppStore.getState().suppressPtyExit
|
||||
)
|
||||
if (closingPtyId && !closingPtyId.startsWith('remote:')) {
|
||||
window.api.pty.setActiveRendererPty?.(closingPtyId, false)
|
||||
}
|
||||
if (ptyId) {
|
||||
// Why: user/CLI pane closes intentionally tear down this PTY after
|
||||
// PaneManager has already promoted the sibling. Suppress that exit
|
||||
@@ -957,19 +943,12 @@ export function useTerminalPaneLifecycle({
|
||||
// stay stuck on the closed pane's last title.
|
||||
const newActivePane = managerRef.current?.getActivePane()
|
||||
if (newActivePane) {
|
||||
reportActiveRendererPtyForPane(
|
||||
paneTransportsRef.current,
|
||||
managerRef.current,
|
||||
newActivePane.id,
|
||||
isActiveRef.current && isVisibleRef.current
|
||||
)
|
||||
reportActiveRendererPtyForPane(paneTransportsRef.current, newActivePane.id)
|
||||
const paneTitles = useAppStore.getState().runtimePaneTitlesByTabId[tabId] ?? {}
|
||||
const activeTitle = paneTitles[newActivePane.id]
|
||||
if (activeTitle) {
|
||||
updateTabTitle(tabId, activeTitle)
|
||||
}
|
||||
} else {
|
||||
reportActiveRendererPtyForPane(paneTransportsRef.current, managerRef.current, null, false)
|
||||
}
|
||||
scheduleRuntimeGraphSync()
|
||||
},
|
||||
@@ -978,12 +957,7 @@ export function useTerminalPaneLifecycle({
|
||||
if (shouldPersistLayout) {
|
||||
persistLayoutSnapshot()
|
||||
}
|
||||
reportActiveRendererPtyForPane(
|
||||
paneTransportsRef.current,
|
||||
managerRef.current,
|
||||
pane.id,
|
||||
isActiveRef.current && isVisibleRef.current
|
||||
)
|
||||
reportActiveRendererPtyForPane(paneTransportsRef.current, pane.id)
|
||||
// Why: when the user switches focus between split panes, update the
|
||||
// tab title to the newly active pane's last-known title so the tab
|
||||
// label reflects the focused agent — not a stale title from the
|
||||
|
||||
@@ -26,7 +26,6 @@ export type PaneSpawnHints = {
|
||||
export type ClosedPaneInfo = {
|
||||
paneId: number
|
||||
leafId: TerminalLeafId
|
||||
terminal?: Terminal
|
||||
}
|
||||
|
||||
export type PaneManagerOptions = {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
const captureScrollState = vi.hoisted(() => vi.fn())
|
||||
const wrapInSplit = vi.hoisted(() => vi.fn())
|
||||
const openTerminal = vi.hoisted(() => vi.fn())
|
||||
const disposePane = vi.hoisted(() => vi.fn())
|
||||
const disposeWebgl = vi.hoisted(() => vi.fn())
|
||||
const clearPendingSplitScrollRestore = vi.hoisted(() => vi.fn())
|
||||
const scheduleSplitScrollRestore = vi.hoisted(() => vi.fn())
|
||||
@@ -23,7 +22,7 @@ vi.mock('./pane-tree-ops', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./pane-lifecycle', () => ({
|
||||
disposePane,
|
||||
disposePane: vi.fn(),
|
||||
openTerminal
|
||||
}))
|
||||
|
||||
@@ -46,7 +45,6 @@ vi.mock('./pane-divider', () => ({
|
||||
}))
|
||||
|
||||
import { splitManagedPane } from './pane-split-close'
|
||||
import { closeManagedPane } from './pane-split-close'
|
||||
|
||||
const TEST_LEAF_ID = '11111111-1111-4111-8111-111111111111' as TerminalLeafId
|
||||
|
||||
@@ -55,7 +53,6 @@ class MockElement {
|
||||
dataset: Record<string, string> = {}
|
||||
parentElement: MockElement | null = null
|
||||
style: Record<string, string> = {}
|
||||
remove = vi.fn()
|
||||
private descendants: MockElement[] = []
|
||||
|
||||
constructor(private readonly classNames: string[]) {
|
||||
@@ -119,11 +116,6 @@ function createPane(id: number, webglAddon: unknown): ManagedPaneInternal {
|
||||
describe('splitManagedPane', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
disposePane.mockImplementation(
|
||||
(pane: ManagedPaneInternal, panes: Map<number, ManagedPaneInternal>) => {
|
||||
panes.delete(pane.id)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('prepares every pane under a moved mounted subtree for split reparenting', () => {
|
||||
@@ -201,44 +193,3 @@ describe('splitManagedPane', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeManagedPane', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
disposePane.mockImplementation(
|
||||
(pane: ManagedPaneInternal, panes: Map<number, ManagedPaneInternal>) => {
|
||||
panes.delete(pane.id)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('reports the closed terminal in the close callback', () => {
|
||||
const closedPane = createPane(1, null)
|
||||
const siblingPane = createPane(2, null)
|
||||
const root = new MockElement(['root'])
|
||||
;(closedPane.container as unknown as MockElement).parentElement = root
|
||||
const panes = new Map<number, ManagedPaneInternal>([
|
||||
[closedPane.id, closedPane],
|
||||
[siblingPane.id, siblingPane]
|
||||
])
|
||||
const onPaneClosed = vi.fn()
|
||||
|
||||
closeManagedPane({
|
||||
paneId: closedPane.id,
|
||||
activePaneId: closedPane.id,
|
||||
panes,
|
||||
root: root as unknown as HTMLElement,
|
||||
styleOptions: {},
|
||||
managerOptions: { onPaneClosed },
|
||||
getDragCallbacks: () => ({}) as never,
|
||||
releasePaneIdentity: vi.fn(),
|
||||
setActivePaneId: vi.fn()
|
||||
})
|
||||
|
||||
expect(onPaneClosed).toHaveBeenCalledWith(closedPane.id, {
|
||||
paneId: closedPane.id,
|
||||
leafId: closedPane.leafId,
|
||||
terminal: closedPane.terminal
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -180,11 +180,7 @@ export function closeManagedPane(args: CloseManagedPaneArgs): void {
|
||||
safeFit(p)
|
||||
}
|
||||
updateMultiPaneState(args.getDragCallbacks())
|
||||
args.managerOptions.onPaneClosed?.(args.paneId, {
|
||||
paneId: args.paneId,
|
||||
leafId: closedLeafId,
|
||||
terminal: pane.terminal
|
||||
})
|
||||
args.managerOptions.onPaneClosed?.(args.paneId, { paneId: args.paneId, leafId: closedLeafId })
|
||||
args.managerOptions.onLayoutChanged?.()
|
||||
}
|
||||
|
||||
|
||||
@@ -612,45 +612,6 @@ describe('pane terminal output scheduler', () => {
|
||||
expect(terminals[2].write).toHaveBeenCalledWith('pane-2')
|
||||
})
|
||||
|
||||
it('drains the active terminal before older queued background terminals', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { setActiveTerminalOutputTarget, writeTerminalOutput } = await loadScheduler()
|
||||
const terminals = [createTerminal(), createTerminal(), createTerminal()]
|
||||
|
||||
terminals.forEach((terminal, index) => {
|
||||
writeTerminalOutput(terminal, `pane-${index}`, { foreground: false })
|
||||
})
|
||||
setActiveTerminalOutputTarget(terminals[2], true)
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
expect(terminals[2].write).toHaveBeenCalledWith('pane-2')
|
||||
expect(terminals[0].write).toHaveBeenCalledWith('pane-0')
|
||||
expect(terminals[1].write).not.toHaveBeenCalled()
|
||||
|
||||
setActiveTerminalOutputTarget(terminals[2], false)
|
||||
})
|
||||
|
||||
it('still drains the active terminal first with one hundred queued terminals', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { setActiveTerminalOutputTarget, writeTerminalOutput } = await loadScheduler()
|
||||
const terminals = Array.from({ length: 100 }, () => createTerminal())
|
||||
const activeTerminal = terminals[99]
|
||||
|
||||
terminals.forEach((terminal, index) => {
|
||||
writeTerminalOutput(terminal, `pane-${index}`, { foreground: false })
|
||||
})
|
||||
setActiveTerminalOutputTarget(activeTerminal, true)
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
expect(activeTerminal.write).toHaveBeenCalledWith('pane-99')
|
||||
expect(terminals[0].write).toHaveBeenCalledWith('pane-0')
|
||||
expect(activeTerminal.write.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
terminals[0].write.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('rotates terminals with remaining backlog behind untouched queued terminals', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
@@ -879,26 +840,6 @@ describe('pane terminal output scheduler', () => {
|
||||
expect(terminal.write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears active priority when terminal output is discarded', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { discardTerminalOutput, setActiveTerminalOutputTarget, writeTerminalOutput } =
|
||||
await loadScheduler()
|
||||
const terminalA = createTerminal()
|
||||
const terminalB = createTerminal()
|
||||
|
||||
setActiveTerminalOutputTarget(terminalB, true)
|
||||
discardTerminalOutput(terminalB)
|
||||
writeTerminalOutput(terminalA, 'new-a', { foreground: false })
|
||||
writeTerminalOutput(terminalB, 'new-b', { foreground: false })
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
expect(terminalB.write).toHaveBeenCalledWith('new-b')
|
||||
expect(terminalA.write).toHaveBeenCalledWith('new-a')
|
||||
expect(terminalA.write.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
terminalB.write.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('survives a write to a disposed terminal during background drain', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
|
||||
@@ -84,7 +84,6 @@ const BACKGROUND_BACKLOG_WARNING =
|
||||
'\x18\x1b[0m\r\n[Orca skipped hidden terminal output because the backlog exceeded 2 MB.]\r\n'
|
||||
|
||||
const queuedByTerminal = new Map<TerminalOutputTarget, QueueEntry>()
|
||||
const activeOutputTargets = new WeakSet<TerminalOutputTarget>()
|
||||
const backlogRecoveryByTerminal = new WeakMap<
|
||||
TerminalOutputTarget,
|
||||
TerminalBacklogRecoveryRequest
|
||||
@@ -526,13 +525,6 @@ function hasDrainableBacklog(): boolean {
|
||||
}
|
||||
|
||||
function takeNextDrainableEntry(): QueueEntry | null {
|
||||
for (const entry of queuedByTerminal.values()) {
|
||||
if (!activeOutputTargets.has(entry.terminal) || !isEntryDrainable(entry)) {
|
||||
continue
|
||||
}
|
||||
queuedByTerminal.delete(entry.terminal)
|
||||
return entry
|
||||
}
|
||||
for (const entry of queuedByTerminal.values()) {
|
||||
if (!isEntryDrainable(entry)) {
|
||||
continue
|
||||
@@ -624,17 +616,6 @@ function drainQueuedOutput(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function setActiveTerminalOutputTarget(
|
||||
terminal: TerminalOutputTarget,
|
||||
active: boolean
|
||||
): void {
|
||||
if (active) {
|
||||
activeOutputTargets.add(terminal)
|
||||
} else {
|
||||
activeOutputTargets.delete(terminal)
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTerminalOutput(
|
||||
terminal: TerminalOutputTarget,
|
||||
data: string,
|
||||
@@ -931,7 +912,6 @@ export function waitForTerminalOutputParsed(terminal: TerminalOutputTarget): Pro
|
||||
export function discardTerminalOutput(terminal: TerminalOutputTarget): void {
|
||||
exposeDebugApi()
|
||||
queuedByTerminal.delete(terminal)
|
||||
activeOutputTargets.delete(terminal)
|
||||
discardForegroundRenderSettle(terminal)
|
||||
recordQueueDebugPressure()
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export async function startSyntheticOpenCodeInjection({
|
||||
`\x1b[1;2H\x1b[38;2;255;138;0m${spinner} OpenCode synthetic agent ${paneIndex}\x1b[0m`,
|
||||
`\x1b[${row};4H\x1b[38;2;231;237;247m${body.padEnd(118, '#')}\x1b[0m`,
|
||||
`\x1b[23;2H\x1b[38;2;106;169;255mstream ${String(frame).padStart(4, '0')} ${'#'.repeat(96)}\x1b[0m`,
|
||||
'\x1b[?25h',
|
||||
'\x1b[?2026l'
|
||||
].join('')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
|
||||
type TerminalRenderState = {
|
||||
coreCursorHidden: boolean | null
|
||||
cursorElementCount: number
|
||||
cursorVisibleElementCount: number
|
||||
hasWebglCanvas: boolean
|
||||
hasComplexScriptOutput: boolean
|
||||
renderer: 'dom' | 'webgl'
|
||||
}
|
||||
|
||||
const EMOJI_TABLE_MARKER = 'ORCA_EMOJI_TABLE_RENDER_DONE'
|
||||
|
||||
function emojiTableScript(marker: string): string {
|
||||
const table = [
|
||||
'| Emoji | Name | Age | Occupation | City | Favorite Color | Pet | Hobby |',
|
||||
'| --- | --- | ---: | --- | --- | --- | --- | --- |',
|
||||
'| 😀 | Alice Johnson | 28 | Engineer | New York | Blue | 🐕 Dog | 🎸 Guitar |',
|
||||
'| 😂 | Bob Smith | 34 | Designer | London | Green | 🐱 Cat | 📚 Reading |',
|
||||
'| 🥰 | Carol Davis | 22 | Student | Paris | Pink | 🐰 Rabbit | 🎨 Painting |',
|
||||
'| 😎 | Dave Wilson | 45 | Architect | Tokyo | Black | 🐢 Turtle | 🏃 Running |',
|
||||
'| 🤩 | Eve Martinez | 31 | Writer | Berlin | Purple | 🐦 Bird | ✈️ Traveling |',
|
||||
'| 😜 | Frank Brown | 27 | Developer | Sydney | Red | 🐹 Hamster | 🎮 Gaming |',
|
||||
'| 🥳 | Grace Lee | 39 | Teacher | Seoul | Yellow | 🐟 Fish | 🌱 Gardening |',
|
||||
'| 🤔 | Henry Taylor | 41 | Doctor | Toronto | White | 🐕 Dog | 🍳 Cooking |',
|
||||
'| 😴 | Ivy Anderson | 26 | Nurse | Chicago | Orange | 🐱 Cat | 🧘 Yoga |',
|
||||
'| 🤗 | Jack Thomas | 33 | Lawyer | Boston | Navy | 🐢 Turtle | 📸 Photography |',
|
||||
'| 😈 | Karen White | 29 | Artist | Miami | Teal | 🐹 Hamster | 🧶 Knitting |',
|
||||
'| 😮 | Leo Harris | 37 | Pilot | Dubai | Gold | 🐦 Bird | 🚁 Drones |',
|
||||
'| 🤠 | Mia Clark | 24 | Barista | Seattle | Coral | 🐰 Rabbit | 🎤 Singing |',
|
||||
'| 😍 | Olivia Hall | 30 | Marketer | Austin | Pink | 🐱 Cat | 🏄 Surfing |'
|
||||
].join('\r\n')
|
||||
|
||||
return `
|
||||
process.stdout.write('\\x1b[?2026h\\x1b[?25l')
|
||||
process.stdout.write('\\x1b[2J\\x1b[H')
|
||||
process.stdout.write(${JSON.stringify(table)})
|
||||
process.stdout.write('\\r\\n${marker}\\r\\n')
|
||||
process.stdout.write('\\x1b[?25h\\x1b[?2026l')
|
||||
setTimeout(() => process.exit(0), 50)
|
||||
`
|
||||
}
|
||||
|
||||
async function readActiveTerminalRenderState(page: Page): Promise<TerminalRenderState> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error('No active terminal pane')
|
||||
}
|
||||
|
||||
const cursorElements = Array.from(
|
||||
pane.container.querySelectorAll<HTMLElement>('.xterm-cursor, .xterm-cursor-layer *')
|
||||
)
|
||||
const cursorVisibleElementCount = cursorElements.filter((element) => {
|
||||
const style = window.getComputedStyle(element)
|
||||
const rect = element.getBoundingClientRect()
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
Number(style.opacity || '1') > 0 &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0
|
||||
)
|
||||
}).length
|
||||
|
||||
const terminal = pane.terminal as {
|
||||
_core?: {
|
||||
coreService?: { isCursorHidden?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
coreCursorHidden:
|
||||
typeof terminal._core?.coreService?.isCursorHidden === 'boolean'
|
||||
? terminal._core.coreService.isCursorHidden
|
||||
: null,
|
||||
cursorElementCount: cursorElements.length,
|
||||
cursorVisibleElementCount,
|
||||
hasWebglCanvas: pane.container.querySelector('.xterm-webgl canvas') !== null,
|
||||
hasComplexScriptOutput: pane.hasComplexScriptOutput === true,
|
||||
renderer: pane.webglAddon ? 'webgl' : 'dom'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function enableRiskyTerminalRendererPath(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
store.setState({
|
||||
settings: {
|
||||
...state.settings!,
|
||||
terminalGpuAcceleration: 'auto',
|
||||
theme: 'dark'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('OpenCode emoji table terminal rendering', () => {
|
||||
test('keeps emoji table output visually sane and restores the cursor', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await enableRiskyTerminalRendererPath(orcaPage)
|
||||
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
const runId = randomUUID()
|
||||
const marker = `${EMOJI_TABLE_MARKER}_${runId}`
|
||||
const scriptPath = path.join(testRepoPath, `.orca-opencode-emoji-table-${runId}.mjs`)
|
||||
writeFileSync(scriptPath, emojiTableScript(marker))
|
||||
try {
|
||||
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
await waitForTerminalOutput(orcaPage, marker, 10_000)
|
||||
await orcaPage.waitForTimeout(250)
|
||||
|
||||
const renderState = await readActiveTerminalRenderState(orcaPage)
|
||||
testInfo.annotations.push({
|
||||
type: 'opencode-emoji-table-rendering',
|
||||
description: JSON.stringify(renderState)
|
||||
})
|
||||
|
||||
expect(renderState.renderer).toBe('dom')
|
||||
expect(renderState.hasWebglCanvas).toBe(false)
|
||||
expect(renderState.coreCursorHidden).toBe(false)
|
||||
expect(renderState.cursorVisibleElementCount).toBeGreaterThan(0)
|
||||
} finally {
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('local real OpenCode demo keeps table rendering and cursor visible', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
process.env.ORCA_E2E_REAL_OPENCODE !== '1',
|
||||
'Set ORCA_E2E_REAL_OPENCODE=1 to exercise the locally installed OpenCode TUI'
|
||||
)
|
||||
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await enableRiskyTerminalRendererPath(orcaPage)
|
||||
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await sendToTerminal(
|
||||
orcaPage,
|
||||
ptyId,
|
||||
'opencode run --demo --interactive "Give me markdown table dummy data a long table with emojis in it"\r'
|
||||
)
|
||||
try {
|
||||
await waitForTerminalOutput(orcaPage, 'Give me markdown table', 15_000)
|
||||
await waitForTerminalOutput(orcaPage, 'Emoji', 60_000)
|
||||
await waitForTerminalOutput(orcaPage, 'Alice', 60_000)
|
||||
await orcaPage.waitForTimeout(1_500)
|
||||
|
||||
await testInfo.attach('real-opencode-demo-table', {
|
||||
body: await orcaPage.screenshot({ fullPage: true }),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
const renderState = await readActiveTerminalRenderState(orcaPage)
|
||||
testInfo.annotations.push({
|
||||
type: 'real-opencode-demo-rendering',
|
||||
description: JSON.stringify(renderState)
|
||||
})
|
||||
expect(renderState.coreCursorHidden).toBe(false)
|
||||
expect(renderState.cursorVisibleElementCount).toBeGreaterThan(0)
|
||||
} finally {
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user