fix(terminal): preserve OSC 8 links across cold parking (#13382)

* fix(terminal): preserve OSC 8 links across cold parking

* test(terminal): make OSC 8 e2e cross-platform

* test(terminal): focus OSC restore e2e on activation
This commit is contained in:
Brennan Benson 2026-08-09 16:36:12 -07:00 committed by GitHub
parent 44cccb8cd9
commit 774bbc788f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 396 additions and 26 deletions

File diff suppressed because one or more lines are too long

View File

@ -12,7 +12,7 @@ patchedDependencies:
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch
'@xterm/addon-serialize@0.15.0-beta.287':
hash: 96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd
hash: af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c
path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
'@xterm/addon-webgl@0.20.0-beta.286':
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
@ -45,7 +45,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@ -9605,7 +9605,7 @@ snapshots:
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)

View File

@ -0,0 +1,99 @@
// OSC 8 metadata used to disappear from hidden-terminal snapshots while its styling survived.
import './xterm-env-polyfill'
import { describe, expect, it } from 'vitest'
import { Terminal } from '@xterm/headless'
import { SerializeAddon } from '@xterm/addon-serialize'
type TerminalHarness = { terminal: Terminal; addon: SerializeAddon }
type OscLinkData = { id?: string; uri: string }
function createTerminal(): TerminalHarness {
const terminal = new Terminal({ cols: 80, rows: 5, scrollback: 100, allowProposedApi: true })
const addon = new SerializeAddon()
terminal.loadAddon(addon)
return { terminal, addon }
}
function write(terminal: Terminal, data: string): Promise<void> {
return new Promise((resolve) => terminal.write(data, () => resolve()))
}
async function replay(data: string): Promise<Terminal> {
const { terminal } = createTerminal()
await write(terminal, data)
return terminal
}
function oscLinkAt(terminal: Terminal, row: number, col: number): OscLinkData | null {
const cell = terminal.buffer.active.getLine(row)?.getCell(col) as
| { extended?: { urlId?: number } }
| undefined
const linkId = cell?.extended?.urlId ?? 0
if (!linkId) {
return null
}
const internals = terminal as unknown as {
_core?: { _oscLinkService?: { getLinkData: (id: number) => OscLinkData | undefined } }
}
return internals._core?._oscLinkService?.getLinkData(linkId) ?? null
}
describe('OSC 8 hyperlink snapshot round-trip', () => {
it('retains a closed link URI without linking surrounding text', async () => {
const url = 'https://github.com/stablyai/orca/issues/12345'
const { terminal, addon } = createTerminal()
await write(terminal, `before \x1b]8;;${url}\x1b\\#12345\x1b]8;;\x1b\\ after`)
const restored = await replay(addon.serialize())
expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe('before #12345 after')
expect(oscLinkAt(restored, 0, 6)).toBeNull()
expect(oscLinkAt(restored, 0, 7)?.uri).toBe(url)
expect(oscLinkAt(restored, 0, 12)?.uri).toBe(url)
expect(oscLinkAt(restored, 0, 13)).toBeNull()
})
it('retains an explicit OSC link id', async () => {
const url = 'https://example.com/identified'
const { terminal, addon } = createTerminal()
await write(terminal, `\x1b]8;id=review-42;${url}\x1b\\review\x1b]8;;\x1b\\`)
const restored = await replay(addon.serialize())
expect(oscLinkAt(restored, 0, 0)).toEqual({ id: 'review-42', uri: url })
})
it('keeps an open link active for output arriving after replay', async () => {
const url = 'https://example.com/streaming-link'
const { terminal, addon } = createTerminal()
await write(terminal, `\x1b]8;id=stream;${url}\x1b\\linked`)
const restored = await replay(addon.serialize())
await write(restored, 'Z\x1b]8;;\x1b\\')
expect(oscLinkAt(restored, 0, 6)).toEqual({ id: 'stream', uri: url })
})
it('retains the URI across repeated serialize and replay cycles', async () => {
const url = 'https://example.com/repeated'
const { terminal, addon } = createTerminal()
await write(terminal, `\x1b]8;;${url}\x1b\\again\x1b]8;;\x1b\\`)
const first = await replay(addon.serialize())
const secondAddon = new SerializeAddon()
first.loadAddon(secondAddon)
const second = await replay(secondAddon.serialize())
expect(oscLinkAt(second, 0, 0)?.uri).toBe(url)
})
it('does not leak an open alternate-screen link into its unlinked prefix', async () => {
const url = 'https://example.com/alternate'
const { terminal, addon } = createTerminal()
await write(terminal, `normal\x1b[?1049h\x1b[Hprefix \x1b]8;id=alternate;${url}\x1b\\linked`)
const restored = await replay(addon.serialize())
expect(restored.buffer.active.type).toBe('alternate')
expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe('prefix linked')
expect(oscLinkAt(restored, 0, 0)).toBeNull()
expect(oscLinkAt(restored, 0, 7)).toEqual({ id: 'alternate', uri: url })
await write(restored, 'Z\x1b]8;;\x1b\\')
expect(oscLinkAt(restored, 0, 13)).toEqual({ id: 'alternate', uri: url })
})
})

View File

