From eaa4a7a065fc2a3d85dee120926c7b45f407a18d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:26:47 -0700 Subject: [PATCH] Stabilize OpenCode terminal rendering for release --- .../use-terminal-pane-global-effects.test.ts | 10 +- .../use-terminal-pane-global-effects.ts | 36 +--- .../use-terminal-pane-lifecycle.test.ts | 72 +------ .../use-terminal-pane-lifecycle.ts | 36 +--- .../lib/pane-manager/pane-manager-types.ts | 1 - .../lib/pane-manager/pane-split-close.test.ts | 51 +---- .../src/lib/pane-manager/pane-split-close.ts | 6 +- .../pane-terminal-output-scheduler.test.ts | 59 ----- .../pane-terminal-output-scheduler.ts | 20 -- ...artificial-opencode-synthetic-injection.ts | 1 + ...nal-opencode-emoji-table-rendering.spec.ts | 201 ++++++++++++++++++ 11 files changed, 223 insertions(+), 270 deletions(-) create mode 100644 tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 44820e6fb8d..f264f3f6b8b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 5cdc7737481..a3dd61a9b2a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -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(() => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index 52a5f21b16c..8c7400939da 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -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 } | 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) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index af0f99f30b3..ef4e7a459e4 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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, - 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 diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index b31ccdeec8a..d0b68c2ddc3 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -26,7 +26,6 @@ export type PaneSpawnHints = { export type ClosedPaneInfo = { paneId: number leafId: TerminalLeafId - terminal?: Terminal } export type PaneManagerOptions = { diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.test.ts b/src/renderer/src/lib/pane-manager/pane-split-close.test.ts index 0b472feb215..9ea4aa0eb7a 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.test.ts @@ -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 = {} parentElement: MockElement | null = null style: Record = {} - 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) => { - 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) => { - 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([ - [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 - }) - }) -}) diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 28366841b51..2d7c50a62f0 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -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?.() } 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 dbc9fef979e..92ec8190fb0 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 @@ -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() 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 506f24300de..7c2de61efb3 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 @@ -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() -const activeOutputTargets = new WeakSet() 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() } diff --git a/tests/e2e/artificial-opencode-synthetic-injection.ts b/tests/e2e/artificial-opencode-synthetic-injection.ts index 4a2f4d9cdac..f6e329bfe81 100644 --- a/tests/e2e/artificial-opencode-synthetic-injection.ts +++ b/tests/e2e/artificial-opencode-synthetic-injection.ts @@ -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('') } diff --git a/tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts b/tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts new file mode 100644 index 00000000000..c2f5c0a2ef5 --- /dev/null +++ b/tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts @@ -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 { + 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('.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 { + 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) + } + }) +})