@ -0,0 +1,183 @@
import { randomUUID } from 'node:crypto'
import type { Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking'
import {
ensureTerminalVisible,
getActiveTabId,
getBrowserTabs,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import {
getTerminalContent,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
import { nodeTerminalCommand } from './terminal-node-command'
import { waitForPtyShellEcho } from './terminal-pty-readiness'
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
test.use({
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
})
type LinkProbe = { clientX: number; clientY: number; tabId: string }
async function locateLink(page: Page, label: string): Promise<LinkProbe> {
return page.evaluate((label) => {
const state = window.__store?.getState()
const tabId = state?.activeTabId ?? null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const screen = pane?.terminal.element?.querySelector<HTMLElement>('.xterm-screen')
if (!tabId || !pane || !screen) {
throw new Error('active terminal pane unavailable')
}
const buffer = pane.terminal.buffer.active
for (let row = pane.terminal.rows - 1; row >= 0; row -= 1) {
const line = buffer.getLine(buffer.viewportY + row)
const col = line?.translateToString(true).lastIndexOf(label) ?? -1
if (col >= 0) {
const rect = screen.getBoundingClientRect()
return {
clientX: rect.left + (col + label.length / 2) * (rect.width / pane.terminal.cols),
clientY: rect.top + (row + 0.5) * (rect.height / pane.terminal.rows),
tabId
}
}
}
throw new Error('OSC 8 label not visible in terminal viewport')
}, label)
}
async function readLinkState(
page: Page,
tabId: string,
label: string
): Promise<{
bufferType: string
serializedUri: boolean
underlined: boolean
uri: string | null
}> {
return page.evaluate(
({ label, tabId }) => {
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('terminal pane unavailable')
}
const buffer = pane.terminal.buffer.active
for (let row = buffer.viewportY; row < buffer.viewportY + pane.terminal.rows; row += 1) {
const line = buffer.getLine(row)
const col = line?.translateToString(true).lastIndexOf(label) ?? -1
if (col < 0) {
continue
}
const cell = line?.getCell(col) as
| (ReturnType<NonNullable<typeof line>['getCell']> & {
extended?: { urlId?: number }
})
| undefined
const linkId = cell?.extended?.urlId ?? 0
const terminal = pane.terminal as unknown as {
_core?: {
_oscLinkService?: { getLinkData: (id: number) => { uri: string } | undefined }
}
}
const uri = linkId
? (terminal._core?._oscLinkService?.getLinkData(linkId)?.uri ?? null)
: null
return {
bufferType: buffer.type,
serializedUri: uri ? pane.serializeAddon.serialize().includes(uri) : false,
underlined: !!cell?.isUnderline(),
uri
}
}
throw new Error('OSC 8 label disappeared from terminal buffer')
},
{ label, tabId }
)
}
async function activateTerminalTab(page: Page, tabId: string): Promise<void> {
await page.evaluate((tabId) => {
const state = window.__store?.getState()
if (!state) {
throw new Error('Orca store unavailable')
}
state.setActiveTabType('terminal')
state.setActiveTab(tabId)
}, tabId)
await expect.poll(() => getActiveTabId(page)).toBe(tabId)
await waitForActiveTerminalManager(page, 30_000)
}
test('restores and opens an OSC 8 link after its terminal is cold-parked', async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
const worktreeId = await waitForActiveWorktree(orcaPage)
await orcaPage.evaluate(async () => {
await window.__store?.getState().updateSettings({
openLinksInApp: true,
openLinksInAppPreferencePrompted: true
})
})
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const tabId = await getActiveTabId(orcaPage)
const ptyId = await waitForActivePanePtyId(orcaPage)
await waitForPtyShellEcho(orcaPage, ptyId, 15_000)
const label = `#${randomUUID().slice(0, 6)}`
const url = `https://example.com/orca-osc8-${randomUUID()}`
const linkedOutput = `\x1b[?1049h\x1b[2J\x1b[H\x1b]8;id=cold-park;${url}\x1b\\${label}\x1b]8;;\x1b\\\n`
await sendToTerminal(
orcaPage,
ptyId,
`${nodeTerminalCommand(['-e', `process.stdout.write(${JSON.stringify(linkedOutput)})`])}\r`
)
await expect.poll(() => getTerminalContent(orcaPage, 4_000)).toContain(label)
const baselineProbe = await locateLink(orcaPage, label)
await orcaPage.mouse.move(baselineProbe.clientX, baselineProbe.clientY)
await expect
.poll(() => readLinkState(orcaPage, tabId, label))
.toMatchObject({
bufferType: 'alternate',
serializedUri: true,
underlined: true,
uri: url
})
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, {
parkDelayMs: PARKING_DELAY_MS
})
await activateTerminalTab(orcaPage, tabId)
await expect.poll(() => getTerminalContent(orcaPage, 4_000)).toContain(label)
const restoredProbe = await locateLink(orcaPage, label)
await orcaPage.mouse.move(restoredProbe.clientX, restoredProbe.clientY)
await expect
.poll(() => readLinkState(orcaPage, tabId, label))
.toMatchObject({
bufferType: 'alternate',
serializedUri: true,
underlined: true,
uri: url
})
const isMac = await orcaPage.evaluate(() => navigator.userAgent.includes('Mac'))
const modifier = isMac ? 'Meta' : 'Control'
await orcaPage.keyboard.down(modifier)
await orcaPage.mouse.down()
await orcaPage.mouse.up()
await orcaPage.keyboard.up(modifier)
await expect
.poll(async () => (await getBrowserTabs(orcaPage, worktreeId)).some((tab) => tab.url === url))
.toBe(true)
})